Files
cleveragents-core/robot/helper_audit_wiring.py
T
CoreRasurae 97c1007bb5
CI / push-validation (pull_request) Successful in 48s
CI / build (pull_request) Successful in 1m17s
CI / helm (pull_request) Successful in 1m0s
CI / lint (pull_request) Successful in 1m54s
CI / quality (pull_request) Successful in 1m55s
CI / security (pull_request) Successful in 2m5s
CI / typecheck (pull_request) Successful in 2m4s
CI / integration_tests (pull_request) Successful in 5m1s
CI / unit_tests (pull_request) Successful in 5m10s
CI / docker (pull_request) Successful in 1m51s
CI / coverage (pull_request) Successful in 12m47s
CI / status-check (pull_request) Successful in 8s
CI / benchmark-publish (push) Has started running
CI / benchmark-regression (push) Failing after 1m15s
CI / typecheck (push) Has started running
CI / security (push) Has started running
CI / quality (push) Has started running
CI / integration_tests (push) Has started running
CI / unit_tests (push) Has started running
CI / e2e_tests (push) Has started running
CI / lint (push) Successful in 56s
CI / helm (push) Successful in 53s
CI / push-validation (push) Successful in 54s
CI / build (push) Successful in 1m24s
CI / coverage (push) Blocked by required conditions
CI / docker (push) Blocked by required conditions
CI / status-check (push) Blocked by required conditions
feat(events): wire domain services to emit missing EventBus events
Add TokenAuthMiddleware to emit AUTH_SUCCESS/AUTH_FAILURE with spec-aligned audit details and wire it through the DI container using server.token resolution.

Add Behave and Robot coverage for auth event emission and end-to-end audit persistence, and update audit subscriber producer notes and changelog.

ISSUES CLOSED: #714
2026-05-15 22:19:57 +01:00

447 lines
15 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 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,
"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]]()