Compare commits

..

No commits in common. "bastion-v0.3-security" and "main" have entirely different histories.

43 changed files with 126 additions and 4009 deletions

1
.gitignore vendored
View file

@ -6,4 +6,3 @@ dist/
*.egg-info/
.ruff_cache/
.pytest_cache/
.venv/

View file

@ -1,86 +0,0 @@
# Intune Connector — Configuration Guide
## Azure AD App Permissions
Register an Azure AD application with the following Microsoft Graph API permissions (Application type, not Delegated):
| Permission | Type | Required For |
|------------|------|-------------|
| `DeviceManagementManagedDevices.Read.All` | Application | list_devices, get_device, get_compliance |
| `DeviceManagementManagedDevices.ReadWrite.All` | Application | sync_device, remote_lock, retire_device, wipe_device |
Grant admin consent for your tenant after adding the permissions.
## Environment Variables
```bash
# Enable the Intune connector
INTUNE_ENABLED=true
# Entra credentials (shared with the Entra registrar)
ENTRA_TENANT_ID=your-tenant-id
ENTRA_CLIENT_ID=your-app-client-id
ENTRA_CLIENT_SECRET=your-app-client-secret
# Compliance gating (optional)
INTUNE_COMPLIANCE_REQUIRED=false # Global default for all accord templates
INTUNE_COMPLIANCE_STRICT=false # Reject if no device_id in token
INTUNE_COMPLIANCE_CACHE_TTL=300 # Cache compliance state for 5 minutes
```
## Compliance-Gated AC Issuance
When `INTUNE_ENABLED=true`, the authorize endpoint can gate AC issuance on device compliance.
### Global Default
Set `INTUNE_COMPLIANCE_REQUIRED=true` to require compliance for all accord templates.
### Per-Accord Override
Accord templates can override the global default. Currently configured in `routers/authorize.py`:
```python
_ACCORD_COMPLIANCE = {
"infrastructure-operations": {"device_compliance_required": True},
"device-management": {"device_compliance_required": True},
}
```
### Strict vs Permissive Mode
- **Strict** (`INTUNE_COMPLIANCE_STRICT=true`): Rejects AC issuance if the token does not contain a device ID (e.g., Keycloak tokens without device claims). Use for environments where every operator must be on a managed device.
- **Permissive** (`INTUNE_COMPLIANCE_STRICT=false`, default): Allows AC issuance without device compliance fields when no device ID is present. Compliance is only checked when a device ID is available.
## Connector Operations
| Operation | Capability | Description |
|-----------|-----------|-------------|
| `list_devices` | READ | List managed devices |
| `get_device` | READ | Get device details |
| `get_compliance` | READ | Check compliance state (cached) |
| `sync_device` | PROPOSE | Trigger Intune device sync |
| `remote_lock` | MUTATE | Remote lock a device |
| `retire_device` | MUTATE | Retire from management |
| `wipe_device` | MUTATE | Factory reset device |
MUTATE operations (lock, retire, wipe) should be gated by ceremony approval in production accord templates via `ceremony_required_for` in the delegation scope.
## MCP Tools
When Intune is enabled, the MCP endpoint exposes:
- `list_devices` — List managed devices
- `get_device_compliance` — Check device compliance
- `sync_device` — Trigger device sync
- `remote_lock` — Remote lock (requires MUTATE)
All MCP tool calls route through the governed `IntuneConnector`, ensuring Chronicle audit trails.
## Chronicle Events
| Event | Code | Emitted When |
|-------|------|-------------|
| `CONNECTOR_INVOKED` | — | Every Intune connector invocation |
| `DEVICE_COMPLIANCE_CHECKED` | `0x2801` | Compliance gate evaluated during AC issuance |

View file

@ -1,137 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Ansible connector — fleet management via Ansible playbooks.
The Ansible connector is an OrchestratorConnector: it plans a
sequence of steps (playbook runs, ad-hoc commands, fact collection)
and executes them via ansible-runner.
Credential model:
Ansible does NOT receive credentials from the broker. Instead,
the connector passes a credential callback that ansible-runner's
credential plugin calls at runtime. This callback resolves
credentials via the broker's CredentialResolver for each target
host at the moment the task runs not at planning time.
This means:
- The Ansible inventory file contains NO passwords or keys.
- Each host's credentials are resolved just-in-time from the
secrets backend (Entra, Vault, etc).
- If a credential expires mid-playbook, the next task for that
host will resolve a fresh credential.
Real integration:
Library: ``ansible-runner`` (execution library)
Auth: Per-host credential resolution via custom credential plugin
Inventory: Dynamic inventory from Intune device cache or Bascule
fleet registry
Stubbed in this sprint steps return placeholder results.
"""
from __future__ import annotations
from typing import Any
from gsap_broker.connectors.base import ConnectorContext
from gsap_broker.connectors.orchestrator import (
OrchestratorConnector,
WorkflowPlan,
WorkflowStep,
)
from gsap_broker.credentials.resolver import CredentialResolver
class AnsibleConnector(OrchestratorConnector):
"""Fleet management via Ansible playbooks."""
connector_id = "ansible"
corpus_entry_cid = "sha256:ansible-connector-v1"
capability_mask = 0x7 # READ | PROPOSE | MUTATE
declared_endpoints = ["ansible://*"]
accord_template = "fleet-management"
gsap_required = True
chronicle_enabled = True
def __init__(self, credential_resolver: CredentialResolver):
super().__init__(credential_resolver)
async def plan(
self, operation: str, parameters: dict[str, Any], context: ConnectorContext
) -> WorkflowPlan:
"""Map operations to workflow plans.
Operations:
"playbook" single step running the named playbook
"adhoc" single step running a module on targets
"collect" single step gathering facts
"role" single step applying a role
"""
targets = parameters.get("targets", [])
if isinstance(targets, str):
targets = [targets]
if operation == "playbook":
return WorkflowPlan(steps=[
WorkflowStep(
name=f"playbook:{parameters.get('playbook', 'site.yml')}",
command=parameters.get("playbook", "site.yml"),
targets=targets,
extra_vars=parameters.get("extra_vars", {}),
)
])
if operation == "adhoc":
return WorkflowPlan(steps=[
WorkflowStep(
name=f"adhoc:{parameters.get('module', 'ping')}",
command=parameters.get("module", "ping"),
targets=targets,
extra_vars=parameters.get("args", {}),
)
])
if operation == "collect":
return WorkflowPlan(steps=[
WorkflowStep(
name="collect:facts",
command="setup",
targets=targets,
required=False, # fact collection is best-effort
)
])
if operation == "role":
return WorkflowPlan(steps=[
WorkflowStep(
name=f"role:{parameters.get('role', '')}",
command=parameters.get("role", ""),
targets=targets,
extra_vars=parameters.get("extra_vars", {}),
)
])
return WorkflowPlan(steps=[
WorkflowStep(name=f"unknown:{operation}", command=operation, targets=targets)
])
async def execute_step(
self, step: WorkflowStep, context: ConnectorContext
) -> dict[str, Any]:
"""Execute a single workflow step via ansible-runner.
Stubbed actual ansible-runner integration in a future sprint.
"""
# TODO: use ansible_runner.run() with:
# - playbook=step.command (for playbook operation)
# - module=step.command, module_args=step.extra_vars (for adhoc)
# - inventory from dynamic source (Intune cache, Bascule fleet)
# - credential plugin that calls self._resolver.resolve()
# per-host at runtime
return {
"success": True,
"stub": True,
"step": step.name,
"targets": step.targets,
}

View file

@ -1,87 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Bascule connector — governed shell sessions via Shellstream.
The Bascule connector uses the AC as the credential. Bascule (the
governed-shell runtime) validates ACs natively no separate secrets
backend is needed. The transport establishes a Shellstream session
to the target endpoint via a Bascule proxy.
Real integration:
The ``BasculeTransport`` will use the Shellstream protocol library
(``substrate/shellstream/``) to establish a 3-way attested handshake
with the target. The SAT (Shell Attestation Token) embedded in the
Shellstream frame header is derived from the AC. See
``SS-SPEC-0001 Shellstream Protocol.md`` for the handshake flow.
Library: ``shellstream-py`` (future) or gRPC to Bascule proxy
Protocol: Shellstream over TCP / QUIC
Auth: AC passthrough the AC IS the credential
Stubbed in this sprint transport returns placeholder results.
"""
from __future__ import annotations
from typing import Any, Optional
from gsap_broker.connectors.session import SessionConnector, SessionTransport
from gsap_broker.credentials.resolver import Credential, CredentialResolver
class BasculeTransport(SessionTransport):
"""Shellstream transport to a Bascule-governed endpoint.
Stubbed actual Shellstream integration in a future sprint.
"""
transport_id = "bascule"
def __init__(self) -> None:
self._target = ""
self._connected = False
async def connect(self, target: str, credential: Credential) -> None:
# TODO: establish Shellstream connection to target via Bascule
# proxy. The credential IS the AC (BasculeCredential).
# Handshake: ATTEST-INIT → ATTEST-VERIFY → ATTEST-CONFIRM.
self._target = target
self._connected = True
async def execute(self, command: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]:
# TODO: send command via Shellstream, capture output.
# Shellstream frames carry the SAT in the header for
# per-frame attestation.
if not self._connected:
raise RuntimeError("Not connected")
return {
"stub": True,
"transport": self.transport_id,
"target": self._target,
"command": command,
"params": params or {},
}
async def disconnect(self) -> None:
self._connected = False
async def is_alive(self) -> bool:
return self._connected
class BasculeConnector(SessionConnector):
"""Governed shell connector using Bascule/Shellstream."""
connector_id = "bascule"
corpus_entry_cid = "sha256:bascule-connector-v1"
credential_type = "bascule_ac"
transport_class = BasculeTransport
capability_mask = 0x7 # READ | PROPOSE | MUTATE
declared_endpoints = ["shellstream://*"]
accord_template = "governed-shell"
gsap_required = True
chronicle_enabled = True
def __init__(self, credential_resolver: CredentialResolver):
super().__init__(credential_resolver)

View file

@ -7,12 +7,6 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
# Fix C-6: capability constants for operation-level enforcement
CAP_READ = 0x1
CAP_PROPOSE = 0x2
CAP_MUTATE = 0x4
CAP_ADMIN = 0x8
@dataclass
class ConnectorContext:
@ -21,9 +15,6 @@ class ConnectorContext:
credentials: dict[str, Any] = field(default_factory=dict)
pipeline_run_id: str = ""
dag_id: str = ""
# Fix C-6: capability_mask from the AC, enforced by ConnectorRuntime
capability_mask: int = 0
principal_did: str = ""
@dataclass
@ -45,13 +36,6 @@ class ConnectorPlugin(ABC):
accord_template: str = ""
gsap_required: bool = True
chronicle_enabled: bool = True
# Fix C-6: per-operation capability requirements
operation_capabilities: dict[str, int] = {}
def capability_for_operation(self, operation: str) -> int:
"""Return the capability mask required for an operation.
Defaults to CAP_READ if not explicitly mapped."""
return self.operation_capabilities.get(operation, CAP_READ)
@abstractmethod
async def invoke(

View file

@ -1,213 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Intune device management connector — governed Graph API invocation.
Implements ConnectorPlugin for Intune Graph API operations.
Every invocation requires an active AC and emits a Chronicle
CONNECTOR_INVOKED event via the ConnectorRuntime.
"""
from __future__ import annotations
import logging
from datetime import datetime, UTC
from typing import Any
import re
from gsap_broker.connectors.base import (
CAP_MUTATE, CAP_PROPOSE, CAP_READ,
ConnectorContext, ConnectorPlugin, ConnectorResult,
)
from gsap_broker.intune.device_cache import DeviceComplianceCache
from gsap_broker.intune.graph_client import GraphClient
from gsap_broker.models.intune import ComplianceState, DeviceSummary
logger = logging.getLogger(__name__)
_GRAPH_DEVICES = "/deviceManagement/managedDevices"
# Fix H-5: device_id must be a UUID to prevent path traversal
_UUID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
re.IGNORECASE,
)
def _validate_device_id(device_id: str) -> str:
"""Fix H-5: validate device_id as UUID before Graph API URL interpolation."""
if not device_id:
raise ValueError("device_id required")
if not _UUID_RE.match(device_id):
raise ValueError(f"Invalid device_id: must be UUID format, got '{device_id}'")
return device_id
class IntuneConnector(ConnectorPlugin):
connector_id = "intune"
corpus_entry_cid = "sha256:intune-connector-v1"
capability_mask = 0x7 # READ | PROPOSE | MUTATE
declared_endpoints = [
"graph.microsoft.com/v1.0/deviceManagement/managedDevices",
]
accord_template = "device-management"
gsap_required = True
chronicle_enabled = True
# Fix C-6: per-operation capability requirements
operation_capabilities = {
"list_devices": CAP_READ,
"get_device": CAP_READ,
"get_compliance": CAP_READ,
"sync_device": CAP_PROPOSE,
"remote_lock": CAP_MUTATE,
"retire_device": CAP_MUTATE,
"wipe_device": CAP_MUTATE,
}
def __init__(self, graph_client: GraphClient, cache: DeviceComplianceCache | None = None):
self.graph = graph_client
self.cache = cache or DeviceComplianceCache()
async def invoke(
self, operation: str, parameters: dict[str, Any], context: ConnectorContext
) -> ConnectorResult:
"""Route to the appropriate Graph API call."""
handlers = {
"list_devices": self._list_devices,
"get_device": self._get_device,
"get_compliance": self._get_compliance,
"sync_device": self._sync_device,
"remote_lock": self._remote_lock,
"retire_device": self._retire_device,
"wipe_device": self._wipe_device,
}
handler = handlers.get(operation)
if handler is None:
return ConnectorResult(
success=False, error=f"Unknown operation: {operation}"
)
try:
return await handler(parameters, context)
except Exception as e:
logger.error("Intune connector error: %s %s", operation, e)
return ConnectorResult(success=False, error=str(e))
def health_check(self) -> bool:
# Synchronous check — can't call async Graph API here.
# Return True if graph client is configured.
return bool(self.graph.tenant_id and self.graph.client_id)
# ── READ operations ──────────────────────────────────────────
async def _list_devices(
self, params: dict[str, Any], ctx: ConnectorContext
) -> ConnectorResult:
top = params.get("top", 50)
select = params.get("select", "id,deviceName,operatingSystem,osVersion,complianceState,lastSyncDateTime,userPrincipalName,azureADDeviceId")
data = await self.graph.get(_GRAPH_DEVICES, params={"$top": top, "$select": select})
devices = [
DeviceSummary(
device_id=d["id"],
device_name=d.get("deviceName", ""),
os_type=d.get("operatingSystem", ""),
os_version=d.get("osVersion", ""),
compliance_state=d.get("complianceState", "unknown"),
last_sync=d.get("lastSyncDateTime"),
user_principal_name=d.get("userPrincipalName"),
entra_device_id=d.get("azureADDeviceId"),
).model_dump(mode="json")
for d in data.get("value", [])
]
return ConnectorResult(success=True, data=devices)
async def _get_device(
self, params: dict[str, Any], ctx: ConnectorContext
) -> ConnectorResult:
try:
device_id = _validate_device_id(params.get("device_id", ""))
except ValueError as e:
return ConnectorResult(success=False, error=str(e))
data = await self.graph.get(f"{_GRAPH_DEVICES}/{device_id}")
return ConnectorResult(success=True, data=data)
async def _get_compliance(
self, params: dict[str, Any], ctx: ConnectorContext
) -> ConnectorResult:
try:
device_id = _validate_device_id(params.get("device_id", ""))
except ValueError as e:
return ConnectorResult(success=False, error=str(e))
# Check cache first
cached = await self.cache.get(device_id)
if cached is not None:
return ConnectorResult(success=True, data=cached.model_dump(mode="json"))
# Fetch from Graph API
data = await self.graph.get(
f"{_GRAPH_DEVICES}/{device_id}",
params={"$select": "id,complianceState,lastSyncDateTime,complianceGracePeriodExpirationDateTime"},
)
raw_state = data.get("complianceState", "unknown")
state = ComplianceState(
device_id=device_id,
compliant=raw_state == "compliant",
state=raw_state,
last_evaluated=datetime.now(UTC),
)
await self.cache.set(device_id, state)
return ConnectorResult(success=True, data=state.model_dump(mode="json"))
# ── PROPOSE operations ───────────────────────────────────────
async def _sync_device(
self, params: dict[str, Any], ctx: ConnectorContext
) -> ConnectorResult:
try:
device_id = _validate_device_id(params.get("device_id", ""))
except ValueError as e:
return ConnectorResult(success=False, error=str(e))
resp = await self.graph.post(f"{_GRAPH_DEVICES}/{device_id}/syncDevice")
if resp.status_code in (200, 204):
await self.cache.invalidate(device_id)
return ConnectorResult(success=True, data={"synced": True})
return ConnectorResult(success=False, error=f"Sync failed: HTTP {resp.status_code}")
# ── MUTATE operations ────────────────────────────────────────
async def _remote_lock(
self, params: dict[str, Any], ctx: ConnectorContext
) -> ConnectorResult:
try:
device_id = _validate_device_id(params.get("device_id", ""))
except ValueError as e:
return ConnectorResult(success=False, error=str(e))
resp = await self.graph.post(f"{_GRAPH_DEVICES}/{device_id}/remoteLock")
if resp.status_code in (200, 204):
return ConnectorResult(success=True, data={"locked": True})
return ConnectorResult(success=False, error=f"Lock failed: HTTP {resp.status_code}")
async def _retire_device(
self, params: dict[str, Any], ctx: ConnectorContext
) -> ConnectorResult:
try:
device_id = _validate_device_id(params.get("device_id", ""))
except ValueError as e:
return ConnectorResult(success=False, error=str(e))
resp = await self.graph.post(f"{_GRAPH_DEVICES}/{device_id}/retire")
if resp.status_code in (200, 204):
return ConnectorResult(success=True, data={"retired": True})
return ConnectorResult(success=False, error=f"Retire failed: HTTP {resp.status_code}")
async def _wipe_device(
self, params: dict[str, Any], ctx: ConnectorContext
) -> ConnectorResult:
try:
device_id = _validate_device_id(params.get("device_id", ""))
except ValueError as e:
return ConnectorResult(success=False, error=str(e))
resp = await self.graph.post(f"{_GRAPH_DEVICES}/{device_id}/wipe")
if resp.status_code in (200, 204):
return ConnectorResult(success=True, data={"wiped": True})
return ConnectorResult(success=False, error=f"Wipe failed: HTTP {resp.status_code}")

View file

@ -1,112 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Orchestrator connector framework — multi-step workflow execution.
Orchestrator connectors manage workflows that span multiple steps
and potentially multiple targets. Each step may acquire its own
credentials via the CredentialResolver.
Unlike ``SessionConnector`` (single target, single credential,
single command), an ``OrchestratorConnector``:
- Plans a sequence of steps before execution
- Executes steps in order, stopping on required-step failure
- Reports partial results (which steps completed before failure)
- Can target different hosts per step
Rust port note:
``WorkflowStep`` and ``WorkflowPlan`` map to plain structs.
``OrchestratorConnector`` maps to an async trait with
``plan()`` and ``execute_step()`` methods.
"""
from __future__ import annotations
import logging
from abc import abstractmethod
from dataclasses import dataclass, field
from typing import Any
from gsap_broker.connectors.base import ConnectorContext, ConnectorPlugin, ConnectorResult
from gsap_broker.credentials.resolver import CredentialResolver
logger = logging.getLogger(__name__)
@dataclass
class WorkflowStep:
"""A single step in a workflow plan."""
name: str
command: str
targets: list[str] = field(default_factory=list)
required: bool = True
extra_vars: dict[str, Any] = field(default_factory=dict)
@dataclass
class WorkflowPlan:
"""Ordered sequence of steps for a workflow."""
steps: list[WorkflowStep] = field(default_factory=list)
class OrchestratorConnector(ConnectorPlugin):
"""Base for multi-step workflow connectors (Ansible, Terraform, etc).
Subclasses implement ``plan()`` to convert an operation + parameters
into a ``WorkflowPlan``, and ``execute_step()`` to run each step.
The base ``invoke()`` handles:
- Planning the workflow
- Executing steps in order
- Stopping on required-step failure
- Aggregating results with partial-completion reporting
"""
def __init__(self, credential_resolver: CredentialResolver):
self._resolver = credential_resolver
async def invoke(
self, operation: str, parameters: dict[str, Any], context: ConnectorContext
) -> ConnectorResult:
try:
plan = await self.plan(operation, parameters, context)
except Exception as e:
return ConnectorResult(success=False, error=f"Planning failed: {e}")
if not plan.steps:
return ConnectorResult(success=True, data={"steps": []})
results: list[dict[str, Any]] = []
for step in plan.steps:
result = await self.execute_step(step, context)
results.append({"step": step.name, **result})
if not result.get("success") and step.required:
return ConnectorResult(
success=False,
data={"completed": results, "failed_at": step.name},
)
return ConnectorResult(success=True, data={"steps": results})
@abstractmethod
async def plan(
self, operation: str, parameters: dict[str, Any], context: ConnectorContext
) -> WorkflowPlan:
"""Convert an operation into a step-by-step execution plan."""
...
@abstractmethod
async def execute_step(
self, step: WorkflowStep, context: ConnectorContext
) -> dict[str, Any]:
"""Execute a single workflow step.
Returns a dict with at minimum a ``success: bool`` key.
"""
...
def health_check(self) -> bool:
return True

View file

@ -1,88 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""PowerShell connector — WinRM/PSRP sessions to Windows endpoints.
Uses Kerberos credentials from the CredentialResolver (acquired via
Entra cloud trust or on-prem KDC) to establish a PSRP (PowerShell
Remoting Protocol) session over WinRM/HTTPS.
Real integration:
Library: ``pypsrp`` (well-maintained PSRP client)
Protocol: PSRP over WinRM (HTTPS port 5986)
Auth: Kerberos (from ``KerberosCredential``) or CredSSP
Output: Structured PSObject results deserialized to dicts
The transport will:
1. Create a ``pypsrp.wsman.WSMan`` connection with the Kerberos
ticket from the credential.
2. Open a ``pypsrp.powershell.PowerShell`` runspace.
3. Execute commands via ``ps.add_script(command).invoke()``.
4. Return deserialized PSObject results as Python dicts.
5. Close the runspace and WSMan connection on disconnect.
Stubbed in this sprint transport returns placeholder results.
"""
from __future__ import annotations
from typing import Any, Optional
from gsap_broker.connectors.session import SessionConnector, SessionTransport
from gsap_broker.credentials.resolver import Credential, CredentialResolver
class PowerShellTransport(SessionTransport):
"""PSRP transport over WinRM/HTTPS.
Stubbed actual pypsrp integration in a future sprint.
"""
transport_id = "psrp"
def __init__(self) -> None:
self._target = ""
self._connected = False
async def connect(self, target: str, credential: Credential) -> None:
# TODO: create pypsrp.wsman.WSMan with Kerberos auth from
# credential.ticket. Target format: hostname:5986.
self._target = target
self._connected = True
async def execute(self, command: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]:
# TODO: execute PowerShell command via PSRP.
# ps = PowerShell(wsman); ps.add_script(command).invoke()
# Return structured PSObject results as dicts.
if not self._connected:
raise RuntimeError("Not connected")
return {
"stub": True,
"transport": self.transport_id,
"target": self._target,
"command": command,
"params": params or {},
}
async def disconnect(self) -> None:
self._connected = False
async def is_alive(self) -> bool:
return self._connected
class PowerShellConnector(SessionConnector):
"""Windows management via PowerShell Remoting."""
connector_id = "powershell"
corpus_entry_cid = "sha256:powershell-connector-v1"
credential_type = "kerberos"
transport_class = PowerShellTransport
capability_mask = 0x7 # READ | PROPOSE | MUTATE
declared_endpoints = ["wsman://*:5986"]
accord_template = "windows-management"
gsap_required = True
chronicle_enabled = True
def __init__(self, credential_resolver: CredentialResolver):
super().__init__(credential_resolver)

View file

@ -1,25 +1,11 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""ConnectorRuntime — governed invocation with Chronicle emission.
Fix C-6: capability_mask enforcement per operation.
Fix C-7: AC validated against database before invocation.
Fix H-4: Chronicle INTENT emitted before execution.
"""
"""ConnectorRuntime — governed invocation with Chronicle emission."""
from __future__ import annotations
import logging
from datetime import datetime, UTC
from typing import Any, Optional
from sqlalchemy import text
from typing import Any
from .base import ConnectorContext, ConnectorResult
from .registry import ConnectorRegistry
logger = logging.getLogger(__name__)
class ConnectorRuntime:
def __init__(
@ -39,49 +25,16 @@ class ConnectorRuntime:
if connector is None:
return ConnectorResult(success=False, error=f"Unknown connector: {connector_id}")
# Fix C-7: validate AC exists and is active
if connector.gsap_required:
if not context.gsap_context_id:
return ConnectorResult(success=False, error="GSAP context required")
ac_valid = await self._validate_ac(context.gsap_context_id)
if not ac_valid:
return ConnectorResult(
success=False,
error=f"AC '{context.gsap_context_id}' not found, expired, or consumed",
)
# Fix C-6: enforce capability_mask (only for governed connectors)
required_cap = connector.capability_for_operation(operation) if connector.gsap_required else 0
if required_cap and not (context.capability_mask & required_cap):
cap_names = {0x1: "READ", 0x2: "PROPOSE", 0x4: "MUTATE", 0x8: "ADMIN"}
return ConnectorResult(
success=False,
error=f"Operation '{operation}' requires {cap_names.get(required_cap, hex(required_cap))} "
f"capability, AC has mask={context.capability_mask}",
)
# Fix H-4: emit Chronicle INTENT before execution
if connector.chronicle_enabled and self.chronicle_client is not None:
try:
await self.chronicle_client.emit(
"CONNECTOR_INVOCATION_INTENT",
{
"connector_id": connector_id,
"operation": operation,
"principal_did": context.principal_did,
"gsap_context_id": context.gsap_context_id,
},
)
except Exception:
pass # Chronicle failure must not block invocation
if connector.gsap_required and not context.gsap_context_id:
return ConnectorResult(success=False, error="GSAP context required but not provided")
result = await connector.invoke(operation, parameters, context)
# Emit Chronicle RESULT after execution
# Emit Chronicle event
if connector.chronicle_enabled and self.chronicle_client is not None:
try:
cid = await self.chronicle_client.emit(
"CONNECTOR_INVOCATION_RESULT",
"CONNECTOR_INVOKED",
{
"connector_id": connector_id,
"operation": operation,
@ -98,39 +51,3 @@ class ConnectorRuntime:
pass # Chronicle failure must not break invocation
return result
async def _validate_ac(self, context_id: str) -> bool:
"""Fix C-7: validate AC exists and is active in the database."""
# Skip validation for internal context IDs (compliance gate)
if context_id == "compliance-gate":
return True
try:
from gsap_broker.db import engine
from sqlmodel.ext.asyncio.session import AsyncSession
async with AsyncSession(engine) as session:
result = await session.execute(
text(
"SELECT status, expires_at FROM authorization_contexts "
"WHERE context_id = :ctx_id"
),
{"ctx_id": context_id.replace("-", "")},
)
row = result.first()
if not row:
return False
status = row[0]
if status not in ("authorized", "active"):
return False
# Check expiry
expires_str = row[1]
if expires_str:
try:
expires = datetime.fromisoformat(str(expires_str))
if expires < datetime.now(UTC).replace(tzinfo=None):
return False
except (ValueError, TypeError):
pass
return True
except Exception as e:
logger.warning("AC validation failed: %s", e)
return False

View file

@ -1,138 +0,0 @@
# 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

View file

@ -1,2 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0

View file

@ -1,163 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Entra credential backend — resolves credentials via Microsoft Entra ID.
For OAuth:
Uses MSAL on-behalf-of (OBO) flow to exchange the operator's Entra
identity (from the AC's identity_proof) for a scoped, short-lived
token targeting a specific resource. The broker never sees or stores
the operator's password — only the OBO assertion.
For Kerberos:
Uses Entra cloud Kerberos trust or on-behalf-of flow to an on-prem
KDC proxy. The actual Kerberos ticket bytes are acquired from the
KDC and returned to the transport. Stubbed in this sprint hybrid
environments need site-specific KDC configuration.
For Bascule:
The AC is the credential. Bascule validates ACs natively, so no
external secrets source is involved. The backend simply wraps the
AC dict in a ``BasculeCredential``.
Rust port note:
MSAL has no Rust equivalent. The Rust port should use ``reqwest``
against the Entra OAuth2 endpoints directly (the OBO flow is a
single POST to the token endpoint).
"""
import logging
from datetime import datetime, timedelta, UTC
from gsap_broker.credentials.resolver import (
BasculeCredential,
Credential,
CredentialBackend,
CredentialResolutionError,
KerberosCredential,
OAuthCredential,
)
logger = logging.getLogger(__name__)
class EntraCredentialBackend(CredentialBackend):
"""Resolves credentials via Microsoft Entra ID."""
def __init__(
self,
tenant_id: str,
client_id: str,
client_secret: str,
):
self.tenant_id = tenant_id
self.client_id = client_id
self.client_secret = client_secret
@property
def supported_types(self) -> list[str]:
return ["bascule_ac", "oauth", "kerberos"]
async def resolve(
self,
credential_type: str,
target: str,
ac_context: dict,
) -> Credential:
if credential_type == "bascule_ac":
return self._resolve_bascule(target, ac_context)
if credential_type == "oauth":
return await self._resolve_oauth(target, ac_context)
if credential_type == "kerberos":
return await self._resolve_kerberos(target, ac_context)
raise CredentialResolutionError(
f"EntraCredentialBackend does not support '{credential_type}'"
)
async def revoke(self, credential: Credential) -> None:
# OAuth tokens issued via OBO are short-lived (5 min) and
# cannot be individually revoked via Entra. Kerberos tickets
# expire naturally. Bascule ACs are revoked via the broker's
# AC lifecycle, not the credential backend.
logger.debug(
"Revoke requested for %s (no-op — TTL is primary revocation)",
credential.credential_type,
)
# ── Private ──────────────────────────────────────────────────
def _resolve_bascule(self, target: str, ac_context: dict) -> BasculeCredential:
"""Bascule accepts ACs directly — no secret needed."""
expires_raw = ac_context.get("expires_at")
if isinstance(expires_raw, str):
expires_at = datetime.fromisoformat(expires_raw)
elif isinstance(expires_raw, datetime):
expires_at = expires_raw
else:
expires_at = datetime.now(UTC) + timedelta(minutes=5)
return BasculeCredential(
target=target,
expires_at=expires_at,
scoped_to=ac_context.get("accord", {}).get("template", ""),
authorization_context=ac_context,
)
async def _resolve_oauth(self, target: str, ac_context: dict) -> OAuthCredential:
"""On-behalf-of flow: exchange operator identity for scoped token.
In production this calls MSAL's ``acquire_token_on_behalf_of``
using the operator's assertion (id_token or access_token from
the identity_proof in the AC). For this sprint we use the
app-level client_credentials flow as a stand-in, since the OBO
flow requires the operator's token to be available at invocation
time (which means the transport layer needs to forward it
wired in a future sprint).
"""
try:
import msal
except ImportError:
raise CredentialResolutionError(
"msal package required for Entra OAuth resolution"
)
app = msal.ConfidentialClientApplication(
client_id=self.client_id,
client_credential=self.client_secret,
authority=f"https://login.microsoftonline.com/{self.tenant_id}",
)
result = app.acquire_token_for_client(
scopes=["https://graph.microsoft.com/.default"]
)
if "access_token" not in result:
raise CredentialResolutionError(
f"Entra token acquisition failed: "
f"{result.get('error_description', result.get('error', 'unknown'))}"
)
return OAuthCredential(
target=target,
expires_at=datetime.now(UTC) + timedelta(minutes=5),
scoped_to=target,
access_token=result["access_token"],
)
async def _resolve_kerberos(self, target: str, ac_context: dict) -> KerberosCredential:
"""Acquire Kerberos ticket via Entra cloud trust.
Fix H-3: raises instead of returning stub ticket. Actual
implementation depends on the hybrid environment:
- Pure Entra: use Entra Kerberos proxy (preview API)
- Hybrid with on-prem AD: use OBO to get a token, then
exchange for a Kerberos ticket via the KDC proxy
- Direct KDC: use kinit with the OBO token
Implementation deferred to hybrid environment sprint.
"""
raise CredentialResolutionError(
"Kerberos credential resolution not yet implemented. "
"Configure an on-premises KDC or use OAuth credentials."
)

View file

@ -1,235 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Zero-credential-storage architecture for Bastion connectors.
The broker holds authorization decisions (ACs). A pluggable secrets
backend holds credentials. Transports acquire short-lived, scoped
credentials at invocation time and discard them after use.
**The broker is NEVER a credential store.**
Why this matters:
A compromised broker leaks authorization metadata who is
authorized to do what but NEVER leaks the credentials needed
to actually do it. Credentials live in Entra, Vault, SPIRE, or
whatever secrets backend the deployment uses, and they come into
existence only for the duration of a single connector invocation.
Credential lifecycle:
1. Operator obtains an AC via ``/governance/authorize/``.
2. Operator (or MCP agent) invokes a connector.
3. The connector's ``SessionConnector.invoke()`` calls
``CredentialResolver.resolve()`` with the AC context.
4. The resolver dispatches to the correct ``CredentialBackend``
(Entra, Vault, Stub, etc.).
5. The backend issues a short-lived credential (max 5 min TTL)
scoped to the target.
6. The transport uses the credential to establish a session.
7. After the operation completes (or fails), the credential is
discarded. No reference is stored anywhere in the broker.
Rust port note:
The ``Credential`` hierarchy maps to a Rust enum with per-variant
fields. ``CredentialBackend`` maps to an async trait.
``CredentialResolver`` maps to a struct holding a ``Vec<Box<dyn
CredentialBackend>>``.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timedelta, UTC
from typing import Any, Optional
# ── Credential types ─────────────────────────────────────────────
@dataclass
class Credential:
"""Base for short-lived, scoped credentials.
Every credential has a ``credential_type``, a ``target`` it is
valid for, an ``expires_at`` wall-clock deadline, and a human-
readable ``scoped_to`` description of what it permits.
"""
credential_type: str
target: str
expires_at: datetime
scoped_to: str = ""
@property
def expired(self) -> bool:
return datetime.now(UTC) >= self.expires_at
@dataclass
class BasculeCredential(Credential):
"""The AC itself — Bascule validates ACs natively.
Bascule (the governed-shell runtime) already knows how to
validate ACs, so no separate secrets backend is involved.
The credential IS the authorization context.
"""
credential_type: str = field(default="bascule_ac", init=False)
# Fix H-1: repr=False prevents AC data leaking into logs
authorization_context: dict = field(default_factory=dict, repr=False)
@dataclass
class KerberosCredential(Credential):
"""Short-lived Kerberos ticket for WinRM / PSRemoting."""
credential_type: str = field(default="kerberos", init=False)
# Fix H-1: repr=False prevents ticket bytes leaking into logs
ticket: bytes = field(default=b"", repr=False)
@dataclass
class OAuthCredential(Credential):
"""Short-lived OAuth token for API access."""
credential_type: str = field(default="oauth", init=False)
# Fix H-1: repr=False prevents access token leaking into logs
access_token: str = field(default="", repr=False)
@dataclass
class SSHCertCredential(Credential):
"""Short-lived SSH certificate."""
credential_type: str = field(default="ssh_cert", init=False)
# Fix H-1: repr=False prevents key material leaking into logs
certificate: str = field(default="", repr=False)
private_key: str = field(default="", repr=False)
# ── Errors ───────────────────────────────────────────────────────
class NoBackendAvailable(Exception):
"""No registered backend supports the requested credential type."""
def __init__(self, credential_type: str):
self.credential_type = credential_type
super().__init__(
f"No credential backend supports type '{credential_type}'"
)
class CredentialResolutionError(Exception):
"""A backend was found but failed to resolve the credential."""
# ── Backend protocol ─────────────────────────────────────────────
class CredentialBackend(ABC):
"""Secrets backend that resolves credentials from ACs.
Implementations talk to external secrets sources: Entra ID,
HashiCorp Vault, SPIRE, AWS STS, etc. They MUST return
credentials with an enforced TTL (max 5 minutes by default).
"""
@abstractmethod
async def resolve(
self,
credential_type: str,
target: str,
ac_context: dict,
) -> Credential:
"""Acquire a short-lived credential for the target.
Args:
credential_type: One of ``bascule_ac``, ``kerberos``,
``oauth``, ``ssh_cert``.
target: Where the credential will be used
(hostname, URL, SPIFFE ID).
ac_context: The serialized AuthorizationContext dict
from AC issuance contains principal_did,
capability_mask, accord_template, etc.
Returns:
A ``Credential`` subclass with ``expires_at`` set.
Raises:
CredentialResolutionError: if the backend cannot issue
a credential for this request.
"""
...
@abstractmethod
async def revoke(self, credential: Credential) -> None:
"""Best-effort revoke before natural expiry.
The credential's short TTL is the primary revocation
mechanism. This call is for defense-in-depth (e.g.
deleting a Vault lease, revoking an Entra token).
Implementations MUST NOT raise on failure.
"""
...
@property
@abstractmethod
def supported_types(self) -> list[str]:
"""Credential types this backend can resolve."""
...
# ── Resolver ─────────────────────────────────────────────────────
class CredentialResolver:
"""Routes credential requests to the first capable backend.
Multiple backends can be registered. Resolution tries them in
registration order and returns the first successful result.
The resolver enforces two invariants that no backend can bypass:
1. Every returned credential MUST have ``expires_at`` set.
2. Every returned credential MUST NOT already be expired.
"""
MAX_TTL = timedelta(minutes=5)
def __init__(self) -> None:
self._backends: list[CredentialBackend] = []
def register(self, backend: CredentialBackend) -> None:
self._backends.append(backend)
async def resolve(
self,
credential_type: str,
target: str,
ac_context: dict,
) -> Credential:
for backend in self._backends:
if credential_type in backend.supported_types:
credential = await backend.resolve(
credential_type, target, ac_context
)
if credential.expires_at is None:
raise CredentialResolutionError(
f"Backend {backend.__class__.__name__} returned "
f"credential without expires_at"
)
if credential.expired:
raise CredentialResolutionError(
f"Backend {backend.__class__.__name__} returned "
f"already-expired credential"
)
return credential
raise NoBackendAvailable(credential_type)
async def revoke(self, credential: Credential) -> None:
"""Delegate revocation to the backend that can handle this type."""
for backend in self._backends:
if credential.credential_type in backend.supported_types:
await backend.revoke(credential)
return

View file

@ -1,84 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Stub credential backend for development and testing.
Returns valid-looking credentials with short TTLs. NEVER use in
production these credentials grant no actual access.
Useful for:
- Running the connector framework locally without Entra/Vault
- Integration tests that verify the credential lifecycle
(acquire use discard) without real secrets infrastructure
- Verifying that transports handle credential types correctly
"""
import logging
from datetime import datetime, timedelta, UTC
from gsap_broker.credentials.resolver import (
BasculeCredential,
Credential,
CredentialBackend,
KerberosCredential,
OAuthCredential,
SSHCertCredential,
)
logger = logging.getLogger(__name__)
class StubCredentialBackend(CredentialBackend):
"""Development/testing backend that returns mock credentials."""
@property
def supported_types(self) -> list[str]:
return ["bascule_ac", "kerberos", "oauth", "ssh_cert"]
async def resolve(
self,
credential_type: str,
target: str,
ac_context: dict,
) -> Credential:
expires_at = datetime.now(UTC) + timedelta(minutes=5)
scoped_to = ac_context.get("accord", {}).get("template", "stub")
if credential_type == "bascule_ac":
return BasculeCredential(
target=target,
expires_at=expires_at,
scoped_to=scoped_to,
authorization_context=ac_context,
)
if credential_type == "kerberos":
return KerberosCredential(
target=target,
expires_at=expires_at,
scoped_to=scoped_to,
ticket=b"STUB_TICKET",
)
if credential_type == "oauth":
return OAuthCredential(
target=target,
expires_at=expires_at,
scoped_to=scoped_to,
access_token="stub-access-token",
)
if credential_type == "ssh_cert":
return SSHCertCredential(
target=target,
expires_at=expires_at,
scoped_to=scoped_to,
certificate="stub-cert",
private_key="stub-key",
)
# Should not happen if supported_types is checked first
raise ValueError(f"StubBackend: unsupported type '{credential_type}'")
async def revoke(self, credential: Credential) -> None:
logger.debug("Stub revoke: %s for %s (no-op)", credential.credential_type, credential.target)

View file

@ -44,19 +44,10 @@ class DelegationManager:
self.registrar = create_registrar(config)
async def create_delegation(
self, request: DelegationRequest, delegator_did: str,
delegator_capability_mask: int = 0x7,
self, request: DelegationRequest, delegator_did: str
) -> DelegationResponse:
delegation_id = f"del-{uuid.uuid4().hex[:8]}"
scope = request.scope or DelegationScope()
# Fix C-9: delegated capability cannot exceed delegator's
requested_mask = _capability_mask_for(scope.capability_ceiling)
if requested_mask & ~delegator_capability_mask:
raise ValueError(
f"Delegated capability ({scope.capability_ceiling} = {requested_mask}) "
f"exceeds delegator's capability ({delegator_capability_mask})"
)
now = datetime.now(UTC)
expires_at = now + timedelta(minutes=scope.max_ttl_minutes)

View file

@ -1,30 +1,57 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Entra Agent ID registrar — registers agent identities via Microsoft Graph.
Implements AgentRegistrar for GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001 §4.2.
Uses the shared GraphClient for authenticated Graph API access.
Uses standard Graph application registration with agent metadata tags.
When Entra Agent ID Blueprint APIs reach GA, this driver should be updated
to use the dedicated /agentIdentityBlueprints and /agentIdentities endpoints.
"""
import logging
from gsap_broker.intune.graph_client import GraphClient
import httpx
import msal
from .base import AgentCredentials
logger = logging.getLogger(__name__)
GRAPH_API = "https://graph.microsoft.com/v1.0"
class EntraRegistrar:
"""AgentRegistrar implementation using Microsoft Entra + Graph API."""
def __init__(self, graph_client: GraphClient, agent_blueprint_id: str = ""):
self.graph = graph_client
def __init__(
self,
tenant_id: str,
client_id: str,
client_secret: str,
agent_blueprint_id: str = "",
):
self.tenant_id = tenant_id
self.client_id = client_id
self.client_secret = client_secret
self.agent_blueprint_id = agent_blueprint_id
self._app = msal.ConfidentialClientApplication(
client_id=self.client_id,
client_credential=self.client_secret,
authority=f"https://login.microsoftonline.com/{self.tenant_id}",
)
async def _get_token(self) -> str:
result = self._app.acquire_token_for_client(
scopes=["https://graph.microsoft.com/.default"]
)
if "access_token" in result:
return result["access_token"]
raise RuntimeError(
f"Entra token error: {result.get('error_description', result.get('error', 'unknown'))}"
)
async def _headers(self) -> dict:
token = await self._get_token()
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
async def register_agent(
self,
@ -35,6 +62,8 @@ class EntraRegistrar:
expires_at: str,
metadata: dict | None = None,
) -> AgentCredentials:
headers = await self._headers()
tags = [
f"agent_type:{agent_type}",
f"delegation_id:{delegation_id}",
@ -53,31 +82,34 @@ class EntraRegistrar:
"passwordCredentials": [],
}
resp = await self.graph.post("/applications", body=app_body)
async with httpx.AsyncClient(timeout=15.0) as http:
resp = await http.post(f"{GRAPH_API}/applications", json=app_body, headers=headers)
if resp.status_code == 401:
# Token expired between construction and call — retry once.
resp = await self.graph.post("/applications", body=app_body)
headers = await self._headers()
resp = await http.post(f"{GRAPH_API}/applications", json=app_body, headers=headers)
resp.raise_for_status()
app_data = resp.json()
app_id = app_data["appId"]
object_id = app_data["id"]
secret_resp = await self.graph.post(
f"/applications/{object_id}/addPassword",
body={
secret_resp = await http.post(
f"{GRAPH_API}/applications/{object_id}/addPassword",
json={
"passwordCredential": {
"displayName": f"delegation-{delegation_id}",
"endDateTime": expires_at,
}
},
headers=headers,
)
secret_resp.raise_for_status()
client_secret = secret_resp.json().get("secretText", "")
sp_resp = await self.graph.post(
"/servicePrincipals",
body={"appId": app_id, "displayName": display_name, "tags": tags},
sp_resp = await http.post(
f"{GRAPH_API}/servicePrincipals",
json={"appId": app_id, "displayName": display_name, "tags": tags},
headers=headers,
)
if sp_resp.status_code not in (200, 201, 409):
sp_resp.raise_for_status()
@ -91,19 +123,25 @@ class EntraRegistrar:
)
async def delete_agent(self, client_id: str) -> bool:
try:
data = await self.graph.get(
"/applications", params={"$filter": f"appId eq '{client_id}'"}
)
except Exception:
return False
headers = await self._headers()
apps = data.get("value", [])
async with httpx.AsyncClient(timeout=10.0) as http:
resp = await http.get(
f"{GRAPH_API}/applications",
params={"$filter": f"appId eq '{client_id}'"},
headers=headers,
)
resp.raise_for_status()
apps = resp.json().get("value", [])
if not apps:
return False
object_id = apps[0]["id"]
deleted = await self.graph.delete(f"/applications/{object_id}")
del_resp = await http.delete(
f"{GRAPH_API}/applications/{object_id}",
headers=headers,
)
deleted = del_resp.status_code in (200, 204)
if deleted:
logger.info("Entra: deleted agent app %s", client_id)
return deleted

View file

@ -1,6 +1,3 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Registrar factory — selects the appropriate AgentRegistrar based on settings."""
import logging
@ -34,30 +31,22 @@ def create_registrar(config) -> AgentRegistrar:
if not config.entra_client_secret:
logger.warning("Entra secret not configured, using stub")
return StubRegistrar()
from gsap_broker.intune.graph_client import GraphClient
from .entra import EntraRegistrar
graph = GraphClient(
return EntraRegistrar(
tenant_id=config.entra_tenant_id,
client_id=config.entra_client_id,
client_secret=config.entra_client_secret,
)
return EntraRegistrar(
graph_client=graph,
agent_blueprint_id=config.entra_agent_blueprint_id,
)
if driver == "auto":
if config.entra_client_secret:
from gsap_broker.intune.graph_client import GraphClient
from .entra import EntraRegistrar
logger.info("Auto-selected Entra registrar")
graph = GraphClient(
return EntraRegistrar(
tenant_id=config.entra_tenant_id,
client_id=config.entra_client_id,
client_secret=config.entra_client_secret,
)
return EntraRegistrar(
graph_client=graph,
agent_blueprint_id=config.entra_agent_blueprint_id,
)
if config.keycloak_admin_client_secret:

View file

@ -79,65 +79,40 @@ async def get_active_delegations() -> list[DelegationDB]:
async def increment_commands(delegation_id: str) -> int:
"""Fix C-10: atomic increment with SQL-level limit check."""
from sqlalchemy import text as sa_text
async with AsyncSession(engine) as session:
result = await session.execute(
sa_text(
"UPDATE delegations "
"SET commands_executed = commands_executed + 1 "
"WHERE delegation_id = :id "
"AND commands_executed < max_commands "
"AND status = 'active'"
),
{"id": delegation_id},
result = await session.exec(
select(DelegationDB).where(DelegationDB.delegation_id == delegation_id)
)
d = result.first()
if not d:
return 0
d.commands_executed += 1
session.add(d)
await session.commit()
if result.rowcount == 0:
return -1 # limit reached or delegation not found/active
# Read back the new count
row = await session.execute(
sa_text("SELECT commands_executed FROM delegations WHERE delegation_id = :id"),
{"id": delegation_id},
)
r = row.first()
return r[0] if r else 0
return d.commands_executed
async def expire_stale() -> list[DelegationDB]:
"""Find and expire delegations past TTL or command limit.
Fix M-23: uses atomic SQL update."""
from sqlalchemy import text as sa_text
from datetime import datetime, UTC
now = datetime.now(UTC).replace(tzinfo=None)
"""Find and expire delegations past TTL or command limit."""
now = datetime.utcnow()
async with AsyncSession(engine) as session:
# Atomically expire by TTL
await session.execute(
sa_text(
"UPDATE delegations SET status = 'expired', "
"revoke_reason = 'ttl_elapsed', revoked_at = :now "
"WHERE status = 'active' AND expires_at < :now"
),
{"now": str(now)},
)
# Atomically expire by command limit
await session.execute(
sa_text(
"UPDATE delegations SET status = 'expired', "
"revoke_reason = 'command_limit', revoked_at = :now "
"WHERE status = 'active' AND commands_executed >= max_commands"
),
{"now": str(now)},
)
await session.commit()
# Return the expired rows for cleanup
result = await session.exec(
select(DelegationDB).where(
DelegationDB.status == "expired",
DelegationDB.revoked_at != None,
select(DelegationDB).where(DelegationDB.status == "active")
)
expired = []
for d in result.all():
if now > d.expires_at or d.commands_executed >= d.max_commands:
d.status = "expired"
d.revoke_reason = (
"command_limit"
if d.commands_executed >= d.max_commands
else "ttl_elapsed"
)
return list(result.all())
d.revoked_at = now
session.add(d)
expired.append(d)
await session.commit()
return expired
async def revoke_by_delegator_ac(delegator_ac_id: str) -> int:

View file

@ -25,7 +25,6 @@ class AuthResult:
mfa_satisfied: bool = False
elevation_required: Optional[ElevationRequired] = None
denial_reason: str = ""
device_id: Optional[str] = None
@property
def is_authorized(self): return self.status == self.STATUS_AUTHORIZED

View file

@ -1,110 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Entra identity driver — GSAP §2.2.
Validates Entra-issued JWTs directly via JWKS verification.
Extracts device_id for compliance gating, MFA status, roles,
and constructs DID from Entra tenant + oid.
Fix C-3: JWKS fetch failure results in denial. Never falls back
to unverified claims.
Fix H-10: JWKS cache refreshes on kid miss for key rotation.
"""
import logging
from typing import Optional
from .base import AuthResult, ElevationRequired, IdentityDriver
from .jwks import AuthenticationError, JWKSVerifier
logger = logging.getLogger(__name__)
class EntraDriver(IdentityDriver):
"""Identity driver for direct Entra JWT validation."""
async def authenticate(self) -> AuthResult:
raw_token = self.config.get("_raw_token", "")
tenant_id = self.config.get("entra_tenant_id", "")
expected_audience = self.config.get("entra_client_id", "")
if not raw_token:
return AuthResult(
status=AuthResult.STATUS_DENIED,
denial_reason="No token in context.",
)
if not tenant_id:
return AuthResult(
status=AuthResult.STATUS_DENIED,
denial_reason="Entra tenant_id not configured.",
)
# Fix C-3: verify via JWKS — no fallback on failure
verifier = JWKSVerifier(
jwks_url=f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys",
audience=expected_audience,
issuer=f"https://login.microsoftonline.com/{tenant_id}/v2.0",
)
try:
token_data = await verifier.verify_or_refresh(raw_token)
except AuthenticationError as e:
return AuthResult(
status=AuthResult.STATUS_DENIED,
denial_reason=str(e),
)
# Extract claims from VERIFIED token data
oid = token_data.get("oid", "")
tid = token_data.get("tid", tenant_id)
roles = token_data.get("roles", [])
acrs = token_data.get("acrs", [])
amr = token_data.get("amr", [])
device_id = token_data.get("deviceid") or token_data.get("device_id")
upn = token_data.get("preferred_username") or token_data.get("upn", "")
display_name = token_data.get("name", upn)
if not oid:
return AuthResult(
status=AuthResult.STATUS_DENIED,
denial_reason="Token missing oid claim.",
)
# Check role requirement for requested accord
requested_accord = self.config.get("requested_accord", "")
required_role = self.config.get("accord_roles", {}).get(requested_accord, "")
suffix = self.config.get("elevated_suffix", "-elevated")
elevation_active = [r for r in roles if r.endswith(suffix)]
if required_role and required_role not in roles:
return AuthResult(
status=AuthResult.STATUS_PENDING_ELEVATION,
elevation_required=ElevationRequired(
role=required_role,
activation_url="/governance/elevate/",
instructions=f"Request elevation to '{required_role}' via POST /governance/elevate/",
mechanism="entra_pim",
),
)
# Construct DID
domain = self.config.get("domain", "guildhouse.dev")
principal_did = f"did:web:{domain}:principal:{oid}"
# MFA detection
mfa_satisfied = "mfa" in amr or "ngcmfa" in amr
return AuthResult(
status=AuthResult.STATUS_AUTHORIZED,
principal_did=principal_did,
display_name=display_name,
stable_id=oid,
token_jti=token_data.get("jti", ""),
elevation_active=elevation_active,
mfa_satisfied=mfa_satisfied,
device_id=device_id,
)
async def revoke(self, session_id: str) -> None:
logger.info("Entra revoke: %s", session_id)

View file

@ -1,137 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Shared JWKS verification for identity drivers.
Fetches, caches, and verifies JWTs against JWKS endpoints.
Used by both Keycloak and Entra drivers.
SECURITY: Never falls back to unverified claims on JWKS failure.
A JWKS fetch failure MUST result in authentication denial.
Fix C-1: Keycloak tokens are now verified via this module.
Fix C-3: Entra tokens are denied on JWKS failure (no fallback).
Fix H-10: JWKS cache refreshes on kid miss for key rotation.
"""
import logging
import time
from typing import Any, Optional
import httpx
from jose import JWTError, jwt as jose_jwt
logger = logging.getLogger(__name__)
class AuthenticationError(Exception):
"""JWT verification failed. The request MUST be denied."""
class JWKSKeyNotFound(AuthenticationError):
"""The JWT's kid does not match any key in the cached JWKS."""
class JWKSVerifier:
"""Verifies JWTs against a remote JWKS endpoint.
Cache TTL defaults to 1 hour. On kid miss, the cache is
invalidated and JWKS is re-fetched once before rejecting.
"""
def __init__(
self,
jwks_url: str,
audience: str,
issuer: str,
cache_ttl: int = 3600,
):
self._jwks_url = jwks_url
self._audience = audience
self._issuer = issuer
self._cache_ttl = cache_ttl
self._jwks_cache: Optional[dict[str, Any]] = None
self._cache_fetched_at: float = 0.0
async def verify_token(self, raw_token: str) -> dict[str, Any]:
"""Verify JWT signature and standard claims.
Returns the verified claims dict.
Raises AuthenticationError on ANY failure NEVER falls back
to unverified claims.
"""
if not raw_token:
raise AuthenticationError("No token provided")
jwks = await self._fetch_jwks()
try:
unverified_header = jose_jwt.get_unverified_header(raw_token)
except JWTError as e:
raise AuthenticationError(f"Malformed JWT header: {e}")
kid = unverified_header.get("kid", "")
signing_key = self._find_key(jwks, kid)
if signing_key is None:
raise JWKSKeyNotFound(f"No matching key for kid={kid}")
try:
# algorithms=["RS256"] blocks alg=none and HMAC confusion
return jose_jwt.decode(
raw_token,
signing_key,
algorithms=["RS256"],
audience=self._audience,
issuer=self._issuer,
options={"require_exp": True},
)
except JWTError as e:
raise AuthenticationError(f"JWT verification failed: {e}")
async def verify_or_refresh(self, raw_token: str) -> dict[str, Any]:
"""Verify with cache; on kid miss, refresh JWKS once and retry.
Fix H-10: handles key rotation gracefully.
"""
try:
return await self.verify_token(raw_token)
except JWKSKeyNotFound:
# kid not in cache — force refresh and retry once
self._invalidate_cache()
return await self.verify_token(raw_token)
async def _fetch_jwks(self) -> dict[str, Any]:
"""Fetch and cache JWKS. Raises on failure — never falls back."""
if self._cache_valid():
return self._jwks_cache
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(self._jwks_url)
resp.raise_for_status()
self._jwks_cache = resp.json()
self._cache_fetched_at = time.time()
return self._jwks_cache
except Exception as e:
# Fix C-3: NEVER fall back — deny on failure
raise AuthenticationError(
f"JWKS fetch failed from {self._jwks_url}: {e}. "
"Cannot verify token — denying."
)
def _cache_valid(self) -> bool:
return (
self._jwks_cache is not None
and (time.time() - self._cache_fetched_at) < self._cache_ttl
)
def _invalidate_cache(self) -> None:
self._jwks_cache = None
self._cache_fetched_at = 0.0
@staticmethod
def _find_key(jwks: dict[str, Any], kid: str) -> Optional[dict[str, Any]]:
for key in jwks.get("keys", []):
if key.get("kid") == kid:
return key
return None

View file

@ -1,47 +1,14 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Keycloak identity driver — GSAP §2.2.
Fix C-1: JWT signatures are now verified via JWKS.
Previously this driver accepted any base64-decoded JWT without
signature verification. Now uses shared JWKSVerifier.
"""
"""Keycloak identity driver — GSAP §2.2."""
import logging
from .base import IdentityDriver, AuthResult, ElevationRequired
from .jwks import AuthenticationError, JWKSVerifier
logger = logging.getLogger(__name__)
class KeycloakDriver(IdentityDriver):
async def authenticate(self) -> AuthResult:
raw_token = self.config.get("_raw_token", "")
keycloak_url = self.config.get("keycloak_url", "http://localhost:8080")
keycloak_realm = self.config.get("keycloak_realm", "substrate")
keycloak_client_id = self.config.get("keycloak_client_id", "")
# Fix C-1: verify JWT signature via JWKS before trusting claims.
if raw_token and keycloak_url and keycloak_realm:
verifier = JWKSVerifier(
jwks_url=f"{keycloak_url}/realms/{keycloak_realm}/protocol/openid-connect/certs",
audience=keycloak_client_id,
issuer=f"{keycloak_url}/realms/{keycloak_realm}",
)
try:
token_data = await verifier.verify_or_refresh(raw_token)
except AuthenticationError as e:
return AuthResult(
status=AuthResult.STATUS_DENIED,
denial_reason=str(e),
)
else:
# No raw token or no Keycloak config — cannot verify
return AuthResult(
status=AuthResult.STATUS_DENIED,
denial_reason="No token or Keycloak configuration missing.",
)
token_data = self.config.get("_token_data", {})
if not token_data:
return AuthResult(status=AuthResult.STATUS_DENIED, denial_reason="No token in context.")
realm_roles = token_data.get("realm_access", {}).get("roles", [])
requested_accord = self.config.get("requested_accord", "")

View file

@ -1,9 +1,8 @@
"""Driver Registry — GSAP §2.5."""
from .base import IdentityDriver
from .entra import EntraDriver
from .keycloak import KeycloakDriver
_DRIVERS: dict[str, type[IdentityDriver]] = {"keycloak": KeycloakDriver, "entra": EntraDriver}
_DRIVERS: dict[str, type[IdentityDriver]] = {"keycloak": KeycloakDriver}
class DriverRegistry:
@staticmethod

View file

@ -1,2 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0

View file

@ -1,39 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""In-memory cache of Intune device compliance state."""
import time
from typing import Optional
from gsap_broker.models.intune import ComplianceState
class DeviceComplianceCache:
"""In-memory cache with TTL for device compliance state."""
def __init__(self, ttl_seconds: int = 300):
self.ttl = ttl_seconds
self._store: dict[str, tuple[ComplianceState, float]] = {}
async def get(self, device_id: str) -> Optional[ComplianceState]:
"""Get cached compliance state, or None if expired/missing."""
entry = self._store.get(device_id)
if entry is None:
return None
state, stored_at = entry
if (time.time() - stored_at) > self.ttl:
del self._store[device_id]
return None
return state
async def set(self, device_id: str, state: ComplianceState) -> None:
"""Cache a compliance state."""
self._store[device_id] = (state, time.time())
async def invalidate(self, device_id: str) -> None:
"""Remove a device from cache."""
self._store.pop(device_id, None)
def size(self) -> int:
return len(self._store)

View file

@ -1,103 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Shared Microsoft Graph API client.
Uses MSAL client_credentials flow for app-only access.
Provides authenticated httpx calls to Graph API endpoints.
Extracted from delegations/registrars/entra.py to serve
both the Entra registrar and the Intune connector.
"""
import logging
from typing import Any, Optional
import httpx
import msal
logger = logging.getLogger(__name__)
GRAPH_API_DEFAULT = "https://graph.microsoft.com/v1.0"
class GraphClient:
"""Authenticated Microsoft Graph API client."""
def __init__(
self,
tenant_id: str,
client_id: str,
client_secret: str,
graph_api_base: str = GRAPH_API_DEFAULT,
):
self.tenant_id = tenant_id
self.client_id = client_id
self.client_secret = client_secret
self.graph_api_base = graph_api_base.rstrip("/")
self._app = msal.ConfidentialClientApplication(
client_id=self.client_id,
client_credential=self.client_secret,
authority=f"https://login.microsoftonline.com/{self.tenant_id}",
)
async def acquire_token(self) -> str:
"""Acquire an access token via MSAL client_credentials."""
result = self._app.acquire_token_for_client(
scopes=["https://graph.microsoft.com/.default"]
)
if "access_token" in result:
return result["access_token"]
raise RuntimeError(
f"Graph token error: {result.get('error_description', result.get('error', 'unknown'))}"
)
async def _headers(self) -> dict[str, str]:
token = await self.acquire_token()
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
async def get(
self, path: str, params: Optional[dict[str, Any]] = None
) -> dict[str, Any]:
"""Authenticated GET to Graph API."""
headers = await self._headers()
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(
f"{self.graph_api_base}{path}", params=params, headers=headers
)
resp.raise_for_status()
return resp.json()
async def post(
self, path: str, body: Optional[dict[str, Any]] = None
) -> httpx.Response:
"""Authenticated POST to Graph API. Returns raw Response for status/header access."""
headers = await self._headers()
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
f"{self.graph_api_base}{path}", json=body, headers=headers
)
return resp
async def patch(
self, path: str, body: Optional[dict[str, Any]] = None
) -> dict[str, Any]:
"""Authenticated PATCH to Graph API."""
headers = await self._headers()
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.patch(
f"{self.graph_api_base}{path}", json=body, headers=headers
)
resp.raise_for_status()
return resp.json()
async def delete(self, path: str) -> bool:
"""Authenticated DELETE to Graph API. Returns True if 200/204."""
headers = await self._headers()
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.delete(
f"{self.graph_api_base}{path}", headers=headers
)
return resp.status_code in (200, 204)

View file

@ -158,49 +158,6 @@ TOOLS = [
"description": "Get current session details: principal, AC scope, delegation, DEFCON level.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "list_devices",
"description": "List managed devices from Intune. Requires Intune connector enabled.",
"inputSchema": {
"type": "object",
"properties": {
"top": {"type": "integer", "description": "Max devices to return (default: 50)"},
},
},
},
{
"name": "get_device_compliance",
"description": "Check compliance state of a specific device via Intune.",
"inputSchema": {
"type": "object",
"properties": {
"device_id": {"type": "string", "description": "Intune managed device ID"},
},
"required": ["device_id"],
},
},
{
"name": "sync_device",
"description": "Trigger Intune sync for a device. Requires PROPOSE capability.",
"inputSchema": {
"type": "object",
"properties": {
"device_id": {"type": "string", "description": "Intune managed device ID"},
},
"required": ["device_id"],
},
},
{
"name": "remote_lock",
"description": "Remote lock a managed device. Requires MUTATE capability. May require ceremony approval in production Accords.",
"inputSchema": {
"type": "object",
"properties": {
"device_id": {"type": "string", "description": "Intune managed device ID"},
},
"required": ["device_id"],
},
},
]
@ -454,32 +411,6 @@ async def _handle_session_info(request: Request) -> dict:
}
async def _handle_intune_tool(tool_name: str, args: dict) -> dict:
"""Route Intune MCP tools through the governed IntuneConnector."""
from gsap_broker.routers.connectors import _registry
from gsap_broker.connectors.base import ConnectorContext
intune = _registry.get("intune")
if intune is None:
return {"error": "Intune connector not enabled. Set intune_enabled=True."}
op_map = {
"list_devices": "list_devices",
"get_device_compliance": "get_compliance",
"sync_device": "sync_device",
"remote_lock": "remote_lock",
}
operation = op_map.get(tool_name)
if not operation:
return {"error": f"Unknown Intune tool: {tool_name}"}
ctx = ConnectorContext(gsap_context_id=args.get("ac_id", "mcp-session"))
result = await intune.invoke(operation, args, ctx)
if result.success:
return {"data": result.data, "lineage_cid": result.lineage_cid}
return {"error": result.error}
# ── Tool Dispatch ────────────────────────────────────────────────
@ -496,10 +427,6 @@ async def _dispatch_tool(request: Request, tool_name: str, arguments: dict) -> d
"get_posture": lambda: _handle_get_posture(arguments),
"check_operation": lambda: _handle_check_operation(arguments, request),
"session_info": lambda: _handle_session_info(request),
"list_devices": lambda: _handle_intune_tool(tool_name, arguments),
"get_device_compliance": lambda: _handle_intune_tool(tool_name, arguments),
"sync_device": lambda: _handle_intune_tool(tool_name, arguments),
"remote_lock": lambda: _handle_intune_tool(tool_name, arguments),
}
handler = handlers.get(tool_name)

View file

@ -58,9 +58,6 @@ class AuthorizationContext(BaseModel):
identity_proof: IdentityProof
broker: dict = Field(default_factory=dict)
signature: Optional[dict] = None
device_id: Optional[str] = None
device_compliant: Optional[bool] = None
compliance_checked_at: Optional[datetime] = None
class ChronicleEvidence(BaseModel):
session_id: Optional[str] = None

View file

@ -1,28 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Pydantic models for Intune device management."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class DeviceSummary(BaseModel):
device_id: str
device_name: str = ""
os_type: str = "" # windows, linux, macOS, android, iOS
os_version: str = ""
compliance_state: str = "" # compliant, noncompliant, unknown, configManager, ...
last_sync: Optional[datetime] = None
user_principal_name: Optional[str] = None
entra_device_id: Optional[str] = None
class ComplianceState(BaseModel):
device_id: str
compliant: bool
state: str = "" # compliant, noncompliant, configManager, ...
detail: Optional[str] = None
last_evaluated: datetime

View file

@ -1,8 +1,6 @@
"""POST /governance/authorize/ — GSAP §5.2"""
import logging
import secrets, uuid
from datetime import datetime, timedelta, UTC
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel.ext.asyncio.session import AsyncSession
from gsap_broker.db import get_session
@ -14,15 +12,6 @@ from gsap_broker.models import (
from gsap_broker.settings import settings
from gsap_broker import chronicle
logger = logging.getLogger(__name__)
# Accord templates that require device compliance.
# In production these would come from a database or config file.
_ACCORD_COMPLIANCE = {
"infrastructure-operations": {"device_compliance_required": True},
"device-management": {"device_compliance_required": True},
}
router = APIRouter()
@ -45,11 +34,6 @@ def _extract_token_data(http_request: Request) -> dict:
async def authorize(body: AuthorizeRequest, http_request: Request, db: AsyncSession = Depends(get_session)):
request = body
token_data = _extract_token_data(http_request)
raw_token = ""
auth_header = http_request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
raw_token = auth_header[7:]
try:
driver = DriverRegistry.get(request.driver_id, config={
"requested_accord": request.accord_template,
@ -57,13 +41,6 @@ async def authorize(body: AuthorizeRequest, http_request: Request, db: AsyncSess
"did_template": settings.keycloak_did_template,
"elevated_suffix": settings.keycloak_elevated_role_suffix,
"_token_data": token_data,
"_raw_token": raw_token,
"entra_tenant_id": settings.entra_tenant_id,
"entra_client_id": settings.entra_client_id,
# Fix C-1: Keycloak driver needs these for JWKS verification
"keycloak_url": settings.keycloak_url,
"keycloak_realm": settings.keycloak_realm,
"keycloak_client_id": settings.keycloak_admin_client_id,
})
except KeyError as e:
raise HTTPException(status_code=400, detail=str(e))
@ -86,71 +63,11 @@ async def authorize(body: AuthorizeRequest, http_request: Request, db: AsyncSess
if not auth_result.is_authorized:
raise HTTPException(status_code=403, detail=auth_result.denial_reason)
# ── Compliance gate ──────────────────────────────────────────
device_id: Optional[str] = auth_result.device_id
device_compliant: Optional[bool] = None
compliance_checked_at: Optional[datetime] = None
if settings.intune_enabled:
accord_policy = _ACCORD_COMPLIANCE.get(request.accord_template, {})
compliance_required = accord_policy.get(
"device_compliance_required", settings.intune_compliance_required
)
if compliance_required:
if not device_id:
if settings.intune_compliance_strict:
await chronicle.emit("DEVICE_COMPLIANCE_CHECKED", {
"event_code": "0x2801",
"principal_did": auth_result.principal_did,
"accord_template": request.accord_template,
"device_id": None,
"decision": "denied",
"reason": "no_device_identity",
})
raise HTTPException(
status_code=403,
detail="Device identity required for this accord template.",
)
# Permissive mode: allow without compliance fields
logger.info("Compliance required but no device_id — permissive mode, allowing")
else:
# Check compliance via Intune connector cache/API
compliance_state = await _check_device_compliance(device_id)
compliance_checked_at = datetime.now(UTC)
device_compliant = compliance_state
await chronicle.emit("DEVICE_COMPLIANCE_CHECKED", {
"event_code": "0x2801",
"principal_did": auth_result.principal_did,
"accord_template": request.accord_template,
"device_id": device_id,
"compliant": compliance_state,
"decision": "allowed" if compliance_state else "denied",
})
if not compliance_state:
raise HTTPException(
status_code=403,
detail="Device is not compliant. AC issuance denied.",
)
# ── End compliance gate ───────────────────────────────────────
now = datetime.now(UTC)
expires = now + timedelta(minutes=settings.ac_ttl_minutes)
ctx_id = uuid.uuid4()
# Fix C-2: on_behalf_of requires gsap:impersonate role
if request.on_behalf_of:
caller_roles = getattr(auth_result, "elevation_active", [])
# Check for impersonation role in JWT roles (passed through auth_result)
token_roles = token_data.get("roles", []) + token_data.get("realm_access", {}).get("roles", [])
if "gsap:impersonate" not in token_roles:
raise HTTPException(
status_code=403,
detail="on_behalf_of requires gsap:impersonate role.",
)
# on_behalf_of: trusted caller (Bascule SA) asserts who the AC is for
principal_did = request.on_behalf_of or auth_result.principal_did
display_name = request.on_behalf_of.rsplit("/", 1)[-1] if request.on_behalf_of else auth_result.display_name
@ -160,9 +77,7 @@ async def authorize(body: AuthorizeRequest, http_request: Request, db: AsyncSess
accord=Accord(template=request.accord_template),
operation=Operation(playbook=request.playbook, corpus_entry_cid=request.corpus_entry_cid, parameters_cid=request.parameters_cid),
identity_proof=IdentityProof(token_jti=auth_result.token_jti, elevation_active=auth_result.elevation_active, mfa_satisfied=auth_result.mfa_satisfied),
broker={"did": settings.broker_did, "name": settings.broker_name},
device_id=device_id, device_compliant=device_compliant,
compliance_checked_at=compliance_checked_at)
broker={"did": settings.broker_did, "name": settings.broker_name})
ac_db = AuthorizationContextDB(
context_id=ctx_id, principal_did=principal_did, driver_id=request.driver_id,
@ -185,25 +100,3 @@ async def authorize_poll(poll_token: str, db: AsyncSession = Depends(get_session
ac_db = result.first()
if not ac_db: raise HTTPException(status_code=404, detail="Not found.")
return AuthorizeResponse(status=ac_db.status, poll_token=poll_token)
async def _check_device_compliance(device_id: str) -> bool:
"""Check device compliance via the Intune connector cache or Graph API.
Returns True if compliant, False otherwise.
"""
try:
from gsap_broker.routers.connectors import _registry
intune = _registry.get("intune")
if intune is None:
logger.warning("Intune connector not registered — defaulting to compliant")
return True
from gsap_broker.connectors.base import ConnectorContext
ctx = ConnectorContext(gsap_context_id="compliance-gate")
result = await intune.invoke("get_compliance", {"device_id": device_id}, ctx)
if result.success and result.data:
return result.data.get("compliant", False)
return False
except Exception as e:
logger.error("Compliance check failed: %s", e)
return False

View file

@ -10,7 +10,6 @@ from gsap_broker.connectors.base import ConnectorContext
from gsap_broker.connectors.registry import ConnectorRegistry
from gsap_broker.connectors.runtime import ConnectorRuntime
from gsap_broker.connectors.examples.echo_connector import EchoConnector
from gsap_broker.settings import settings
router = APIRouter()
@ -21,64 +20,6 @@ _runtime = ConnectorRuntime(registry=_registry)
# Register built-in connectors
_registry.register(EchoConnector())
# ── 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
):
# Fix H-2: stub backend requires explicit opt-in
if not settings.allow_stub_credentials:
import logging as _logging
_logging.getLogger(__name__).warning(
"StubCredentialBackend would activate but ALLOW_STUB_CREDENTIALS is not set. "
"Session connectors will have no credential backend."
)
else:
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
from gsap_broker.connectors.intune import IntuneConnector
_intune_graph = GraphClient(
tenant_id=settings.entra_tenant_id,
client_id=settings.entra_client_id,
client_secret=settings.entra_client_secret,
)
_intune_cache = DeviceComplianceCache(ttl_seconds=settings.intune_compliance_cache_ttl)
_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
@ -87,9 +28,6 @@ class InvokeRequest(BaseModel):
gsap_context_id: str = ""
pipeline_run_id: str = ""
dag_id: str = ""
# Fix C-6: caller must declare the AC's capability_mask
capability_mask: int = 0
principal_did: str = ""
class InvokeResponse(BaseModel):
@ -125,8 +63,6 @@ async def invoke_connector(connector_id: str, body: InvokeRequest) -> InvokeResp
gsap_context_id=body.gsap_context_id,
pipeline_run_id=body.pipeline_run_id,
dag_id=body.dag_id,
capability_mask=body.capability_mask,
principal_did=body.principal_did,
)
result = await _runtime.invoke(connector_id, body.operation, body.parameters, ctx)
return InvokeResponse(

View file

@ -1,2 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0

View file

@ -1,159 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""DeviceRouter — automatic connector selection based on target device.
The DeviceRouter inspects the target device's characteristics (OS,
management channel, enrollment status) and routes operations to the
appropriate connector.
Routing decision logic:
1. If the operation is API-mediated (compliance check, inventory
query), route to the Intune connector regardless of device OS.
2. If the device is Windows and the operation is session-based
(execute, configure), route to PowerShell connector.
3. If the device is Linux/macOS and the operation is session-based,
route to Bascule connector.
4. If the operation is multi-host (fleet-wide playbook), route to
Ansible connector.
5. If the device is unknown, raise ``UnknownDevice``.
The device inventory comes from:
- Intune device cache (Windows/iOS/Android managed devices)
- Bascule fleet registry (Linux governed endpoints) future
- Manual registration (for unmanaged devices) future
Rust port note:
``DeviceRouter`` maps to a struct with connector registry and
device cache references. The routing logic is a match statement
on ``(operation_type, device_os)``.
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from gsap_broker.connectors.base import ConnectorContext, ConnectorResult
from gsap_broker.connectors.registry import ConnectorRegistry
from gsap_broker.intune.device_cache import DeviceComplianceCache
logger = logging.getLogger(__name__)
# Operations that are API-mediated (go through Intune Graph API
# regardless of device OS).
_API_OPERATIONS = frozenset({
"get_compliance", "list_devices", "get_device",
"sync_device", "retire_device", "wipe_device",
})
# Operations that span multiple hosts (go through Ansible).
_FLEET_OPERATIONS = frozenset({
"playbook", "adhoc", "role", "collect",
})
# OS → preferred session connector mapping.
_OS_CONNECTOR_MAP = {
"windows": "powershell",
"linux": "bascule",
"macos": "bascule",
"ios": "intune", # mobile — API-only
"android": "intune", # mobile — API-only
}
class UnknownDevice(Exception):
"""Target device is not in any inventory."""
def __init__(self, target: str):
self.target = target
super().__init__(f"Unknown device: {target}")
class DeviceRouter:
"""Routes operations to the appropriate connector based on target.
Combines the device inventory (Intune cache) with the connector
registry to select the best management channel for each operation.
"""
def __init__(
self,
connector_registry: ConnectorRegistry,
device_cache: Optional[DeviceComplianceCache] = None,
):
self._connectors = connector_registry
self._devices = device_cache
async def route(
self, operation: str, target: str, context: ConnectorContext
) -> tuple[str, str]:
"""Determine which connector handles this operation.
Returns:
(connector_id, mapped_operation) the connector to use
and the operation name to pass to it (may differ from the
input if the router translates operations).
Raises:
UnknownDevice: if the target is not in any inventory and
cannot be routed.
"""
# API-mediated operations always go to Intune
if operation in _API_OPERATIONS:
return ("intune", operation)
# Fleet operations go to Ansible
if operation in _FLEET_OPERATIONS:
return ("ansible", operation)
# Session operations: route based on device OS
device_os = await self._detect_os(target)
if device_os is None:
raise UnknownDevice(target)
connector_id = _OS_CONNECTOR_MAP.get(device_os.lower(), "bascule")
return (connector_id, operation)
async def invoke(
self,
operation: str,
target: str,
parameters: dict[str, Any],
context: ConnectorContext,
) -> ConnectorResult:
"""Route and invoke in one call."""
try:
connector_id, mapped_op = await self.route(operation, target, context)
except UnknownDevice as e:
return ConnectorResult(success=False, error=str(e))
connector = self._connectors.get(connector_id)
if connector is None:
return ConnectorResult(
success=False,
error=f"Connector '{connector_id}' not registered",
)
parameters["target"] = target
return await connector.invoke(mapped_op, parameters, context)
async def _detect_os(self, target: str) -> Optional[str]:
"""Look up the device OS from the Intune cache.
Future: also check Bascule fleet registry, manual
registration, DNS-based hints, etc.
"""
if self._devices is None:
return None
cached = await self._devices.get(target)
if cached is not None:
# ComplianceState doesn't carry OS — the cache is keyed
# by device_id but the Intune connector's list_devices
# response has os_type. For now, return None and let the
# caller handle it. A proper DeviceInventory cache (not
# just compliance) is needed — tracked for next sprint.
return None
return None

View file

@ -35,24 +35,6 @@ class Settings(BaseSettings):
entra_client_secret: str = ""
entra_agent_blueprint_id: str = ""
# ── Intune / Device Management ──
intune_enabled: bool = False
intune_compliance_required: bool = False # global default for accord templates
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"
# Fix H-2: stub backend requires explicit opt-in
allow_stub_credentials: bool = False
# Delegation defaults
default_delegation_ttl_minutes: int = 60
default_max_commands: int = 500

View file

@ -1,210 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Tests for compliance-gated AC issuance."""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from httpx import AsyncClient, ASGITransport
from sqlmodel import SQLModel
from sqlalchemy.ext.asyncio import create_async_engine
from gsap_broker.app import app
from gsap_broker import db as db_module
from gsap_broker.drivers.base import AuthResult
@pytest.fixture(autouse=True)
async def test_db():
engine = create_async_engine("sqlite+aiosqlite:///./test_compliance.db")
db_module.engine = engine
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
yield
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.drop_all)
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
def _mock_auth_result(device_id=None):
"""Return a mock auth result with optional device_id."""
return AuthResult(
status=AuthResult.STATUS_AUTHORIZED,
principal_did="did:web:test/p/alice",
display_name="Alice",
token_jti="jti-test",
mfa_satisfied=True,
device_id=device_id,
)
def _authorize_body(accord_template="test-ops"):
return {
"playbook": "test",
"corpus_entry_cid": "sha256:" + "a" * 64,
"parameters_cid": "sha256:" + "b" * 64,
"accord_template": accord_template,
"driver_id": "keycloak",
}
# ── TEST 13: Compliance disabled by default ───────────────────
@pytest.mark.asyncio
async def test_compliance_disabled_by_default(client, mocker):
"""With default settings (intune_enabled=False), no compliance check runs."""
mocker.patch(
"gsap_broker.drivers.keycloak.KeycloakDriver.authenticate",
return_value=_mock_auth_result(),
)
resp = await client.post("/governance/authorize/", json=_authorize_body())
assert resp.status_code == 200
ac = resp.json()["authorization_context"]
assert ac["device_id"] is None
assert ac["device_compliant"] is None
# ── TEST 9: Compliant device → AC issued ──────────────────────
@pytest.mark.asyncio
async def test_compliance_required_compliant_device(client, mocker):
"""Compliance required + compliant device → AC issued with device metadata."""
mocker.patch(
"gsap_broker.drivers.keycloak.KeycloakDriver.authenticate",
return_value=_mock_auth_result(device_id="dev-123"),
)
mocker.patch("gsap_broker.routers.authorize.settings.intune_enabled", True)
mocker.patch("gsap_broker.routers.authorize.settings.intune_compliance_required", True)
mocker.patch(
"gsap_broker.routers.authorize._check_device_compliance",
new_callable=AsyncMock,
return_value=True,
)
resp = await client.post("/governance/authorize/", json=_authorize_body())
assert resp.status_code == 200
ac = resp.json()["authorization_context"]
assert ac["device_id"] == "dev-123"
assert ac["device_compliant"] is True
assert ac["compliance_checked_at"] is not None
# ── TEST 10: Non-compliant device → 403 ──────────────────────
@pytest.mark.asyncio
async def test_compliance_required_noncompliant_device(client, mocker):
"""Compliance required + non-compliant device → 403."""
mocker.patch(
"gsap_broker.drivers.keycloak.KeycloakDriver.authenticate",
return_value=_mock_auth_result(device_id="dev-bad"),
)
mocker.patch("gsap_broker.routers.authorize.settings.intune_enabled", True)
mocker.patch("gsap_broker.routers.authorize.settings.intune_compliance_required", True)
mocker.patch(
"gsap_broker.routers.authorize._check_device_compliance",
new_callable=AsyncMock,
return_value=False,
)
resp = await client.post("/governance/authorize/", json=_authorize_body())
assert resp.status_code == 403
assert "not compliant" in resp.json()["detail"].lower()
# ── TEST 11: No device_id + strict mode → 403 ────────────────
@pytest.mark.asyncio
async def test_compliance_strict_no_device_id(client, mocker):
"""Strict mode + no device_id → 403."""
mocker.patch(
"gsap_broker.drivers.keycloak.KeycloakDriver.authenticate",
return_value=_mock_auth_result(device_id=None),
)
mocker.patch("gsap_broker.routers.authorize.settings.intune_enabled", True)
mocker.patch("gsap_broker.routers.authorize.settings.intune_compliance_required", True)
mocker.patch("gsap_broker.routers.authorize.settings.intune_compliance_strict", True)
resp = await client.post("/governance/authorize/", json=_authorize_body())
assert resp.status_code == 403
assert "device identity required" in resp.json()["detail"].lower()
# ── TEST 12: No device_id + permissive mode → AC issued ──────
@pytest.mark.asyncio
async def test_compliance_permissive_no_device_id(client, mocker):
"""Permissive mode + no device_id → AC issued without compliance fields."""
mocker.patch(
"gsap_broker.drivers.keycloak.KeycloakDriver.authenticate",
return_value=_mock_auth_result(device_id=None),
)
mocker.patch("gsap_broker.routers.authorize.settings.intune_enabled", True)
mocker.patch("gsap_broker.routers.authorize.settings.intune_compliance_required", True)
mocker.patch("gsap_broker.routers.authorize.settings.intune_compliance_strict", False)
resp = await client.post("/governance/authorize/", json=_authorize_body())
assert resp.status_code == 200
ac = resp.json()["authorization_context"]
assert ac["device_compliant"] is None
# ── TEST: Per-accord compliance override ──────────────────────
@pytest.mark.asyncio
async def test_per_accord_compliance_override(client, mocker):
"""Accord template 'infrastructure-operations' requires compliance even when global default is False."""
mocker.patch(
"gsap_broker.drivers.keycloak.KeycloakDriver.authenticate",
return_value=_mock_auth_result(device_id="dev-infra"),
)
mocker.patch("gsap_broker.routers.authorize.settings.intune_enabled", True)
mocker.patch("gsap_broker.routers.authorize.settings.intune_compliance_required", False)
mocker.patch(
"gsap_broker.routers.authorize._check_device_compliance",
new_callable=AsyncMock,
return_value=True,
)
resp = await client.post(
"/governance/authorize/",
json=_authorize_body(accord_template="infrastructure-operations"),
)
assert resp.status_code == 200
ac = resp.json()["authorization_context"]
assert ac["device_compliant"] is True
# ── TEST 14: Chronicle event emitted ─────────────────────────
@pytest.mark.asyncio
async def test_chronicle_event_on_compliance_check(client, mocker):
"""Compliance check emits DEVICE_COMPLIANCE_CHECKED Chronicle event."""
mocker.patch(
"gsap_broker.drivers.keycloak.KeycloakDriver.authenticate",
return_value=_mock_auth_result(device_id="dev-chron"),
)
mocker.patch("gsap_broker.routers.authorize.settings.intune_enabled", True)
mocker.patch("gsap_broker.routers.authorize.settings.intune_compliance_required", True)
mocker.patch(
"gsap_broker.routers.authorize._check_device_compliance",
new_callable=AsyncMock,
return_value=True,
)
chronicle_mock = mocker.patch("gsap_broker.routers.authorize.chronicle.emit", new_callable=AsyncMock, return_value="")
resp = await client.post("/governance/authorize/", json=_authorize_body())
assert resp.status_code == 200
# Find the DEVICE_COMPLIANCE_CHECKED call
compliance_calls = [
call for call in chronicle_mock.call_args_list
if call.args[0] == "DEVICE_COMPLIANCE_CHECKED"
]
assert len(compliance_calls) == 1
event_data = compliance_calls[0].args[1]
assert event_data["device_id"] == "dev-chron"
assert event_data["decision"] == "allowed"

View file

@ -1,316 +0,0 @@
# 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

View file

@ -1,205 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Tests for the Entra identity driver — C-3, H-10."""
import pytest
from unittest.mock import AsyncMock, patch
from jose import jwt as jose_jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from gsap_broker.drivers.entra import EntraDriver
from gsap_broker.drivers.jwks import AuthenticationError
def _generate_rsa_keypair():
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
return private_key, public_key
def _private_key_pem(private_key):
return private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
def _public_numbers_to_jwk(public_key, kid="test-kid-1"):
import base64
nums = public_key.public_numbers()
e_bytes = nums.e.to_bytes((nums.e.bit_length() + 7) // 8, "big")
n_bytes = nums.n.to_bytes((nums.n.bit_length() + 7) // 8, "big")
return {
"kty": "RSA",
"kid": kid,
"use": "sig",
"alg": "RS256",
"n": base64.urlsafe_b64encode(n_bytes).rstrip(b"=").decode(),
"e": base64.urlsafe_b64encode(e_bytes).rstrip(b"=").decode(),
}
PRIVATE_KEY, PUBLIC_KEY = _generate_rsa_keypair()
KID = "test-kid-1"
TENANT_ID = "test-tenant-id-1234"
CLIENT_ID = "test-client-id-5678"
JWKS = {"keys": [_public_numbers_to_jwk(PUBLIC_KEY, KID)]}
def _make_token(claims: dict, kid: str = KID, expired: bool = False) -> str:
import datetime
now = datetime.datetime.now(datetime.UTC)
base_claims = {
"iss": f"https://login.microsoftonline.com/{TENANT_ID}/v2.0",
"aud": CLIENT_ID,
"iat": int(now.timestamp()),
"nbf": int(now.timestamp()),
"exp": int((now + datetime.timedelta(hours=1)).timestamp()),
"oid": "user-oid-1",
"tid": TENANT_ID,
"preferred_username": "alice@contoso.com",
"name": "Alice Smith",
"jti": "test-jti",
}
if expired:
base_claims["exp"] = int((now - datetime.timedelta(hours=1)).timestamp())
base_claims["nbf"] = int((now - datetime.timedelta(hours=2)).timestamp())
base_claims["iat"] = int((now - datetime.timedelta(hours=2)).timestamp())
base_claims.update(claims)
return jose_jwt.encode(
base_claims, _private_key_pem(PRIVATE_KEY), algorithm="RS256", headers={"kid": kid}
)
def _driver_config(raw_token: str = "", extra: dict = None) -> dict:
config = {
"_raw_token": raw_token,
"entra_tenant_id": TENANT_ID,
"entra_client_id": CLIENT_ID,
"domain": "contoso.com",
}
if extra:
config.update(extra)
return config
@pytest.fixture
def mock_jwks_fetch():
"""Mock the JWKS HTTP fetch to return test keys."""
with patch("gsap_broker.drivers.jwks.httpx.AsyncClient") as mock_http:
import unittest.mock
mock_resp = unittest.mock.MagicMock()
mock_resp.json.return_value = JWKS
mock_resp.raise_for_status = unittest.mock.MagicMock()
ctx_manager = AsyncMock()
ctx_manager.__aenter__.return_value.get = AsyncMock(return_value=mock_resp)
mock_http.return_value = ctx_manager
yield mock_http
@pytest.mark.asyncio
async def test_authenticate_valid_token(mock_jwks_fetch):
token = _make_token({"roles": ["admin"], "amr": ["pwd", "mfa"]})
driver = EntraDriver(config=_driver_config(raw_token=token))
result = await driver.authenticate()
assert result.is_authorized
assert result.principal_did == "did:web:contoso.com:principal:user-oid-1"
assert result.mfa_satisfied is True
@pytest.mark.asyncio
async def test_authenticate_extracts_device_id(mock_jwks_fetch):
token = _make_token({"deviceid": "device-abc-123"})
driver = EntraDriver(config=_driver_config(raw_token=token))
result = await driver.authenticate()
assert result.device_id == "device-abc-123"
@pytest.mark.asyncio
async def test_authenticate_no_device_id(mock_jwks_fetch):
token = _make_token({})
driver = EntraDriver(config=_driver_config(raw_token=token))
result = await driver.authenticate()
assert result.device_id is None
@pytest.mark.asyncio
async def test_authenticate_expired_token(mock_jwks_fetch):
token = _make_token({}, expired=True)
driver = EntraDriver(config=_driver_config(raw_token=token))
result = await driver.authenticate()
assert not result.is_authorized
@pytest.mark.asyncio
async def test_authenticate_no_token():
driver = EntraDriver(config={"_raw_token": "", "entra_tenant_id": TENANT_ID})
result = await driver.authenticate()
assert not result.is_authorized
assert "No token" in result.denial_reason
@pytest.mark.asyncio
async def test_authenticate_mfa_detection(mock_jwks_fetch):
token = _make_token({"amr": ["pwd", "mfa"]})
driver = EntraDriver(config=_driver_config(raw_token=token))
result = await driver.authenticate()
assert result.mfa_satisfied is True
token = _make_token({"amr": ["pwd"]})
driver = EntraDriver(config=_driver_config(raw_token=token))
result = await driver.authenticate()
assert result.mfa_satisfied is False
@pytest.mark.asyncio
async def test_authenticate_elevation_required(mock_jwks_fetch):
token = _make_token({"roles": ["reader"]})
config = _driver_config(raw_token=token, extra={
"requested_accord": "admin-ops",
"accord_roles": {"admin-ops": "admin-role"},
})
driver = EntraDriver(config=config)
result = await driver.authenticate()
assert result.needs_elevation
@pytest.mark.asyncio
async def test_did_construction(mock_jwks_fetch):
token = _make_token({"oid": "unique-user-oid"})
driver = EntraDriver(config=_driver_config(raw_token=token, extra={"domain": "example.dev"}))
result = await driver.authenticate()
assert result.principal_did == "did:web:example.dev:principal:unique-user-oid"
@pytest.mark.asyncio
async def test_wrong_kid_rejected_then_refreshed(mock_jwks_fetch):
"""H-10: kid miss triggers JWKS refresh. With only one JWKS response,
the second fetch still has the same keys, so unknown kid is rejected."""
token = _make_token({}, kid="unknown-kid")
driver = EntraDriver(config=_driver_config(raw_token=token))
result = await driver.authenticate()
assert not result.is_authorized
assert "signing key" in result.denial_reason.lower() or "key" in result.denial_reason.lower()
@pytest.mark.asyncio
async def test_jwks_failure_denies_no_fallback():
"""C-3: JWKS fetch failure results in denial, no fallback."""
with patch("gsap_broker.drivers.jwks.httpx.AsyncClient") as mock_http:
ctx_manager = AsyncMock()
ctx_manager.__aenter__.return_value.get = AsyncMock(
side_effect=Exception("Network unreachable")
)
mock_http.return_value = ctx_manager
token = _make_token({})
driver = EntraDriver(config=_driver_config(raw_token=token))
result = await driver.authenticate()
assert not result.is_authorized
assert "JWKS fetch failed" in result.denial_reason

View file

@ -1,74 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Tests for the shared Graph API client."""
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from gsap_broker.intune.graph_client import GraphClient
@pytest.fixture
def mock_msal():
with patch("gsap_broker.intune.graph_client.msal") as m:
app_instance = MagicMock()
app_instance.acquire_token_for_client.return_value = {
"access_token": "test-token-abc"
}
m.ConfidentialClientApplication.return_value = app_instance
yield m
@pytest.fixture
def graph(mock_msal):
return GraphClient(
tenant_id="test-tenant",
client_id="test-client",
client_secret="test-secret",
)
@pytest.mark.asyncio
async def test_acquire_token(graph, mock_msal):
token = await graph.acquire_token()
assert token == "test-token-abc"
@pytest.mark.asyncio
async def test_acquire_token_error():
with patch("gsap_broker.intune.graph_client.msal") as m:
app_instance = MagicMock()
app_instance.acquire_token_for_client.return_value = {
"error": "invalid_client",
"error_description": "Bad credentials",
}
m.ConfidentialClientApplication.return_value = app_instance
graph = GraphClient(
tenant_id="t", client_id="c", client_secret="s"
)
with pytest.raises(RuntimeError, match="Bad credentials"):
await graph.acquire_token()
@pytest.mark.asyncio
async def test_get_includes_auth_header(graph):
"""GET request includes Bearer token in Authorization header."""
import httpx
with patch("gsap_broker.intune.graph_client.httpx.AsyncClient") as mock_http:
mock_response = MagicMock()
mock_response.json.return_value = {"value": []}
mock_response.raise_for_status = MagicMock()
ctx_manager = AsyncMock()
ctx_manager.__aenter__.return_value.get = AsyncMock(return_value=mock_response)
mock_http.return_value = ctx_manager
result = await graph.get("/test/path", params={"$top": "10"})
call_args = ctx_manager.__aenter__.return_value.get.call_args
headers = call_args.kwargs.get("headers", {})
assert headers["Authorization"] == "Bearer test-token-abc"
assert result == {"value": []}

View file

@ -1,224 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Tests for Intune connector and device compliance cache."""
import time
import pytest
from datetime import datetime, UTC
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
from gsap_broker.connectors.base import ConnectorContext
from gsap_broker.connectors.intune import IntuneConnector
from gsap_broker.intune.device_cache import DeviceComplianceCache
from gsap_broker.intune.graph_client import GraphClient
from gsap_broker.models.intune import ComplianceState
@pytest.fixture
def mock_graph():
graph = MagicMock(spec=GraphClient)
graph.tenant_id = "test-tenant"
graph.client_id = "test-client"
graph.get = AsyncMock()
graph.post = AsyncMock()
return graph
@pytest.fixture
def cache():
return DeviceComplianceCache(ttl_seconds=5)
@pytest.fixture
def connector(mock_graph, cache):
return IntuneConnector(graph_client=mock_graph, cache=cache)
@pytest.fixture
def ctx():
return ConnectorContext(gsap_context_id="test-ac-123")
# ── list_devices ──────────────────────────────────────────────
@pytest.mark.asyncio
async def test_list_devices(connector, mock_graph, ctx):
mock_graph.get.return_value = {
"value": [
{
"id": "dev-1",
"deviceName": "LAPTOP-001",
"operatingSystem": "Windows",
"osVersion": "10.0.19045",
"complianceState": "compliant",
"lastSyncDateTime": "2026-04-14T00:00:00Z",
"userPrincipalName": "alice@contoso.com",
"azureADDeviceId": "entra-dev-1",
},
{
"id": "dev-2",
"deviceName": "PHONE-001",
"operatingSystem": "iOS",
"osVersion": "17.0",
"complianceState": "noncompliant",
},
]
}
result = await connector.invoke("list_devices", {"top": 10}, ctx)
assert result.success
assert len(result.data) == 2
assert result.data[0]["device_id"] == "dev-1"
assert result.data[0]["compliance_state"] == "compliant"
assert result.data[1]["compliance_state"] == "noncompliant"
# ── get_compliance ────────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_compliance_compliant(connector, mock_graph, ctx):
mock_graph.get.return_value = {
"id": "dev-1",
"complianceState": "compliant",
"lastSyncDateTime": "2026-04-14T00:00:00Z",
}
result = await connector.invoke("get_compliance", {"device_id": "00000000-0000-0000-0000-000000000001"}, ctx)
assert result.success
assert result.data["compliant"] is True
assert result.data["state"] == "compliant"
@pytest.mark.asyncio
async def test_get_compliance_noncompliant(connector, mock_graph, ctx):
mock_graph.get.return_value = {
"id": "dev-1",
"complianceState": "noncompliant",
}
result = await connector.invoke("get_compliance", {"device_id": "00000000-0000-0000-0000-000000000001"}, ctx)
assert result.success
assert result.data["compliant"] is False
assert result.data["state"] == "noncompliant"
@pytest.mark.asyncio
async def test_get_compliance_uses_cache(connector, mock_graph, cache, ctx):
# Pre-populate cache
state = ComplianceState(
device_id="00000000-0000-0000-0000-00000000000c", compliant=True, state="compliant",
last_evaluated=datetime.now(UTC),
)
await cache.set("00000000-0000-0000-0000-00000000000c", state)
result = await connector.invoke("get_compliance", {"device_id": "00000000-0000-0000-0000-00000000000c"}, ctx)
assert result.success
assert result.data["compliant"] is True
# Graph API should NOT have been called
mock_graph.get.assert_not_called()
@pytest.mark.asyncio
async def test_get_compliance_missing_device_id(connector, ctx):
result = await connector.invoke("get_compliance", {}, ctx)
assert not result.success
assert "device_id required" in result.error
# ── remote_lock ───────────────────────────────────────────────
@pytest.mark.asyncio
async def test_remote_lock(connector, mock_graph, ctx):
resp = MagicMock()
resp.status_code = 204
mock_graph.post.return_value = resp
result = await connector.invoke("remote_lock", {"device_id": "00000000-0000-0000-0000-000000000001"}, ctx)
assert result.success
assert result.data["locked"] is True
# ── unknown operation ─────────────────────────────────────────
@pytest.mark.asyncio
async def test_unknown_operation(connector, ctx):
result = await connector.invoke("hack_device", {}, ctx)
assert not result.success
assert "Unknown operation" in result.error
# ── health_check ──────────────────────────────────────────────
def test_health_check(connector):
assert connector.health_check() is True
def test_health_check_unconfigured():
graph = MagicMock(spec=GraphClient)
graph.tenant_id = ""
graph.client_id = ""
conn = IntuneConnector(graph_client=graph)
assert conn.health_check() is False
# ── DeviceComplianceCache ─────────────────────────────────────
@pytest.mark.asyncio
async def test_cache_set_and_get(cache):
state = ComplianceState(
device_id="dev-1", compliant=True, state="compliant",
last_evaluated=datetime.now(UTC),
)
await cache.set("dev-1", state)
result = await cache.get("dev-1")
assert result is not None
assert result.compliant is True
@pytest.mark.asyncio
async def test_cache_ttl_expiry():
cache = DeviceComplianceCache(ttl_seconds=0)
state = ComplianceState(
device_id="dev-1", compliant=True, state="compliant",
last_evaluated=datetime.now(UTC),
)
await cache.set("dev-1", state)
# TTL is 0, so it should be expired immediately
time.sleep(0.01)
result = await cache.get("dev-1")
assert result is None
@pytest.mark.asyncio
async def test_cache_miss():
cache = DeviceComplianceCache()
result = await cache.get("nonexistent")
assert result is None
# ── Connector catalog conditional registration ────────────────
@pytest.mark.asyncio
async def test_intune_not_in_catalog_when_disabled(client):
"""Intune connector should NOT appear when intune_enabled=False (default)."""
resp = await client.get("/connectors/")
assert resp.status_code == 200
ids = [c["connector_id"] for c in resp.json()]
assert "intune" not in ids
@pytest.fixture
async def client():
"""Reuse the broker test client fixture pattern."""
from httpx import AsyncClient, ASGITransport
from gsap_broker.app import app
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c

View file

@ -1,173 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Tests for the Keycloak identity driver — C-1: JWKS verification."""
import datetime
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from jose import jwt as jose_jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from gsap_broker.drivers.keycloak import KeycloakDriver
def _generate_rsa_keypair():
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
return private_key, private_key.public_key()
def _private_key_pem(private_key):
return private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
def _public_numbers_to_jwk(public_key, kid="kc-kid-1"):
import base64
nums = public_key.public_numbers()
e_bytes = nums.e.to_bytes((nums.e.bit_length() + 7) // 8, "big")
n_bytes = nums.n.to_bytes((nums.n.bit_length() + 7) // 8, "big")
return {
"kty": "RSA", "kid": kid, "use": "sig", "alg": "RS256",
"n": base64.urlsafe_b64encode(n_bytes).rstrip(b"=").decode(),
"e": base64.urlsafe_b64encode(e_bytes).rstrip(b"=").decode(),
}
PRIVATE_KEY, PUBLIC_KEY = _generate_rsa_keypair()
KID = "kc-kid-1"
KC_URL = "http://keycloak.test:8080"
KC_REALM = "test-realm"
KC_CLIENT_ID = "test-kc-client"
JWKS = {"keys": [_public_numbers_to_jwk(PUBLIC_KEY, KID)]}
def _make_kc_token(claims: dict, kid: str = KID, expired: bool = False) -> str:
now = datetime.datetime.now(datetime.UTC)
base_claims = {
"iss": f"{KC_URL}/realms/{KC_REALM}",
"aud": KC_CLIENT_ID,
"iat": int(now.timestamp()),
"nbf": int(now.timestamp()),
"exp": int((now + datetime.timedelta(hours=1)).timestamp()),
"sub": "user-sub-1",
"preferred_username": "bob",
"name": "Bob Smith",
"jti": "kc-jti-1",
"realm_access": {"roles": ["user"]},
}
if expired:
base_claims["exp"] = int((now - datetime.timedelta(hours=1)).timestamp())
base_claims["nbf"] = int((now - datetime.timedelta(hours=2)).timestamp())
base_claims["iat"] = int((now - datetime.timedelta(hours=2)).timestamp())
base_claims.update(claims)
return jose_jwt.encode(
base_claims, _private_key_pem(PRIVATE_KEY), algorithm="RS256", headers={"kid": kid}
)
def _driver_config(raw_token: str = "") -> dict:
return {
"_raw_token": raw_token,
"keycloak_url": KC_URL,
"keycloak_realm": KC_REALM,
"keycloak_client_id": KC_CLIENT_ID,
"domain": "example.com",
"did_template": "did:web:{domain}/principal/{alias}",
"elevated_suffix": "-elevated",
}
@pytest.fixture
def mock_jwks_fetch():
with patch("gsap_broker.drivers.jwks.httpx.AsyncClient") as mock_http:
mock_resp = MagicMock()
mock_resp.json.return_value = JWKS
mock_resp.raise_for_status = MagicMock()
ctx_manager = AsyncMock()
ctx_manager.__aenter__.return_value.get = AsyncMock(return_value=mock_resp)
mock_http.return_value = ctx_manager
yield mock_http
@pytest.mark.asyncio
async def test_valid_keycloak_jwt_accepted(mock_jwks_fetch):
"""C-1: Valid signed Keycloak JWT is accepted."""
token = _make_kc_token({})
driver = KeycloakDriver(config=_driver_config(raw_token=token))
result = await driver.authenticate()
assert result.is_authorized
assert "bob" in result.principal_did
@pytest.mark.asyncio
async def test_forged_keycloak_jwt_rejected(mock_jwks_fetch):
"""C-1: Forged JWT (wrong signature) is rejected."""
# Create a token signed with a DIFFERENT key
other_key, _ = _generate_rsa_keypair()
now = datetime.datetime.now(datetime.UTC)
forged = jose_jwt.encode(
{
"iss": f"{KC_URL}/realms/{KC_REALM}",
"aud": KC_CLIENT_ID,
"exp": int((now + datetime.timedelta(hours=1)).timestamp()),
"sub": "attacker",
"preferred_username": "hacker",
},
_private_key_pem(other_key),
algorithm="RS256",
headers={"kid": KID},
)
driver = KeycloakDriver(config=_driver_config(raw_token=forged))
result = await driver.authenticate()
assert not result.is_authorized
assert "verification failed" in result.denial_reason.lower() or "signature" in result.denial_reason.lower()
@pytest.mark.asyncio
async def test_expired_keycloak_jwt_rejected(mock_jwks_fetch):
token = _make_kc_token({}, expired=True)
driver = KeycloakDriver(config=_driver_config(raw_token=token))
result = await driver.authenticate()
assert not result.is_authorized
@pytest.mark.asyncio
async def test_no_token_rejected():
driver = KeycloakDriver(config=_driver_config(raw_token=""))
result = await driver.authenticate()
assert not result.is_authorized
@pytest.mark.asyncio
async def test_jwks_unreachable_rejected():
"""JWKS fetch failure denies — no fallback."""
with patch("gsap_broker.drivers.jwks.httpx.AsyncClient") as mock_http:
ctx = AsyncMock()
ctx.__aenter__.return_value.get = AsyncMock(side_effect=Exception("DNS failure"))
mock_http.return_value = ctx
token = _make_kc_token({})
driver = KeycloakDriver(config=_driver_config(raw_token=token))
result = await driver.authenticate()
assert not result.is_authorized
assert "JWKS fetch failed" in result.denial_reason
@pytest.mark.asyncio
async def test_alg_none_rejected(mock_jwks_fetch):
"""alg=none attack is blocked."""
import base64, json
header = base64.urlsafe_b64encode(json.dumps({"alg": "none", "typ": "JWT"}).encode()).rstrip(b"=").decode()
payload = base64.urlsafe_b64encode(json.dumps({
"sub": "attacker", "iss": f"{KC_URL}/realms/{KC_REALM}",
"aud": KC_CLIENT_ID, "exp": 9999999999,
}).encode()).rstrip(b"=").decode()
forged = f"{header}.{payload}."
driver = KeycloakDriver(config=_driver_config(raw_token=forged))
result = await driver.authenticate()
assert not result.is_authorized

View file

@ -1,48 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Tests for Intune MCP tools."""
import pytest
from httpx import AsyncClient, ASGITransport
from gsap_broker.app import app
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.mark.asyncio
async def test_mcp_tools_list_includes_intune(client):
"""MCP tools/list should include Intune tools."""
resp = await client.post("/mcp", json={
"jsonrpc": "2.0",
"method": "tools/list",
"id": 1,
})
assert resp.status_code == 200
tools = resp.json()["result"]["tools"]
tool_names = [t["name"] for t in tools]
assert "list_devices" in tool_names
assert "get_device_compliance" in tool_names
assert "sync_device" in tool_names
assert "remote_lock" in tool_names
@pytest.mark.asyncio
async def test_mcp_intune_tool_without_connector(client):
"""Intune MCP tool should return error when connector not enabled."""
resp = await client.post("/mcp", json={
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "list_devices",
"arguments": {},
},
"id": 2,
})
assert resp.status_code == 200
content = resp.json()["result"]["content"][0]["text"]
assert "not enabled" in content.lower() or "error" in content.lower()

View file

@ -1,156 +0,0 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Security regression tests for audit findings C-2, C-6, C-7, C-9, H-1, H-5."""
import pytest
from datetime import datetime, timedelta, UTC
from unittest.mock import AsyncMock, MagicMock
from gsap_broker.connectors.base import CAP_MUTATE, CAP_READ, ConnectorContext, ConnectorResult
from gsap_broker.connectors.intune import IntuneConnector, _validate_device_id
from gsap_broker.credentials.resolver import (
BasculeCredential, KerberosCredential, OAuthCredential, SSHCertCredential,
)
# ── H-1: Credential repr does not leak secrets ──────────────────
def test_oauth_credential_repr_hides_token():
"""H-1: access_token must not appear in repr."""
cred = OAuthCredential(
target="target", expires_at=datetime.now(UTC) + timedelta(hours=1),
access_token="super-secret-token-123",
)
r = repr(cred)
assert "super-secret-token-123" not in r
def test_kerberos_credential_repr_hides_ticket():
"""H-1: ticket bytes must not appear in repr."""
cred = KerberosCredential(
target="target", expires_at=datetime.now(UTC) + timedelta(hours=1),
ticket=b"SECRET_KERBEROS_TICKET_BYTES",
)
r = repr(cred)
assert "SECRET_KERBEROS_TICKET_BYTES" not in r
def test_ssh_credential_repr_hides_key():
"""H-1: private_key must not appear in repr."""
cred = SSHCertCredential(
target="target", expires_at=datetime.now(UTC) + timedelta(hours=1),
certificate="cert-data", private_key="PRIVATE-KEY-MATERIAL",
)
r = repr(cred)
assert "PRIVATE-KEY-MATERIAL" not in r
# ── H-5: device_id path traversal rejected ──────────────────────
def test_device_id_path_traversal_rejected():
"""H-5: path traversal in device_id is rejected."""
with pytest.raises(ValueError, match="UUID format"):
_validate_device_id("../../users/admin")
def test_device_id_valid_uuid_accepted():
"""H-5: valid UUID device_id is accepted."""
result = _validate_device_id("550e8400-e29b-41d4-a716-446655440000")
assert result == "550e8400-e29b-41d4-a716-446655440000"
def test_device_id_empty_rejected():
with pytest.raises(ValueError, match="device_id required"):
_validate_device_id("")
def test_device_id_not_uuid_rejected():
with pytest.raises(ValueError, match="UUID format"):
_validate_device_id("not-a-uuid")
# ── C-6: capability_mask enforcement ─────────────────────────────
@pytest.mark.asyncio
async def test_read_only_ac_cannot_invoke_wipe():
"""C-6: READ-only AC must be denied for MUTATE operations."""
mock_graph = MagicMock()
mock_graph.tenant_id = "t"
mock_graph.client_id = "c"
connector = IntuneConnector(graph_client=mock_graph)
# Verify the connector declares wipe as MUTATE
assert connector.capability_for_operation("wipe_device") == CAP_MUTATE
assert connector.capability_for_operation("remote_lock") == CAP_MUTATE
assert connector.capability_for_operation("retire_device") == CAP_MUTATE
# Verify READ operations are READ
assert connector.capability_for_operation("list_devices") == CAP_READ
assert connector.capability_for_operation("get_compliance") == CAP_READ
# ── C-9: delegation capability bounding ──────────────────────────
@pytest.mark.asyncio
async def test_delegation_capability_exceeding_delegator_rejected():
"""C-9: delegated capability cannot exceed delegator's."""
from gsap_broker.delegations.lifecycle import DelegationManager, _capability_mask_for
from gsap_broker.delegations.models import DelegationRequest, DelegationScope
manager = DelegationManager()
request = DelegationRequest(
delegator_ac_id="test-ac",
agent_type="claude-code",
scope=DelegationScope(capability_ceiling="CAP_ADMIN"),
)
# Delegator has only READ capability (mask=1)
with pytest.raises(ValueError, match="exceeds delegator"):
await manager.create_delegation(
request, delegator_did="did:web:test/p/alice",
delegator_capability_mask=CAP_READ,
)
# ── C-2: on_behalf_of gate ───────────────────────────────────────
@pytest.mark.asyncio
async def test_on_behalf_of_without_impersonate_role_rejected(mocker):
"""C-2: on_behalf_of without gsap:impersonate role is rejected."""
from httpx import AsyncClient, ASGITransport
from sqlmodel import SQLModel
from sqlalchemy.ext.asyncio import create_async_engine
from gsap_broker.app import app
from gsap_broker import db as db_module
from gsap_broker.drivers.base import AuthResult
engine = create_async_engine("sqlite+aiosqlite:///./test_security.db")
db_module.engine = engine
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
try:
mocker.patch(
"gsap_broker.drivers.keycloak.KeycloakDriver.authenticate",
return_value=AuthResult(
status=AuthResult.STATUS_AUTHORIZED,
principal_did="did:web:test/p/alice",
token_jti="jti",
),
)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.post("/governance/authorize/", json={
"playbook": "test",
"corpus_entry_cid": "sha256:" + "a" * 64,
"parameters_cid": "sha256:" + "b" * 64,
"accord_template": "test",
"driver_id": "keycloak",
"on_behalf_of": "did:web:test/p/admin",
})
assert resp.status_code == 403
assert "gsap:impersonate" in resp.json()["detail"]
finally:
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.drop_all)

147
uv.lock
View file

@ -121,95 +121,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" },
{ url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" },
{ url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" },
{ url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" },
{ url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" },
{ url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" },
{ url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" },
{ url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" },
{ url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" },
{ url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" },
{ url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" },
{ url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" },
{ url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" },
{ url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" },
{ url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" },
{ url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" },
{ url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
{ url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
{ url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
{ url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
{ url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
{ url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
{ url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
{ url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
{ url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
{ url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
{ url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
{ url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
]
[[package]]
name = "click"
version = "8.3.1"
@ -342,9 +253,6 @@ dev = [
{ name = "pytest-mock" },
{ name = "ruff" },
]
entra = [
{ name = "msal" },
]
[package.metadata]
requires-dist = [
@ -352,7 +260,6 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.111.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
{ name = "msal", marker = "extra == 'entra'", specifier = ">=1.28.0" },
{ name = "pydantic", specifier = ">=2.7.0" },
{ name = "pydantic-settings", specifier = ">=2.2.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
@ -364,7 +271,7 @@ requires-dist = [
{ name = "structlog", specifier = ">=24.1.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" },
]
provides-extras = ["dev", "entra"]
provides-extras = ["dev"]
[[package]]
name = "greenlet"
@ -509,20 +416,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "msal"
version = "1.36.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/cb/b02b0f748ac668922364ccb3c3bff5b71628a05f5adfec2ba2a5c3031483/msal-1.36.0.tar.gz", hash = "sha256:3f6a4af2b036b476a4215111c4297b4e6e236ed186cd804faefba23e4990978b", size = 174217, upload-time = "2026-04-09T10:20:33.525Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/d3/414d1f0a5f6f4fe5313c2b002c54e78a3332970feb3f5fed14237aa17064/msal-1.36.0-py3-none-any.whl", hash = "sha256:36ecac30e2ff4322d956029aabce3c82301c29f0acb1ad89b94edcabb0e58ec4", size = 121547, upload-time = "2026-04-09T10:20:32.336Z" },
]
[[package]]
name = "packaging"
version = "26.0"
@ -694,20 +587,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyjwt"
version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]]
name = "pytest"
version = "9.0.2"
@ -832,21 +711,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "requests"
version = "2.33.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
]
[[package]]
name = "rsa"
version = "4.9.1"
@ -1002,15 +866,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "uvicorn"
version = "0.42.0"