mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-06 06:24:12 +00:00
140 lines
4.6 KiB
Python
140 lines
4.6 KiB
Python
"""Audit events, sinks and correlation (EP-08-T02/T03, README.md §27/§28)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from emcp_bus.audit import events
|
|
from emcp_bus.audit.correlation import (
|
|
DecisionCorrelation,
|
|
compute_policy_decision_id,
|
|
new_decision_id,
|
|
)
|
|
from emcp_bus.audit.models import EventCategory
|
|
from emcp_bus.audit.sink import InMemoryAuditSink, JSONLFileAuditSink
|
|
|
|
|
|
def test_tool_call_denied_is_classified_as_grl() -> None:
|
|
event = events.tool_call_denied(
|
|
client_id="sales-read-agent",
|
|
profile_name="Sales.Read",
|
|
tool="finance.payment.execute",
|
|
reason_code="NOT_IN_ENTITLEMENT",
|
|
)
|
|
|
|
assert event.category is EventCategory.GRL
|
|
assert event.reason_code == "NOT_IN_ENTITLEMENT"
|
|
|
|
|
|
def test_tool_call_allowed_is_classified_as_ic() -> None:
|
|
correlation = DecisionCorrelation(
|
|
decision_id="d1",
|
|
policy_decision_id="p1",
|
|
entitlement_version="e1",
|
|
policy_version="v1",
|
|
mcp_request_id="1",
|
|
)
|
|
event = events.tool_call_allowed(
|
|
client_id="sales-read-agent",
|
|
profile_name="Sales.Read",
|
|
tool="sales.customer.get",
|
|
correlation=correlation,
|
|
)
|
|
|
|
assert event.category is EventCategory.IC
|
|
assert event.decision_id == "d1"
|
|
assert event.policy_decision_id == "p1"
|
|
assert event.entitlement_version == "e1"
|
|
|
|
|
|
def test_pdp_unavailable_is_classified_as_noc() -> None:
|
|
event = events.pdp_unavailable(client_id="sales-read-agent", tool="sales.customer.get")
|
|
|
|
assert event.category is EventCategory.NOC
|
|
|
|
|
|
def test_bypass_attempt_detected_is_classified_as_grl() -> None:
|
|
event = events.bypass_attempt_detected(backend="sales-domain", reason="missing_credential")
|
|
|
|
assert event.category is EventCategory.GRL
|
|
assert event.payload == {"backend": "sales-domain", "reason": "missing_credential"}
|
|
|
|
|
|
def test_event_payload_never_contains_a_raw_token_value() -> None:
|
|
"""README.md §27, 'Não registrar': a payload key that looks like a secret
|
|
is replaced by a hash reference, never left as the raw value."""
|
|
event = events.tool_call_allowed(
|
|
client_id="c1",
|
|
profile_name="Sales.Read",
|
|
tool="sales.customer.get",
|
|
correlation=DecisionCorrelation(
|
|
decision_id="d1",
|
|
policy_decision_id="p1",
|
|
entitlement_version=None,
|
|
policy_version=None,
|
|
mcp_request_id=None,
|
|
),
|
|
)
|
|
event.payload = {"backend_token": "super-secret-value", "customer_id": "cust-001"}
|
|
# Re-trigger sanitization the way construction does (model_post_init runs
|
|
# once at construction; this simulates a caller building payload then
|
|
# assigning it, which the real call sites never do -- included as an
|
|
# explicit regression guard on the sanitizer itself).
|
|
from emcp_bus.audit.models import _sanitize
|
|
|
|
sanitized = _sanitize(event.payload)
|
|
assert sanitized["backend_token"].startswith("sha256:")
|
|
assert sanitized["backend_token"] != "super-secret-value"
|
|
assert sanitized["customer_id"] == "cust-001"
|
|
|
|
|
|
def test_in_memory_sink_collects_emitted_events() -> None:
|
|
sink = InMemoryAuditSink()
|
|
event = events.client_authenticated(client_id="c1", mcp_request_id="1")
|
|
|
|
sink.emit(event)
|
|
|
|
assert sink.events == [event]
|
|
|
|
|
|
def test_jsonl_file_sink_appends_one_line_per_event(tmp_path: Path) -> None:
|
|
path = tmp_path / "audit" / "events.jsonl"
|
|
sink = JSONLFileAuditSink(path)
|
|
|
|
sink.emit(events.client_authenticated(client_id="c1", mcp_request_id="1"))
|
|
sink.emit(events.client_authenticated(client_id="c2", mcp_request_id="2"))
|
|
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
assert len(lines) == 2
|
|
assert json.loads(lines[0])["client_id"] == "c1"
|
|
assert json.loads(lines[1])["client_id"] == "c2"
|
|
|
|
|
|
def test_new_decision_id_is_unique_per_call() -> None:
|
|
assert new_decision_id() != new_decision_id()
|
|
|
|
|
|
def test_policy_decision_id_is_deterministic_for_identical_inputs() -> None:
|
|
def _compute() -> str:
|
|
return compute_policy_decision_id(
|
|
policy_version="v1",
|
|
client_profile="Sales.Write",
|
|
tool="sales.quote.create",
|
|
arguments={"amount": 100, "region": "BR-SP"},
|
|
)
|
|
|
|
assert _compute() == _compute()
|
|
|
|
|
|
def test_policy_decision_id_differs_for_different_arguments() -> None:
|
|
def _compute(amount: int) -> str:
|
|
return compute_policy_decision_id(
|
|
policy_version="v1",
|
|
client_profile="Sales.Write",
|
|
tool="sales.quote.create",
|
|
arguments={"amount": amount},
|
|
)
|
|
|
|
assert _compute(100) != _compute(200)
|