feat(connectors): add SessionTransport and SessionConnector base
Session-based connectors acquire credentials at invocation time from CredentialResolver, manage transport lifecycle with cleanup guarantees, and never store credentials. Signed-off-by: Tyler King <tking@guildhouse.dev>
This commit is contained in:
parent
24eefe1699
commit
5a759f5e12
1 changed files with 138 additions and 0 deletions
138
gsap_broker/connectors/session.py
Normal file
138
gsap_broker/connectors/session.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
# Copyright 2026 Guildhouse Dev
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
"""Session-based connector framework.
|
||||||
|
|
||||||
|
Session connectors establish stateful connections to target endpoints
|
||||||
|
(SSH, WinRM/PSRP, Shellstream/Bascule) and execute commands over them.
|
||||||
|
|
||||||
|
Credential lifecycle:
|
||||||
|
1. ``SessionConnector.invoke()`` is called with an operation and
|
||||||
|
target.
|
||||||
|
2. The connector calls ``CredentialResolver.resolve()`` to acquire
|
||||||
|
a short-lived, scoped credential for that target.
|
||||||
|
3. The credential is passed to ``SessionTransport.connect()`` which
|
||||||
|
uses it to establish the session.
|
||||||
|
4. The command is executed via ``SessionTransport.execute()``.
|
||||||
|
5. ``SessionTransport.disconnect()`` is called in a finally block
|
||||||
|
— guaranteed even on failure.
|
||||||
|
6. The credential goes out of scope and is garbage-collected.
|
||||||
|
No reference is stored anywhere in the broker.
|
||||||
|
|
||||||
|
Rust port note:
|
||||||
|
``SessionTransport`` maps to an async trait with an associated
|
||||||
|
error type. ``SessionConnector`` becomes a generic struct
|
||||||
|
parameterized by the transport type. The finally-block cleanup
|
||||||
|
maps to Drop + an async shutdown method (or a wrapper that calls
|
||||||
|
disconnect on drop via ``tokio::spawn``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Optional, Type
|
||||||
|
|
||||||
|
from gsap_broker.connectors.base import ConnectorContext, ConnectorPlugin, ConnectorResult
|
||||||
|
from gsap_broker.credentials.resolver import Credential, CredentialResolver
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionTransport(ABC):
|
||||||
|
"""A stateful connection to a target endpoint.
|
||||||
|
|
||||||
|
Implementations wrap protocol-specific clients:
|
||||||
|
- ``BasculeTransport``: Shellstream via Bascule proxy
|
||||||
|
- ``PowerShellTransport``: PSRP via pypsrp
|
||||||
|
- ``SSHTransport``: SSH via asyncssh (future)
|
||||||
|
|
||||||
|
Transports are ephemeral — created per invocation, not pooled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
transport_id: str = ""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def connect(self, target: str, credential: Credential) -> None:
|
||||||
|
"""Establish the session using the provided credential."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def execute(self, command: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||||||
|
"""Execute a command over the established session.
|
||||||
|
|
||||||
|
Returns a dict with at minimum ``stdout``, ``stderr``,
|
||||||
|
``exit_code`` keys (for shell transports) or
|
||||||
|
transport-specific structured output.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
"""Tear down the session. MUST be idempotent."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def is_alive(self) -> bool:
|
||||||
|
"""Check if the session is still usable."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class SessionConnector(ConnectorPlugin):
|
||||||
|
"""Base for connectors that establish sessions to endpoints.
|
||||||
|
|
||||||
|
Subclasses set ``credential_type`` and ``transport_class``
|
||||||
|
to wire the connector to a specific transport and credential
|
||||||
|
backend.
|
||||||
|
|
||||||
|
The ``invoke()`` method handles the full lifecycle:
|
||||||
|
credential acquisition → transport connect → execute →
|
||||||
|
disconnect, with guaranteed cleanup on failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
credential_type: str = ""
|
||||||
|
transport_class: Type[SessionTransport] = SessionTransport # overridden by subclass
|
||||||
|
|
||||||
|
def __init__(self, credential_resolver: CredentialResolver):
|
||||||
|
self._resolver = credential_resolver
|
||||||
|
|
||||||
|
async def invoke(
|
||||||
|
self, operation: str, parameters: dict[str, Any], context: ConnectorContext
|
||||||
|
) -> ConnectorResult:
|
||||||
|
target = parameters.get("target", "")
|
||||||
|
if not target:
|
||||||
|
return ConnectorResult(success=False, error="target required for session connector")
|
||||||
|
|
||||||
|
# Build an AC-like context dict for the resolver.
|
||||||
|
ac_context = {
|
||||||
|
"gsap_context_id": context.gsap_context_id,
|
||||||
|
"accord": {"template": getattr(self, "accord_template", "")},
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
credential = await self._resolver.resolve(
|
||||||
|
self.credential_type, target, ac_context
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return ConnectorResult(success=False, error=f"Credential resolution failed: {e}")
|
||||||
|
|
||||||
|
transport = self.transport_class()
|
||||||
|
try:
|
||||||
|
await transport.connect(target, credential)
|
||||||
|
result = await transport.execute(operation, parameters)
|
||||||
|
return ConnectorResult(success=True, data=result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Session connector %s failed: %s", self.connector_id, e)
|
||||||
|
return ConnectorResult(success=False, error=str(e))
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await transport.disconnect()
|
||||||
|
except Exception as cleanup_err:
|
||||||
|
logger.warning(
|
||||||
|
"Transport disconnect failed for %s: %s",
|
||||||
|
self.connector_id,
|
||||||
|
cleanup_err,
|
||||||
|
)
|
||||||
|
|
||||||
|
def health_check(self) -> bool:
|
||||||
|
return True
|
||||||
Loading…
Reference in a new issue