Files
cleveragents-core/robot/helper_audit_wiring.py
T
HAL9000 8ed8750160
CI / push-validation (pull_request) Successful in 23s
CI / helm (pull_request) Successful in 30s
CI / build (pull_request) Successful in 43s
CI / lint (pull_request) Successful in 55s
CI / quality (pull_request) Successful in 54s
CI / typecheck (pull_request) Successful in 57s
CI / security (pull_request) Successful in 1m28s
CI / integration_tests (pull_request) Successful in 3m4s
CI / unit_tests (pull_request) Successful in 7m30s
CI / docker (pull_request) Failing after 14m0s
CI / coverage (pull_request) Failing after 21m7s
CI / status-check (pull_request) Has been cancelled
fix(audit): forward DomainEvent.timestamp to AuditService.record()
AuditService.record() was generating its own timestamp internally,
discarding the original DomainEvent.timestamp. This means audit entries
recorded when an event was audited, not when the domain event actually
occurred, breaking forensic accuracy per §Audit Logging (SEC7).

Changes:
- Add `timestamp: datetime | None = None` keyword parameter to
  AuditService.record(). When provided, uses it as created_at;
  falls back to datetime.now(tz=UTC) for backward compatibility.
  Applied to both the async queue path and the synchronous DB path.
- AuditEventSubscriber._handle_event() now passes timestamp=event.timestamp
  so the original event creation time is preserved in audit entries.
- Add 3 Behave BDD scenarios covering: full pipeline timestamp
  preservation, direct record() with explicit timestamp, and backward
  compatibility (record() without timestamp auto-generates created_at).
- Add preserve_event_timestamp Robot integration test and helper subcommand.
- Add static source check in security_audit.robot verifying the
  timestamp parameter signature exists.

ISSUES CLOSED: #719
2026-05-29 16:36:39 -04:00

480 lines
16 KiB
Python

"""Helper script for audit_service_wiring.robot integration tests.
Each subcommand is a self-contained check that prints a sentinel on success.
"""
from __future__ import annotations
import sys
from pathlib import Path
# Ensure local source tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy.orm import sessionmaker # noqa: E402
from cleveragents.application.services.audit_event_subscriber import ( # noqa: E402
SECURITY_EVENT_MAP,
AuditEventSubscriber,
)
from cleveragents.application.services.audit_service import ( # noqa: E402
AuditService,
)
from cleveragents.application.services.auth_middleware import ( # noqa: E402
TokenAuthMiddleware,
)
from cleveragents.application.services.plan_lifecycle_service import ( # noqa: E402
PlanLifecycleService,
)
from cleveragents.config.settings import Settings # noqa: E402
from cleveragents.infrastructure.database.models import Base # noqa: E402
from cleveragents.infrastructure.events.models import DomainEvent # noqa: E402
from cleveragents.infrastructure.events.reactive import ( # noqa: E402
ReactiveEventBus,
)
from cleveragents.infrastructure.events.types import EventType # noqa: E402
def _make_audit_service() -> AuditService:
"""Create an in-memory AuditService for testing."""
Settings._instance = None
settings = Settings(database_url="sqlite:///:memory:")
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine)()
return AuditService(settings=settings, session=session)
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def record_events() -> None:
"""Verify subscriber records security events through AuditService."""
svc = _make_audit_service()
bus = ReactiveEventBus()
AuditEventSubscriber(audit_service=svc, event_bus=bus)
# Emit all 9 security-relevant event types
for event_type in SECURITY_EVENT_MAP:
bus.emit(DomainEvent(event_type=event_type, plan_id="TEST-001"))
count = svc.count()
if count != len(SECURITY_EVENT_MAP):
print(
f"FAIL: expected {len(SECURITY_EVENT_MAP)} entries, got {count}",
file=sys.stderr,
)
sys.exit(1)
print("AUDIT_OK")
def redact_secrets() -> None:
"""Verify sensitive data in event details is redacted."""
svc = _make_audit_service()
bus = ReactiveEventBus()
AuditEventSubscriber(audit_service=svc, event_bus=bus)
bus.emit(
DomainEvent(
event_type=EventType.CONFIG_CHANGED,
details={
"api_key": "sk-proj-ABCDEFGHIJ1234567890",
"normal": "visible",
},
)
)
entries = svc.list_entries(event_type="config_changed")
if not entries:
print("FAIL: no config_changed entry found", file=sys.stderr)
sys.exit(1)
details = entries[0].details
if details.get("api_key") != "***REDACTED***":
print(
f"FAIL: api_key not redacted: {details.get('api_key')!r}",
file=sys.stderr,
)
sys.exit(1)
if details.get("normal") != "visible":
print(
f"FAIL: normal field changed: {details.get('normal')!r}",
file=sys.stderr,
)
sys.exit(1)
print("REDACT_OK")
def ignore_non_security() -> None:
"""Verify non-security events are not recorded."""
svc = _make_audit_service()
bus = ReactiveEventBus()
AuditEventSubscriber(audit_service=svc, event_bus=bus)
bus.emit(DomainEvent(event_type=EventType.PLAN_CREATED, plan_id="P-nonsec"))
count = svc.count()
if count != 0:
print(f"FAIL: expected 0 entries, got {count}", file=sys.stderr)
sys.exit(1)
print("FILTER_OK")
def container_wiring() -> None:
"""Verify DI container registers a functional AuditEventSubscriber.
Goes beyond type checking: emits an event through the container's
EventBus and verifies it reaches the AuditService via the subscriber.
"""
from dependency_injector import providers
from cleveragents.application.container import Container
# Reset Settings singleton to avoid stale state from earlier tests.
Settings._instance = None
container = Container()
# Override database_url to use an in-memory SQLite database so the
# test does not depend on the filesystem (the default file-based URL
# requires the .cleveragents/ directory to exist).
container.database_url.override(providers.Object("sqlite:///:memory:"))
subscriber = container.audit_event_subscriber()
if not isinstance(subscriber, AuditEventSubscriber):
print(
f"FAIL: expected AuditEventSubscriber, got {type(subscriber).__name__}",
file=sys.stderr,
)
sys.exit(1)
# Functional verification: emit through container's EventBus
# and verify the event reaches the AuditService.
audit_svc = container.audit_service()
# The container's in-memory DB has no tables yet — create them
# so the audit service can persist entries.
db_session = audit_svc._ensure_session()
audit_engine = db_session.get_bind()
Base.metadata.create_all(audit_engine)
# Disable async mode so writes happen synchronously and we can
# verify the count immediately without race conditions.
audit_svc._async_mode = False
bus = container.event_bus()
bus.emit(DomainEvent(event_type=EventType.PLAN_APPLIED, plan_id="WIRE-001"))
count = audit_svc.count()
if count < 1:
print(
f"FAIL: expected >=1 audit entries after emit, got {count}",
file=sys.stderr,
)
sys.exit(1)
print("CONTAINER_OK")
def e2e_plan_lifecycle() -> None:
"""E2E: PlanLifecycleService -> EventBus -> AuditEventSubscriber -> DB.
Exercises the full domain-to-audit pipeline:
1. Create PlanLifecycleService with an EventBus (in-memory mode).
2. Wire AuditEventSubscriber on the same bus.
3. Create an action, use it to create a plan.
4. Cancel the plan (triggers PLAN_CANCELLED event).
5. Verify a plan_cancelled audit entry exists in the DB.
"""
svc = _make_audit_service()
bus = ReactiveEventBus()
AuditEventSubscriber(audit_service=svc, event_bus=bus)
settings = Settings._instance or Settings(database_url="sqlite:///:memory:")
plc = PlanLifecycleService(settings=settings, event_bus=bus)
# Create and register an action
plc.create_action(
name="local/e2e-audit-test",
description="E2E test action",
definition_of_done="verify audit wiring",
strategy_actor="test-strategy",
execution_actor="test-execution",
)
# Use the action to create a plan
plan = plc.use_action("local/e2e-audit-test")
# Cancel the plan -- this emits PLAN_CANCELLED via the EventBus
plan_id = plan.identity.plan_id
plc.cancel_plan(plan_id, reason="e2e audit verification")
entries = svc.list_entries(event_type="plan_cancelled")
if not entries:
print(
"FAIL: no plan_cancelled audit entry after cancel_plan()", file=sys.stderr
)
sys.exit(1)
if entries[0].plan_id != plan_id:
print(
f"FAIL: plan_id mismatch: {entries[0].plan_id!r} != {plan_id!r}",
file=sys.stderr,
)
sys.exit(1)
print("E2E_OK")
def user_identity_field() -> None:
"""Verify DomainEvent.user_identity propagates to audit entry."""
svc = _make_audit_service()
bus = ReactiveEventBus()
AuditEventSubscriber(audit_service=svc, event_bus=bus)
bus.emit(
DomainEvent(
event_type=EventType.SESSION_CREATED,
session_id="SES-FIELD-INT",
user_identity="integration-user@example.com",
details={"mode": "interactive"},
)
)
entries = svc.list_entries(event_type="session_created")
if not entries:
print("FAIL: no session_created entry found", file=sys.stderr)
sys.exit(1)
if entries[0].user_identity != "integration-user@example.com":
print(
f"FAIL: user_identity={entries[0].user_identity!r}, "
f"expected 'integration-user@example.com'",
file=sys.stderr,
)
sys.exit(1)
print("IDENTITY_FIELD_OK")
def user_identity_details_fallback() -> None:
"""Verify backward compat: user_identity from details when field is None."""
svc = _make_audit_service()
bus = ReactiveEventBus()
AuditEventSubscriber(audit_service=svc, event_bus=bus)
bus.emit(
DomainEvent(
event_type=EventType.SESSION_CREATED,
session_id="SES-FALLBACK-INT",
details={
"mode": "interactive",
"user_identity": "fallback-user@example.com",
},
)
)
entries = svc.list_entries(event_type="session_created")
if not entries:
print("FAIL: no session_created entry found", file=sys.stderr)
sys.exit(1)
if entries[0].user_identity != "fallback-user@example.com":
print(
f"FAIL: user_identity={entries[0].user_identity!r}, "
f"expected 'fallback-user@example.com'",
file=sys.stderr,
)
sys.exit(1)
print("IDENTITY_FALLBACK_OK")
def user_identity_field_precedence() -> None:
"""Verify field takes precedence over details for user_identity."""
svc = _make_audit_service()
bus = ReactiveEventBus()
AuditEventSubscriber(audit_service=svc, event_bus=bus)
bus.emit(
DomainEvent(
event_type=EventType.SESSION_CREATED,
session_id="SES-PREC-INT",
user_identity="field-wins@example.com",
details={
"mode": "interactive",
"user_identity": "details-loses@example.com",
},
)
)
entries = svc.list_entries(event_type="session_created")
if not entries:
print("FAIL: no session_created entry found", file=sys.stderr)
sys.exit(1)
if entries[0].user_identity != "field-wins@example.com":
print(
f"FAIL: user_identity={entries[0].user_identity!r}, "
f"expected 'field-wins@example.com'",
file=sys.stderr,
)
sys.exit(1)
# Also verify the details dict does NOT contain user_identity
# (it should have been stripped to avoid duplication).
if "user_identity" in entries[0].details:
print(
"FAIL: user_identity still present in details after field precedence",
file=sys.stderr,
)
sys.exit(1)
print("IDENTITY_PRECEDENCE_OK")
def preserve_event_timestamp() -> None:
"""Verify DomainEvent.timestamp is preserved in the audit entry created_at."""
from datetime import UTC, datetime
svc = _make_audit_service()
bus = ReactiveEventBus()
AuditEventSubscriber(audit_service=svc, event_bus=bus)
known_ts = datetime(2024, 6, 15, 10, 30, 0, tzinfo=UTC)
bus.emit(
DomainEvent(
event_type=EventType.PLAN_APPLIED,
plan_id="PLAN-TS-INT",
timestamp=known_ts,
)
)
entries = svc.list_entries(event_type="plan_applied")
if not entries:
print("FAIL: no plan_applied entry found", file=sys.stderr)
sys.exit(1)
expected = known_ts.strftime("%Y-%m-%dT%H:%M:%S.%f")
if entries[0].created_at != expected:
print(
f"FAIL: created_at={entries[0].created_at!r}, expected {expected!r}",
file=sys.stderr,
)
sys.exit(1)
print("TIMESTAMP_OK")
def auth_middleware_pipeline() -> None:
"""E2E: TokenAuthMiddleware -> EventBus -> AuditEventSubscriber -> DB."""
svc = _make_audit_service()
bus = ReactiveEventBus()
AuditEventSubscriber(audit_service=svc, event_bus=bus)
middleware = TokenAuthMiddleware(expected_token="tok_secret_123", event_bus=bus)
success = middleware.authenticate(
"tok_secret_123",
identity="carol@example.com",
ip_address="10.1.2.3",
)
failure = middleware.authenticate(
"tok_bad_123",
identity="dave@example.com",
ip_address="10.1.2.4",
)
if not success or failure:
print(
"FAIL: unexpected auth boolean results "
f"success={success} failure={failure}",
file=sys.stderr,
)
sys.exit(1)
success_entries = svc.list_entries(event_type="auth_success")
failure_entries = svc.list_entries(event_type="auth_failure")
if len(success_entries) != 1:
print(
f"FAIL: expected 1 auth_success entry, got {len(success_entries)}",
file=sys.stderr,
)
sys.exit(1)
if len(failure_entries) != 1:
print(
f"FAIL: expected 1 auth_failure entry, got {len(failure_entries)}",
file=sys.stderr,
)
sys.exit(1)
s_entry = success_entries[0]
f_entry = failure_entries[0]
s_details = s_entry.details
f_details = f_entry.details
if s_entry.user_identity != "carol@example.com":
print(
f"FAIL: unexpected auth_success identity {s_entry.user_identity!r}",
file=sys.stderr,
)
sys.exit(1)
if f_entry.user_identity != "unauthenticated":
print(
f"FAIL: unexpected auth_failure identity {f_entry.user_identity!r}",
file=sys.stderr,
)
sys.exit(1)
if s_details.get("ip_address") != "10.1.2.3":
print(
f"FAIL: unexpected auth_success ip {s_details.get('ip_address')!r}",
file=sys.stderr,
)
sys.exit(1)
if s_details.get("token_prefix") != "tok_se...":
print(
"FAIL: auth_success token_prefix mismatch",
file=sys.stderr,
)
sys.exit(1)
if f_details.get("ip_address") != "10.1.2.4":
print(
f"FAIL: unexpected auth_failure ip {f_details.get('ip_address')!r}",
file=sys.stderr,
)
sys.exit(1)
if f_details.get("attempted_identity") != "dave@example.com":
print(
"FAIL: auth_failure attempted_identity mismatch",
file=sys.stderr,
)
sys.exit(1)
if f_details.get("failure_reason") != "invalid_token":
print(
f"FAIL: unexpected auth_failure reason {f_details.get('failure_reason')!r}",
file=sys.stderr,
)
sys.exit(1)
if f_details.get("token_prefix") != "tok_ba...":
print(
"FAIL: auth_failure token_prefix mismatch",
file=sys.stderr,
)
sys.exit(1)
print("AUTH_PIPELINE_OK")
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
_COMMANDS = {
"record_events": record_events,
"redact_secrets": redact_secrets,
"ignore_non_security": ignore_non_security,
"container_wiring": container_wiring,
"e2e_plan_lifecycle": e2e_plan_lifecycle,
"user_identity_field": user_identity_field,
"user_identity_details_fallback": user_identity_details_fallback,
"user_identity_field_precedence": user_identity_field_precedence,
"preserve_event_timestamp": preserve_event_timestamp,
"auth_middleware_pipeline": auth_middleware_pipeline,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <command>", file=sys.stderr)
print(f"Commands: {list(_COMMANDS)}", file=sys.stderr)
sys.exit(1)
_COMMANDS[sys.argv[1]]()