Files
ComfyUI-OpenClaw/services/connector_callback_contract.py
T

503 lines
19 KiB
Python

from __future__ import annotations
import hashlib
import hmac
import json
import logging
import time
from dataclasses import dataclass
from enum import Enum
from typing import Any, Dict, Optional
from connector.config import CommandClass
from connector.security_profile import ReplayGuard
from connector.transport_contract import (
CallbackContract,
CallbackError,
CallbackRecord,
CallbackState,
)
try:
from .audit import emit_audit_event
from .connector_installation_registry import (
ConnectorInstallationRegistry,
InstallationResolution,
get_connector_installation_registry,
)
from .connector_replay_lifecycle import ConnectorReplayLifecycle
except ImportError:
from services.audit import emit_audit_event # type: ignore
from services.connector_installation_registry import ( # type: ignore
ConnectorInstallationRegistry,
InstallationResolution,
get_connector_installation_registry,
)
from services.connector_replay_lifecycle import (
ConnectorReplayLifecycle, # type: ignore
)
try:
from .tenant_context import DEFAULT_TENANT_ID, get_current_tenant_id
except ImportError:
from services.tenant_context import ( # type: ignore
DEFAULT_TENANT_ID,
get_current_tenant_id,
)
logger = logging.getLogger("ComfyUI-OpenClaw.services.connector_callback_contract")
DEFAULT_CALLBACK_TIMESTAMP_DRIFT_SEC = 300
_CALLBACK_SIGNING_VERSION = "v1"
class CallbackDecisionCode(str, Enum):
ACCEPT_PUBLIC = "cb_accept_public"
ACCEPT_RUN = "cb_accept_run"
ACCEPT_ADMIN = "cb_accept_admin"
REQUIRE_APPROVAL = "cb_require_approval"
REJECT_SIGNATURE = "cb_reject_signature"
REJECT_TIMESTAMP = "cb_reject_timestamp"
REJECT_PAYLOAD_HASH = "cb_reject_payload_hash"
REJECT_REPLAY = "cb_reject_replay"
REJECT_UNKNOWN_ACTION = "cb_reject_unknown_action"
REJECT_MISSING_INSTALLATION = "cb_reject_missing_installation"
REJECT_AMBIGUOUS_INSTALLATION = "cb_reject_ambiguous_installation"
REJECT_INACTIVE_INSTALLATION = "cb_reject_inactive_installation"
REJECT_STALE_TOKEN_REF = "cb_reject_stale_token_ref"
REJECT_TENANT_MISMATCH = "cb_reject_tenant_mismatch"
REJECT_POLICY_DENIED = "cb_reject_policy_denied"
REJECT_INVALID_ENVELOPE = "cb_reject_invalid_envelope"
@dataclass
class CallbackActorContext:
is_admin: bool = False
is_trusted: bool = False
user_id: str = ""
tenant_id: str = DEFAULT_TENANT_ID
@dataclass
class InteractiveCallbackEnvelope:
signature: str
timestamp: int
request_id: str
workspace_id: str
action_type: str
payload_hash: str
@classmethod
def from_dict(cls, payload: Dict[str, Any]) -> "InteractiveCallbackEnvelope":
try:
timestamp = int(payload.get("timestamp", 0))
except Exception as exc:
raise ValueError("timestamp must be integer") from exc
signature = str(payload.get("signature", "")).strip()
request_id = str(payload.get("request_id", "")).strip()
workspace_id = str(payload.get("workspace_id", "")).strip()
action_type = str(payload.get("action_type", "")).strip()
payload_hash = str(payload.get("payload_hash", "")).strip()
if not all(
[signature, request_id, workspace_id, action_type, payload_hash, timestamp]
):
raise ValueError("callback envelope fields must be non-empty")
return cls(
signature=signature,
timestamp=timestamp,
request_id=request_id,
workspace_id=workspace_id,
action_type=action_type,
payload_hash=payload_hash,
)
@dataclass
class CallbackDecision:
ok: bool
decision_code: str
command_class: str = ""
requires_approval: bool = False
callback_id: str = ""
installation_id: str = ""
message: str = ""
def to_dict(self) -> Dict[str, Any]:
return {
"ok": self.ok,
"decision_code": self.decision_code,
"command_class": self.command_class,
"requires_approval": self.requires_approval,
"callback_id": self.callback_id,
"installation_id": self.installation_id,
"message": self.message,
}
class ConnectorCallbackContract:
"""Platform-agnostic interactive callback validation and policy gate."""
def __init__(
self,
signing_secret: str,
*,
installation_registry: Optional[ConnectorInstallationRegistry] = None,
replay_guard: Optional[ReplayGuard] = None,
replay_lifecycle: Optional[ConnectorReplayLifecycle] = None,
callback_contract: Optional[CallbackContract] = None,
action_policy_map: Optional[Dict[str, str]] = None,
timestamp_drift_sec: int = DEFAULT_CALLBACK_TIMESTAMP_DRIFT_SEC,
):
self._signing_secret = str(signing_secret or "")
self._installation_registry = (
installation_registry or get_connector_installation_registry()
)
self._replay_guard = replay_guard or ReplayGuard(
window_sec=300, max_entries=5000
)
self._replay_lifecycle = replay_lifecycle or ConnectorReplayLifecycle(
ttl_sec=300, max_entries=5000
)
self._callback_contract = callback_contract or CallbackContract()
self._action_policy_map = dict(action_policy_map or {})
self._timestamp_drift_sec = max(1, int(timestamp_drift_sec))
@staticmethod
def compute_payload_hash(payload: Dict[str, Any]) -> str:
raw = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def compute_signature(self, envelope: InteractiveCallbackEnvelope) -> str:
base = (
f"{_CALLBACK_SIGNING_VERSION}:{envelope.timestamp}:{envelope.request_id}:"
f"{envelope.workspace_id}:{envelope.action_type}:{envelope.payload_hash}"
)
return hmac.new(
self._signing_secret.encode("utf-8"),
base.encode("utf-8"),
hashlib.sha256,
).hexdigest()
def build_envelope(
self,
*,
request_id: str,
workspace_id: str,
action_type: str,
payload: Dict[str, Any],
timestamp: Optional[int] = None,
) -> InteractiveCallbackEnvelope:
ts = int(timestamp or time.time())
payload_hash = self.compute_payload_hash(payload)
unsigned = InteractiveCallbackEnvelope(
signature="",
timestamp=ts,
request_id=request_id,
workspace_id=workspace_id,
action_type=action_type,
payload_hash=payload_hash,
)
unsigned.signature = self.compute_signature(unsigned)
return unsigned
def _map_installation_reject(
self, resolution: InstallationResolution
) -> CallbackDecision:
reason = resolution.reject_reason
if reason == "missing_binding":
code = CallbackDecisionCode.REJECT_MISSING_INSTALLATION.value
elif reason == "ambiguous_binding":
code = CallbackDecisionCode.REJECT_AMBIGUOUS_INSTALLATION.value
elif reason == "inactive_binding":
code = CallbackDecisionCode.REJECT_INACTIVE_INSTALLATION.value
elif reason.startswith("stale_token_ref"):
code = CallbackDecisionCode.REJECT_STALE_TOKEN_REF.value
elif reason == "tenant_mismatch":
code = CallbackDecisionCode.REJECT_TENANT_MISMATCH.value
else:
code = CallbackDecisionCode.REJECT_INVALID_ENVELOPE.value
return CallbackDecision(ok=False, decision_code=code, message=reason)
def _resolve_action_policy(self, action_type: str) -> Optional[str]:
if action_type in self._action_policy_map:
return self._action_policy_map[action_type]
for key, value in self._action_policy_map.items():
if key.endswith("*") and action_type.startswith(key[:-1]):
return value
return None
def _audit_decision(
self,
*,
platform: str,
envelope: Optional[InteractiveCallbackEnvelope],
decision: CallbackDecision,
) -> None:
details = {
"platform": platform,
"decision_code": decision.decision_code,
"command_class": decision.command_class,
"requires_approval": decision.requires_approval,
"callback_id": decision.callback_id,
"installation_id": decision.installation_id,
}
if envelope is not None:
details.update(
{
"request_id": envelope.request_id,
"workspace_id": envelope.workspace_id,
"action_type": envelope.action_type,
}
)
emit_audit_event(
action="connector.callback.evaluate",
target=decision.installation_id or "connector_callback",
outcome="allow" if decision.ok else "deny",
status_code=200 if decision.ok else 403,
details=details,
)
@staticmethod
def _request_lifecycle_key(envelope: InteractiveCallbackEnvelope) -> str:
return (
f"interactive:{envelope.workspace_id}:"
f"{envelope.action_type}:{envelope.request_id}"
)
def evaluate(
self,
*,
platform: str,
envelope_dict: Dict[str, Any],
payload: Dict[str, Any],
actor: CallbackActorContext,
) -> CallbackDecision:
try:
envelope = InteractiveCallbackEnvelope.from_dict(envelope_dict)
except ValueError as exc:
decision = CallbackDecision(
ok=False,
decision_code=CallbackDecisionCode.REJECT_INVALID_ENVELOPE.value,
message=str(exc),
)
self._audit_decision(platform=platform, envelope=None, decision=decision)
return decision
now = int(time.time())
if abs(now - envelope.timestamp) > self._timestamp_drift_sec:
decision = CallbackDecision(
ok=False,
decision_code=CallbackDecisionCode.REJECT_TIMESTAMP.value,
message="timestamp_out_of_window",
)
self._audit_decision(
platform=platform, envelope=envelope, decision=decision
)
return decision
expected_hash = self.compute_payload_hash(payload)
if not hmac.compare_digest(expected_hash, envelope.payload_hash):
decision = CallbackDecision(
ok=False,
decision_code=CallbackDecisionCode.REJECT_PAYLOAD_HASH.value,
message="payload_hash_mismatch",
)
self._audit_decision(
platform=platform, envelope=envelope, decision=decision
)
return decision
# CRITICAL: interactive callback signatures must remain canonical and constant-time.
expected_signature = self.compute_signature(envelope)
if not self._signing_secret or not hmac.compare_digest(
expected_signature, envelope.signature
):
decision = CallbackDecision(
ok=False,
decision_code=CallbackDecisionCode.REJECT_SIGNATURE.value,
message="signature_mismatch",
)
self._audit_decision(
platform=platform, envelope=envelope, decision=decision
)
return decision
lifecycle_key = self._request_lifecycle_key(envelope)
claim = self._replay_lifecycle.claim(
lifecycle_key,
metadata={
"workspace_id": envelope.workspace_id,
"action_type": envelope.action_type,
},
)
if not claim.accepted:
decision = CallbackDecision(
ok=False,
decision_code=CallbackDecisionCode.REJECT_REPLAY.value,
message=claim.code,
)
self._audit_decision(
platform=platform, envelope=envelope, decision=decision
)
return decision
resolution = self._installation_registry.resolve_installation(
platform,
envelope.workspace_id,
tenant_id=(actor.tenant_id or get_current_tenant_id()),
)
if not resolution.ok or resolution.installation is None:
decision = self._map_installation_reject(resolution)
self._replay_lifecycle.fail_terminal(
lifecycle_key, reason=decision.decision_code
)
self._audit_decision(
platform=platform, envelope=envelope, decision=decision
)
return decision
command_class = self._resolve_action_policy(envelope.action_type)
if command_class is None:
decision = CallbackDecision(
ok=False,
decision_code=CallbackDecisionCode.REJECT_UNKNOWN_ACTION.value,
installation_id=resolution.installation.installation_id,
message="unknown_action_type",
)
self._replay_lifecycle.fail_terminal(
lifecycle_key, reason=decision.decision_code
)
self._audit_decision(
platform=platform, envelope=envelope, decision=decision
)
return decision
record = self._callback_contract.create(
idempotency_key=envelope.request_id,
payload=payload,
metadata={
"platform": platform,
"workspace_id": envelope.workspace_id,
"action_type": envelope.action_type,
"installation_id": resolution.installation.installation_id,
},
)
if command_class == CommandClass.PUBLIC.value:
decision = CallbackDecision(
ok=True,
decision_code=CallbackDecisionCode.ACCEPT_PUBLIC.value,
command_class=command_class,
callback_id=record.callback_id,
installation_id=resolution.installation.installation_id,
)
elif command_class == CommandClass.RUN.value:
if actor.is_admin or actor.is_trusted:
decision = CallbackDecision(
ok=True,
decision_code=CallbackDecisionCode.ACCEPT_RUN.value,
command_class=command_class,
callback_id=record.callback_id,
installation_id=resolution.installation.installation_id,
)
else:
decision = CallbackDecision(
ok=False,
decision_code=CallbackDecisionCode.REQUIRE_APPROVAL.value,
command_class=command_class,
requires_approval=True,
callback_id=record.callback_id,
installation_id=resolution.installation.installation_id,
)
elif command_class == CommandClass.ADMIN.value:
if actor.is_admin:
decision = CallbackDecision(
ok=True,
decision_code=CallbackDecisionCode.ACCEPT_ADMIN.value,
command_class=command_class,
callback_id=record.callback_id,
installation_id=resolution.installation.installation_id,
)
else:
decision = CallbackDecision(
ok=False,
decision_code=CallbackDecisionCode.REJECT_POLICY_DENIED.value,
command_class=command_class,
callback_id=record.callback_id,
installation_id=resolution.installation.installation_id,
message="admin_required",
)
self._replay_lifecycle.fail_terminal(
lifecycle_key, reason=decision.decision_code
)
else:
decision = CallbackDecision(
ok=False,
decision_code=CallbackDecisionCode.REJECT_UNKNOWN_ACTION.value,
installation_id=resolution.installation.installation_id,
message="unsupported_command_class",
)
self._replay_lifecycle.fail_terminal(
lifecycle_key, reason=decision.decision_code
)
self._audit_decision(platform=platform, envelope=envelope, decision=decision)
return decision
def get_record(self, request_id: str) -> Optional[CallbackRecord]:
return self._callback_contract.get_by_idempotency_key(str(request_id).strip())
def acknowledge_request(self, request_id: str) -> CallbackRecord:
record = self.get_record(request_id)
if record is None:
raise CallbackError(f"Callback not found for request_id={request_id}")
return self._callback_contract.acknowledge(record.callback_id)
def complete_request(self, request_id: str) -> CallbackRecord:
record = self.get_record(request_id)
if record is None:
raise CallbackError(f"Callback not found for request_id={request_id}")
delivered = self._callback_contract.deliver(record.callback_id)
workspace_id = str(delivered.metadata.get("workspace_id", "") or "")
action_type = str(delivered.metadata.get("action_type", "") or "")
if workspace_id and action_type:
self._replay_lifecycle.commit_success(
f"interactive:{workspace_id}:{action_type}:{request_id}",
reason="completed",
)
return delivered
def release_request_retryable(
self, request_id: str, *, reason: str = "retryable_failure"
) -> None:
record = self.get_record(request_id)
if record is None:
return
if record.state != CallbackState.DELIVERED.value:
# IMPORTANT: retryable release must make CallbackContract.create()
# allocate a fresh record on the next accepted claim. Keeping the
# old acknowledged/pending record would fail the second ack path.
record.state = CallbackState.FAILED.value
workspace_id = str(record.metadata.get("workspace_id", "") or "")
action_type = str(record.metadata.get("action_type", "") or "")
if workspace_id and action_type:
self._replay_lifecycle.release_retryable(
f"interactive:{workspace_id}:{action_type}:{request_id}",
reason=reason,
)
def fail_request_terminal(
self, request_id: str, *, reason: str = "terminal_failure"
) -> None:
record = self.get_record(request_id)
if record is None:
return
if record.state != CallbackState.DELIVERED.value:
record.state = CallbackState.FAILED.value
workspace_id = str(record.metadata.get("workspace_id", "") or "")
action_type = str(record.metadata.get("action_type", "") or "")
if workspace_id and action_type:
self._replay_lifecycle.fail_terminal(
f"interactive:{workspace_id}:{action_type}:{request_id}",
reason=reason,
)