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
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
480 lines
16 KiB
Python
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]]()
|