mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-06 06:24:12 +00:00
M0 e M1 revisados e fixes implementados
This commit is contained in:
0
RI/src/emcp_bus/approval/__init__.py
Normal file
0
RI/src/emcp_bus/approval/__init__.py
Normal file
52
RI/src/emcp_bus/approval/models.py
Normal file
52
RI/src/emcp_bus/approval/models.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Approval request contract (EP-14-T01, README.md §26).
|
||||
|
||||
Out-of-band, bound to the *exact* operation it was requested for (not just
|
||||
the tool name), with an expiry and single-use semantics -- so an approval
|
||||
granted for one call can never be replayed against a different one (same
|
||||
tool, different arguments) or reused twice (README.md §46, "approval
|
||||
replay").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
def compute_operation_hash(*, client_profile: str, tool: str, arguments: dict[str, Any]) -> str:
|
||||
"""Binds an approval to one exact (client_profile, tool, arguments) triple.
|
||||
|
||||
Canonical JSON (sorted keys) so the same logical call always hashes the
|
||||
same way regardless of dict insertion order.
|
||||
"""
|
||||
canonical = json.dumps(
|
||||
{"client_profile": client_profile, "tool": tool, "arguments": arguments},
|
||||
sort_keys=True,
|
||||
default=str,
|
||||
)
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class ApprovalRequest(BaseModel):
|
||||
approval_id: str = Field(default_factory=lambda: str(uuid4()))
|
||||
operation_hash: str
|
||||
client_profile: str
|
||||
tool: str
|
||||
arguments_summary: dict[str, Any]
|
||||
"""A copy of the relevant arguments for an approver to review -- README.md
|
||||
§26 ("com resumo dos argumentos relevantes"). Not redacted in this RI;
|
||||
a production deployment would apply the same PII-minimization rules as
|
||||
audit logging (README.md §27)."""
|
||||
|
||||
reason_code: str
|
||||
requested_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
expires_at: datetime
|
||||
status: Literal["pending", "granted", "denied", "consumed"] = "pending"
|
||||
|
||||
def is_expired(self, *, now: datetime | None = None) -> bool:
|
||||
return (now or datetime.now(UTC)) >= self.expires_at
|
||||
113
RI/src/emcp_bus/approval/service.py
Normal file
113
RI/src/emcp_bus/approval/service.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Approval service (EP-14-T02, README.md §26).
|
||||
|
||||
Gates a `REQUIRE_APPROVAL` PDP decision (EP-03-T04): the Gateway calls
|
||||
`check_and_consume` for every attempt of an operation that needs approval; a
|
||||
human approver (CLI, API, or a future Orchestrator human-in-the-loop node,
|
||||
EP-11-T05) calls `request`/`grant`/`deny` out-of-band. Approval is never
|
||||
implicit -- a REQUIRE_APPROVAL attempt with no matching grant is denied, not
|
||||
queued-and-allowed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from emcp_bus.approval.models import ApprovalRequest, compute_operation_hash
|
||||
from emcp_bus.audit.events import approval_granted
|
||||
from emcp_bus.audit.sink import AuditSink
|
||||
|
||||
|
||||
class ApprovalNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ApprovalService:
|
||||
def __init__(self, audit_sink: AuditSink | None = None) -> None:
|
||||
self._requests: dict[str, ApprovalRequest] = {}
|
||||
self._audit_sink = audit_sink
|
||||
|
||||
def request(
|
||||
self,
|
||||
*,
|
||||
client_profile: str,
|
||||
tool: str,
|
||||
arguments: dict[str, Any],
|
||||
reason_code: str,
|
||||
ttl_seconds: int = 300,
|
||||
) -> ApprovalRequest:
|
||||
"""Create (or return the existing pending one for) an approval request
|
||||
for this exact operation -- repeated attempts of the same call while
|
||||
one is pending do not spawn duplicate requests for an approver to
|
||||
wade through."""
|
||||
operation_hash = compute_operation_hash(
|
||||
client_profile=client_profile, tool=tool, arguments=arguments
|
||||
)
|
||||
existing = self._pending_for_operation(operation_hash)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
approval = ApprovalRequest(
|
||||
operation_hash=operation_hash,
|
||||
client_profile=client_profile,
|
||||
tool=tool,
|
||||
arguments_summary=arguments,
|
||||
reason_code=reason_code,
|
||||
expires_at=datetime.now(UTC) + timedelta(seconds=ttl_seconds),
|
||||
)
|
||||
self._requests[approval.approval_id] = approval
|
||||
return approval
|
||||
|
||||
def grant(self, approval_id: str) -> None:
|
||||
approval = self._get(approval_id)
|
||||
approval.status = "granted"
|
||||
if self._audit_sink is not None:
|
||||
self._audit_sink.emit(
|
||||
approval_granted(
|
||||
approval_id=approval.approval_id,
|
||||
client_profile=approval.client_profile,
|
||||
tool=approval.tool,
|
||||
)
|
||||
)
|
||||
|
||||
def deny(self, approval_id: str) -> None:
|
||||
approval = self._get(approval_id)
|
||||
approval.status = "denied"
|
||||
|
||||
def check_and_consume(
|
||||
self, *, client_profile: str, tool: str, arguments: dict[str, Any]
|
||||
) -> bool:
|
||||
"""True exactly once for a matching, granted, unexpired approval.
|
||||
|
||||
Single-use (README.md §46, "approval replay"): a granted approval
|
||||
that passes this check is immediately marked `consumed` and can
|
||||
never satisfy a second attempt, even of the identical operation.
|
||||
"""
|
||||
operation_hash = compute_operation_hash(
|
||||
client_profile=client_profile, tool=tool, arguments=arguments
|
||||
)
|
||||
for approval in self._requests.values():
|
||||
if (
|
||||
approval.operation_hash == operation_hash
|
||||
and approval.status == "granted"
|
||||
and not approval.is_expired()
|
||||
):
|
||||
approval.status = "consumed"
|
||||
return True
|
||||
return False
|
||||
|
||||
def _pending_for_operation(self, operation_hash: str) -> ApprovalRequest | None:
|
||||
for approval in self._requests.values():
|
||||
if (
|
||||
approval.operation_hash == operation_hash
|
||||
and approval.status == "pending"
|
||||
and not approval.is_expired()
|
||||
):
|
||||
return approval
|
||||
return None
|
||||
|
||||
def _get(self, approval_id: str) -> ApprovalRequest:
|
||||
approval = self._requests.get(approval_id)
|
||||
if approval is None:
|
||||
raise ApprovalNotFoundError(approval_id)
|
||||
return approval
|
||||
Reference in New Issue
Block a user