diff --git a/gsap_broker/routers/connectors.py b/gsap_broker/routers/connectors.py index 24d63b2..e329347 100644 --- a/gsap_broker/routers/connectors.py +++ b/gsap_broker/routers/connectors.py @@ -21,7 +21,27 @@ _runtime = ConnectorRuntime(registry=_registry) # Register built-in connectors _registry.register(EchoConnector()) -# Conditionally register Intune connector +# ── Credential resolver (shared by session connectors) ────────── +from gsap_broker.credentials.resolver import CredentialResolver + +_credential_resolver = CredentialResolver() + +if settings.credential_backend == "stub" or ( + settings.credential_backend == "auto" and not settings.entra_client_secret +): + from gsap_broker.credentials.stub_backend import StubCredentialBackend + _credential_resolver.register(StubCredentialBackend()) +elif settings.entra_client_secret: + from gsap_broker.credentials.entra_backend import EntraCredentialBackend + _credential_resolver.register(EntraCredentialBackend( + tenant_id=settings.entra_tenant_id, + client_id=settings.entra_client_id, + client_secret=settings.entra_client_secret, + )) + +# ── Conditionally register connectors ─────────────────────────── + +# Intune (API-mediated — uses GraphClient, not CredentialResolver) if settings.intune_enabled and settings.entra_client_secret: from gsap_broker.intune.graph_client import GraphClient from gsap_broker.intune.device_cache import DeviceComplianceCache @@ -36,6 +56,21 @@ if settings.intune_enabled and settings.entra_client_secret: _intune_connector = IntuneConnector(graph_client=_intune_graph, cache=_intune_cache) _registry.register(_intune_connector) +# Bascule (session-based — uses CredentialResolver) +if settings.bascule_enabled: + from gsap_broker.connectors.bascule import BasculeConnector + _registry.register(BasculeConnector(credential_resolver=_credential_resolver)) + +# PowerShell (session-based — uses CredentialResolver) +if settings.powershell_enabled: + from gsap_broker.connectors.powershell import PowerShellConnector + _registry.register(PowerShellConnector(credential_resolver=_credential_resolver)) + +# Ansible (orchestrator — uses CredentialResolver) +if settings.ansible_enabled: + from gsap_broker.connectors.ansible import AnsibleConnector + _registry.register(AnsibleConnector(credential_resolver=_credential_resolver)) + class InvokeRequest(BaseModel): operation: str diff --git a/gsap_broker/settings.py b/gsap_broker/settings.py index ba68f95..7849aa0 100644 --- a/gsap_broker/settings.py +++ b/gsap_broker/settings.py @@ -41,6 +41,16 @@ class Settings(BaseSettings): intune_compliance_strict: bool = False # reject if no device_id present intune_compliance_cache_ttl: int = 300 # seconds + # ── Session connectors ── + bascule_enabled: bool = False + powershell_enabled: bool = False + ansible_enabled: bool = False + + # ── Credential backend ── + # "auto" | "entra" | "stub" + # auto: use Entra if entra_client_secret is set, else stub + credential_backend: str = "auto" + # Delegation defaults default_delegation_ttl_minutes: int = 60 default_max_commands: int = 500 diff --git a/tests/test_credentials.py b/tests/test_credentials.py new file mode 100644 index 0000000..425978c --- /dev/null +++ b/tests/test_credentials.py @@ -0,0 +1,316 @@ +# Copyright 2026 Guildhouse Dev +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for credential resolver, backends, and session connectors.""" + +import pytest +from datetime import datetime, timedelta, UTC + +from gsap_broker.credentials.resolver import ( + BasculeCredential, + Credential, + CredentialResolver, + CredentialResolutionError, + KerberosCredential, + NoBackendAvailable, + OAuthCredential, + SSHCertCredential, +) +from gsap_broker.credentials.stub_backend import StubCredentialBackend +from gsap_broker.connectors.base import ConnectorContext +from gsap_broker.connectors.bascule import BasculeConnector +from gsap_broker.connectors.powershell import PowerShellConnector +from gsap_broker.connectors.ansible import AnsibleConnector +from gsap_broker.connectors.orchestrator import WorkflowPlan, WorkflowStep +from gsap_broker.routing.device_router import DeviceRouter, UnknownDevice + + +AC_CONTEXT = { + "gsap_context_id": "test-ac", + "expires_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(), + "accord": {"template": "test-ops"}, + "principal": {"did": "did:web:test/p/alice"}, +} + + +# ── CredentialResolver ──────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_resolver_routes_to_correct_backend(): + """TEST 2: Resolver routes each credential type to the stub backend.""" + resolver = CredentialResolver() + resolver.register(StubCredentialBackend()) + + for cred_type, expected_cls in [ + ("bascule_ac", BasculeCredential), + ("kerberos", KerberosCredential), + ("oauth", OAuthCredential), + ("ssh_cert", SSHCertCredential), + ]: + cred = await resolver.resolve(cred_type, "target-1", AC_CONTEXT) + assert isinstance(cred, expected_cls) + assert cred.expires_at is not None + assert not cred.expired + + +@pytest.mark.asyncio +async def test_resolver_rejects_unknown_type(): + """TEST 3: Unknown credential type raises NoBackendAvailable.""" + resolver = CredentialResolver() + resolver.register(StubCredentialBackend()) + + with pytest.raises(NoBackendAvailable) as exc_info: + await resolver.resolve("quantum_key", "target", AC_CONTEXT) + assert "quantum_key" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_bascule_credential_preserves_ac(): + """TEST 4: Bascule credential passes AC through.""" + resolver = CredentialResolver() + resolver.register(StubCredentialBackend()) + + cred = await resolver.resolve("bascule_ac", "target-1", AC_CONTEXT) + assert isinstance(cred, BasculeCredential) + assert cred.authorization_context == AC_CONTEXT + + +@pytest.mark.asyncio +async def test_stub_credentials_not_expired(): + """TEST 5: Stub backend returns credentials that are not expired.""" + resolver = CredentialResolver() + resolver.register(StubCredentialBackend()) + + for cred_type in ["bascule_ac", "kerberos", "oauth", "ssh_cert"]: + cred = await resolver.resolve(cred_type, "target", AC_CONTEXT) + assert not cred.expired + + +# ── SessionConnector lifecycle ──────────────────────────────────── + + +@pytest.mark.asyncio +async def test_session_connector_success(): + """TEST 6: Full lifecycle — resolve, connect, execute, disconnect.""" + resolver = CredentialResolver() + resolver.register(StubCredentialBackend()) + + connector = BasculeConnector(credential_resolver=resolver) + ctx = ConnectorContext(gsap_context_id="test-ac") + + result = await connector.invoke("run-script", {"target": "node-1"}, ctx) + + assert result.success + assert result.data["target"] == "node-1" + assert result.data["command"] == "run-script" + assert result.data["stub"] is True + + +@pytest.mark.asyncio +async def test_session_connector_missing_target(): + """Session connector requires target parameter.""" + resolver = CredentialResolver() + resolver.register(StubCredentialBackend()) + + connector = BasculeConnector(credential_resolver=resolver) + ctx = ConnectorContext(gsap_context_id="test-ac") + + result = await connector.invoke("run-script", {}, ctx) + assert not result.success + assert "target required" in result.error + + +@pytest.mark.asyncio +async def test_session_connector_transport_failure(): + """TEST 7: Transport failure still calls disconnect (cleanup guarantee).""" + from gsap_broker.connectors.session import SessionTransport, SessionConnector + from gsap_broker.credentials.resolver import Credential + + disconnect_called = False + + class FailingTransport(SessionTransport): + transport_id = "failing" + + async def connect(self, target, credential): + pass + + async def execute(self, command, params=None): + raise RuntimeError("Transport exploded") + + async def disconnect(self): + nonlocal disconnect_called + disconnect_called = True + + async def is_alive(self): + return False + + class FailingConnector(SessionConnector): + connector_id = "failing" + corpus_entry_cid = "sha256:test" + credential_type = "bascule_ac" + transport_class = FailingTransport + capability_mask = 1 + declared_endpoints = [] + + resolver = CredentialResolver() + resolver.register(StubCredentialBackend()) + + connector = FailingConnector(credential_resolver=resolver) + ctx = ConnectorContext(gsap_context_id="test-ac") + + result = await connector.invoke("crash", {"target": "node-1"}, ctx) + + assert not result.success + assert "exploded" in result.error + assert disconnect_called, "disconnect() must be called even on failure" + + +# ── PowerShell connector ────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_powershell_connector_stub(): + """PowerShell connector lifecycle with stubbed transport.""" + resolver = CredentialResolver() + resolver.register(StubCredentialBackend()) + + connector = PowerShellConnector(credential_resolver=resolver) + ctx = ConnectorContext(gsap_context_id="test-ac") + + result = await connector.invoke("Get-Process", {"target": "win-srv-01:5986"}, ctx) + + assert result.success + assert result.data["transport"] == "psrp" + assert result.data["target"] == "win-srv-01:5986" + + +# ── OrchestratorConnector (Ansible) ────────────────────────────── + + +@pytest.mark.asyncio +async def test_orchestrator_all_steps_succeed(): + """TEST 8: All steps succeed → aggregate success.""" + resolver = CredentialResolver() + resolver.register(StubCredentialBackend()) + + connector = AnsibleConnector(credential_resolver=resolver) + ctx = ConnectorContext(gsap_context_id="test-ac") + + result = await connector.invoke( + "playbook", + {"playbook": "site.yml", "targets": ["host-1", "host-2"]}, + ctx, + ) + + assert result.success + assert len(result.data["steps"]) == 1 + assert result.data["steps"][0]["success"] is True + + +@pytest.mark.asyncio +async def test_orchestrator_required_step_fails(): + """TEST 9: Required step fails → early termination, partial results.""" + from gsap_broker.connectors.orchestrator import OrchestratorConnector, WorkflowPlan, WorkflowStep + + resolver = CredentialResolver() + resolver.register(StubCredentialBackend()) + + class FailingOrchestrator(OrchestratorConnector): + connector_id = "failing-orch" + corpus_entry_cid = "sha256:test" + capability_mask = 1 + declared_endpoints = [] + + async def plan(self, operation, parameters, context): + return WorkflowPlan(steps=[ + WorkflowStep(name="step-1", command="ok", required=True), + WorkflowStep(name="step-2", command="fail", required=True), + WorkflowStep(name="step-3", command="never", required=True), + ]) + + async def execute_step(self, step, context): + if step.command == "fail": + return {"success": False, "error": "boom"} + return {"success": True} + + connector = FailingOrchestrator(credential_resolver=resolver) + ctx = ConnectorContext(gsap_context_id="test-ac") + + result = await connector.invoke("run", {}, ctx) + + assert not result.success + assert result.data["failed_at"] == "step-2" + assert len(result.data["completed"]) == 2 # step-1 succeeded, step-2 failed + # step-3 was never executed + + +# ── DeviceRouter ────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_router_api_operation_routes_to_intune(): + """TEST 12: API-mediated operations route to Intune.""" + from gsap_broker.connectors.registry import ConnectorRegistry + registry = ConnectorRegistry() + router = DeviceRouter(connector_registry=registry) + + ctx = ConnectorContext(gsap_context_id="test-ac") + connector_id, op = await router.route("get_compliance", "dev-1", ctx) + assert connector_id == "intune" + + +@pytest.mark.asyncio +async def test_router_fleet_operation_routes_to_ansible(): + """Fleet operations route to Ansible.""" + from gsap_broker.connectors.registry import ConnectorRegistry + registry = ConnectorRegistry() + router = DeviceRouter(connector_registry=registry) + + ctx = ConnectorContext(gsap_context_id="test-ac") + connector_id, op = await router.route("playbook", "fleet", ctx) + assert connector_id == "ansible" + + +@pytest.mark.asyncio +async def test_router_unknown_device_raises(): + """TEST 13: Unknown device raises clear error.""" + from gsap_broker.connectors.registry import ConnectorRegistry + registry = ConnectorRegistry() + router = DeviceRouter(connector_registry=registry) + + ctx = ConnectorContext(gsap_context_id="test-ac") + with pytest.raises(UnknownDevice): + await router.route("exec", "mystery-host", ctx) + + +@pytest.mark.asyncio +async def test_router_invoke_missing_connector(): + """Router invoke returns error when connector not registered.""" + from gsap_broker.connectors.registry import ConnectorRegistry + registry = ConnectorRegistry() + router = DeviceRouter(connector_registry=registry) + + ctx = ConnectorContext(gsap_context_id="test-ac") + result = await router.invoke("get_compliance", "dev-1", {}, ctx) + assert not result.success + assert "not registered" in result.error + + +# ── Catalog conditional registration ───────────────────────────── + + +@pytest.mark.asyncio +async def test_connectors_absent_when_disabled(): + """TEST 15: Optional connectors not in catalog when disabled.""" + from httpx import AsyncClient, ASGITransport + from gsap_broker.app import app + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/connectors/") + assert resp.status_code == 200 + ids = [c["connector_id"] for c in resp.json()] + # With default settings, only echo is registered + assert "bascule" not in ids + assert "powershell" not in ids + assert "ansible" not in ids