Compare commits

...

21 commits

Author SHA256 Message Date
Tyler J King
782f5654ac fix: shared bearer auth, delegation depth, SQLite permissions
C-4: MCP endpoint requires verified bearer token. Unauthenticated
     requests rejected. _extract_principal() replaced by verified
     AuthResult from middleware.
C-8: All delegation endpoints require verified bearer token.
     X-Delegator-DID header removed — identity from token only.
     delegator_ac_id validated to belong to authenticated principal.
     Only delegators can revoke. Only delegator/delegate can view.
H-6: SQLite file permissions restricted to 0o600 (owner-only).
     Umask set before creation. WAL/SHM files also restricted.
H-7: Delegation depth tracked and enforced against max_delegation_depth.
     Sub-delegations increment depth. Exceeded depth → 403.

Shared TokenAuthenticator auto-detects identity driver from JWT
issuer claim (Keycloak or Entra). verify_bearer FastAPI dependency
for all protected endpoints. Health endpoint remains public.

ALL 10 critical findings CLOSED. ALL 10 high findings CLOSED.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 17:31:46 -04:00
Tyler J King
85afbd8d61 feat(ansible): add guildhouse.bastion Ansible Galaxy collection
Dynamic inventory plugin — queries Bastion for managed devices,
groups by OS and compliance state, bastion_* host vars, zero
credentials in inventory.

Credential lookup plugin — resolves short-lived credentials from
Bastion's CredentialResolver at execution time. Graceful
degradation when broker unavailable.

Chronicle callback plugin — reports playbook lifecycle events
(started, task completed, completed) to Chronicle. Optionally
triggers compliance re-evaluation after playbook completion.

Shared BastionClient for all plugins using stdlib urllib.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 11:23:03 -04:00
Tyler J King
77964e4042 feat(templates): add template system — manifest, policy, loader, registries
bastion.toml manifest parser with variable validation and dependency
declarations. Declarative compliance policy schema with per-platform
check implementations. Template loader with variable substitution
(Bastion-owned files only — never touches Ansible/Terraform).
PolicyRegistry and AccordRegistry with builtin fallbacks.

BOUNDARY: loader never touches automation framework files.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 11:09:41 -04:00
Tyler J King
d62974f1b7 docs: add Bastion product roadmap
Feature matrix, release plan v0.4 through v1.0, reference
deployment, architecture principles, and contribution guide.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 10:35:07 -04:00
Tyler J King
f82000e0f6 chore: add .venv to gitignore, remove from tracking
Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 08:13:35 -04:00
Tyler J King
e744336385 fix: capability enforcement, credential safety, atomic delegations, input validation
C-6: ConnectorRuntime enforces capability_mask per operation.
     READ-only ACs cannot invoke MUTATE operations (wipe, lock, retire).
C-7: AC validated against database (exists, active, not expired)
     before connector invocation.
C-9: Delegated AC capability bounded by delegator's capability.
C-10: Command counter uses atomic SQL increment with limit check.
M-23: expire_stale() uses same atomic SQL pattern.

H-1: Sensitive credential fields hidden from repr/logs via repr=False.
H-2: Stub backend requires ALLOW_STUB_CREDENTIALS=true to activate.
H-3: Kerberos backend raises CredentialResolutionError instead of
     returning stub ticket.
H-4: Chronicle INTENT emitted before execution, RESULT after.
H-5: device_id validated as UUID before Graph API URL interpolation.
H-8: ConnectorRuntime enforces governance for all connector invocations.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 08:13:27 -04:00
Tyler J King
5015f3dd43 fix(drivers): JWKS verification for Keycloak, remove Entra fallback, gate on_behalf_of
C-1: Keycloak driver now verifies JWT signatures via JWKS.
     Forged tokens are rejected. Previously any base64 JWT was accepted.
C-2: on_behalf_of requires gsap:impersonate role in JWT claims.
C-3: Entra driver denies on JWKS failure (no unverified fallback).
H-10: JWKS cache refreshes on kid miss for key rotation.

Shared JWKSVerifier used by both drivers. alg=none blocked.
iss, aud, exp validated for all tokens.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 07:51:38 -04:00
Tyler J King
4dff879c84 feat: wire credential resolver and connectors into broker startup
All connectors registered conditionally based on settings.
CredentialResolver with Entra backend (production) or Stub
backend (dev mode). 15 new tests covering credential resolution,
session lifecycle, orchestrator workflows, and device routing.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 06:03:57 -04:00
Tyler J King
5adc55aff5 feat(routing): add DeviceRouter for automatic connector selection
Routes operations to Intune, PowerShell, Bascule, or Ansible
based on operation type and target device OS. API-mediated ops
always go to Intune, fleet ops to Ansible, session ops routed
by OS. Single invoke endpoint for all device operations.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 06:01:55 -04:00
Tyler J King
2ac5aa3b85 feat(connectors): add OrchestratorConnector base and stubbed Ansible
Multi-step workflow base class with plan/execute lifecycle and
partial-completion reporting. Ansible connector stubbed —
ansible-runner integration in future sprint. Credentials
resolved per-host at runtime via CredentialResolver, never stored.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 06:00:48 -04:00
Tyler J King
eee8740ce8 feat(connectors): add stubbed Bascule and PowerShell connectors
Bascule: session-based connector using AC as credential.
Transport stubbed — Shellstream integration in future sprint.

PowerShell: session-based connector using Kerberos credentials
from CredentialResolver. PSRP transport stubbed — pypsrp
integration in future sprint.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 05:59:56 -04:00
Tyler J King
5a759f5e12 feat(connectors): add SessionTransport and SessionConnector base
Session-based connectors acquire credentials at invocation time
from CredentialResolver, manage transport lifecycle with cleanup
guarantees, and never store credentials.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 05:58:58 -04:00
Tyler J King
24eefe1699 feat(credentials): add Entra and Stub credential backends
Entra backend resolves OAuth tokens via MSAL client_credentials
(OBO flow wired in future sprint) and passes ACs through for
Bascule. Kerberos stubbed pending hybrid environment config.
Stub backend for dev/testing without real IdP.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 05:57:52 -04:00
Tyler J King
043693652a feat(credentials): add CredentialResolver and Credential types
Zero-credential-storage architecture. The broker holds ACs
(authorization). Pluggable CredentialBackend implementations
hold secrets. Transports acquire short-lived, scoped credentials
at invocation time and discard after use.

Credential types: BasculeCredential, KerberosCredential,
OAuthCredential, SSHCertCredential.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 05:57:00 -04:00
Tyler J King
1d24019544 docs: add Intune connector configuration guide
Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 05:30:06 -04:00
Tyler J King
6cfe5f7d9a test: add Graph API client unit tests
Verifies MSAL token acquisition, error handling, and
Authorization header inclusion in Graph API requests.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 05:28:46 -04:00
Tyler J King
e24a87db6f feat(mcp): add Intune device management tools
MCP tools for list_devices, get_device_compliance, sync_device,
remote_lock. All route through governed IntuneConnector
invocation with Chronicle audit.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 05:25:08 -04:00
Tyler J King
03a99b4aff feat(authorize): add Intune compliance-gated AC issuance
AC issuance can now require device compliance via Intune.
Configurable per-accord and globally. Disabled by default
for backward compatibility. Emits DEVICE_COMPLIANCE_CHECKED
Chronicle event. Adds device_id, device_compliant, and
compliance_checked_at fields to AuthorizationContext.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 05:24:03 -04:00
Tyler J King
871541f0eb feat(connectors): add Intune device management connector
Implements ConnectorPlugin for Intune Graph API operations.
Governed invocation: every Intune call requires an active AC
and emits a Chronicle CONNECTOR_INVOKED event.
Operations: list, get, compliance check, sync, lock, retire, wipe.
In-memory compliance cache with configurable TTL.
Conditional registration via intune_enabled setting.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 05:21:47 -04:00
Tyler J King
8196396ce6 feat(drivers): add native Entra identity driver
Validates Entra JWTs directly via JWKS verification.
Extracts device_id for compliance gating, MFA status,
roles, and constructs DID from Entra tenant + oid.
Adds device_id field to AuthResult dataclass.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 05:19:54 -04:00
Tyler J King
1ab47417c9 refactor: extract shared Graph API client from Entra registrar
Creates gsap_broker/intune/graph_client.py with MSAL
client_credentials auth and typed Graph API methods.
Entra registrar refactored to consume the shared client.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 05:16:09 -04:00
68 changed files with 6346 additions and 162 deletions

1
.gitignore vendored
View file

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

86
INTUNE.md Normal file
View file

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

366
ROADMAP.md Normal file
View file

@ -0,0 +1,366 @@
# Bastion — Product Roadmap
**Unified Device & Workspace Governance for the Enterprise**
*Last updated: April 2026*
---
## Vision
One governance authority, every endpoint type, every management mode, unified by identity. Bastion is the open-source MDM control plane that governs physical endpoints and virtual workspaces under a single identity-aware, cryptographically attestable policy framework.
---
## Feature Matrix
### Legend
| Status | Meaning |
|--------|---------|
| ✅ Shipped | Implemented, tested, in bastion-v0.3 |
| 🔨 In Progress | Partially implemented or stubbed |
| 📐 Designed | Architecture defined, not yet coded |
| 🗺️ Planned | Scoped and prioritized, design pending |
| 💡 Future | Identified need, not yet scoped |
---
### Identity & Authentication
| Feature | Status | Version | Notes |
|---------|--------|---------|-------|
| Entra ID identity driver (JWKS-verified) | ✅ Shipped | v0.1 | Native JWT validation, device_id extraction, MFA detection |
| Keycloak identity driver (JWKS-verified) | ✅ Shipped | v0.3 | Shared JWKSVerifier, realm_access roles, DID construction |
| Shared JWKS verification framework | ✅ Shipped | v0.3 | Reusable across all identity drivers, kid-miss refresh |
| on_behalf_of impersonation gating | ✅ Shipped | v0.3 | Requires `gsap:impersonate` role |
| Okta identity driver | 🗺️ Planned | v0.6 | OIDC JWT verification, Okta-specific claims |
| SPIFFE/SPIRE workload identity | 🗺️ Planned | v0.7 | Service-to-service identity within governance infra |
| FIDO2/WebAuthn integration | 💡 Future | — | Hardware key attestation for operator authentication |
| Shared bearer auth middleware | 🔨 In Progress | v0.4 | FastAPI `Depends(verify_bearer)` for all protected endpoints |
### Device Management — Traditional Mode
| Feature | Status | Version | Notes |
|---------|--------|---------|-------|
| Intune connector (7 operations) | ✅ Shipped | v0.1 | list, get, compliance, sync, lock, retire, wipe |
| Intune compliance cache | ✅ Shipped | v0.1 | In-memory with configurable TTL |
| Compliance-gated AC issuance | ✅ Shipped | v0.1 | Per-accord and global configuration |
| Entra device_id in AC metadata | ✅ Shipped | v0.1 | Extracted from JWT deviceid claim |
| device_id UUID validation | ✅ Shipped | v0.3 | Path traversal prevention for Graph API |
| Intune MCP tools | ✅ Shipped | v0.1 | 4 tools via governed connector invocation |
| Capability-enforced operations | ✅ Shipped | v0.3 | READ/PROPOSE/MUTATE per-operation |
| Keylime connector (TPM attestation) | 📐 Designed | v0.5 | Measured boot + IMA runtime integrity |
| Fleet/osquery connector | 🗺️ Planned | v0.5 | Cross-platform posture collection for Linux/macOS/Windows |
| Jamf connector (macOS) | 🗺️ Planned | v0.6 | macOS endpoint compliance and management |
| SNMP/API network device connector | 💡 Future | — | Switch/router/firewall posture assessment |
| Windows Device Health Attestation | 📐 Designed | v0.5 | TPM attestation via Intune DHA Graph API |
### Device Management — VDI Mode
| Feature | Status | Version | Notes |
|---------|--------|---------|-------|
| VDI mode architecture | 📐 Designed | v0.6 | Workspace provisioning, profile governance, session binding |
| Apache Guacamole adapter | 🗺️ Planned | v0.6 | REST API integration for session lifecycle |
| Governed shell integration (Bascule) | 🔨 In Progress | v0.4 | Stubbed connector, needs Shellstream transport |
| FSLogix / profile governance | 📐 Designed | v0.7 | Content-addressed profiles as governed artifacts |
| Citrix CVAD adapter | 🗺️ Planned | v0.7 | Broker Service API for session lifecycle |
| VMware Horizon adapter | 🗺️ Planned | v0.8 | REST API integration |
| Session-device binding | 📐 Designed | v0.5 | Correlate Bascule session with originating device posture |
| Mid-session compliance re-evaluation | 📐 Designed | v0.6 | Revoke/restrict session when device posture degrades |
### Hardware Security
| Feature | Status | Version | Notes |
|---------|--------|---------|-------|
| TPM attestation (Linux via Keylime) | 📐 Designed | v0.5 | Measured boot, PCR verification, IMA integration |
| TPM attestation (Windows via DHA) | 📐 Designed | v0.5 | Intune Device Health Attestation Graph API |
| HBOM collection (Linux) | 📐 Designed | v0.5 | dmidecode, sysfs, lspci, lsusb, TPM PCR values |
| HBOM collection (Windows) | 📐 Designed | v0.5 | WMI hardware classes, TPM WMI |
| HBOM drift detection | 📐 Designed | v0.5 | Content-hash comparison, unexpected component alerting |
| Firmware version verification | 📐 Designed | v0.5 | HBOM declared version vs TPM-measured version |
| HardwareIntegrity posture condition | 📐 Designed | v0.5 | Composite: TPM + HBOM + firmware all valid |
| Barcode/QR enrollment scanning | 🗺️ Planned | v0.6 | USB/camera barcode scan for device onboarding, serial/model/SKU auto-population |
| Scan-to-HBOM verification | 🗺️ Planned | v0.6 | Compare scanned vendor declaration against TPM/OS-reported hardware at first boot |
| Supply chain provenance tracking | 🗺️ Planned | v0.7 | Full hardware lifecycle: procurement scan → provisioning → production → decommission |
### Connector Framework
| Feature | Status | Version | Notes |
|---------|--------|---------|-------|
| ConnectorPlugin ABC | ✅ Shipped | v0.1 | API-mediated connector pattern |
| ConnectorRuntime with Chronicle audit | ✅ Shipped | v0.1 | Intent-before-execution, result-after |
| SessionTransport / SessionConnector | ✅ Shipped | v0.2 | Session-based connector pattern with lifecycle |
| OrchestratorConnector | ✅ Shipped | v0.2 | Multi-step workflow pattern with partial-completion |
| Bascule connector (stubbed) | 🔨 In Progress | v0.4 | AC-as-credential, needs Shellstream transport |
| PowerShell connector (stubbed) | 🔨 In Progress | v0.5 | Kerberos credential, needs pypsrp transport |
| Ansible connector (stubbed) | 🔨 In Progress | v0.5 | Orchestrator pattern, needs ansible-runner |
| Keylime connector | 📐 Designed | v0.5 | TPM attestation API integration |
| Connector plugin SDK | 🗺️ Planned | v0.7 | Guild-facing SDK for third-party connectors |
### Credential Management
| Feature | Status | Version | Notes |
|---------|--------|---------|-------|
| CredentialResolver abstraction | ✅ Shipped | v0.2 | Type routing, expiry enforcement, zero-storage |
| Entra credential backend (OAuth) | ✅ Shipped | v0.2 | MSAL on-behalf-of token acquisition |
| Bascule credential passthrough | ✅ Shipped | v0.2 | AC is the credential |
| Stub credential backend | ✅ Shipped | v0.2 | Dev/testing only, requires explicit opt-in (v0.3) |
| Credential repr safety | ✅ Shipped | v0.3 | field(repr=False) on all sensitive fields |
| Kerberos credential resolution | 🔨 In Progress | v0.5 | Entra Kerberos proxy or hybrid AD |
| SSH certificate credential | 📐 Designed | v0.5 | Short-lived certs from Bascule CA |
| HashiCorp Vault backend | 🗺️ Planned | v0.6 | Dynamic secrets for all credential types |
| CyberArk backend | 🗺️ Planned | v0.7 | Enterprise PAM integration |
| Azure Key Vault backend | 🗺️ Planned | v0.6 | Cloud-native secrets for Azure environments |
### Authorization & Governance
| Feature | Status | Version | Notes |
|---------|--------|---------|-------|
| AC issuance (GSAP spec) | ✅ Shipped | v0.1 | Full lifecycle: issue, poll, consume |
| Completion receipts | ✅ Shipped | v0.1 | Outcome recording with behavioral attestation |
| Capability mask enforcement | ✅ Shipped | v0.3 | READ/PROPOSE/MUTATE per-operation check |
| AC validation in ConnectorRuntime | ✅ Shipped | v0.3 | Exists, active, not expired |
| Session mode ACs | ✅ Shipped | v0.1 | Multi-operation sessions with session_end |
| Delegation lifecycle | ✅ Shipped | v0.1 | Create, revoke, query, list, TTL, command limits |
| Bounded delegation capability | ✅ Shipped | v0.3 | Cannot exceed delegator's capability mask |
| Atomic command counter | ✅ Shipped | v0.3 | SQL-level increment with limit check |
| DeviceRouter | ✅ Shipped | v0.2 | Automatic connector selection by device OS/channel |
| Declarative compliance policies | 🗺️ Planned | v0.5 | Cross-platform policy definitions |
| Accord template externalization | 🗺️ Planned | v0.5 | From hardcoded dict to CRD/file-based |
| Ceremony-gated operations | 📐 Designed | v0.6 | Multi-party approval for destructive operations |
| Delegation depth enforcement | 🔨 In Progress | v0.4 | Chain traversal and depth limit |
### AI Agent Integration
| Feature | Status | Version | Notes |
|---------|--------|---------|-------|
| MCP tool surface | ✅ Shipped | v0.1 | JSON-RPC 2.0, 11 core + 4 Intune tools |
| Agent delegation system | ✅ Shipped | v0.1 | Ephemeral IdP registrations, scoped ACs |
| Delegation with Entra registrar | ✅ Shipped | v0.1 | App registration + service principal + client credential |
| Delegation with Keycloak registrar | ✅ Shipped | v0.1 | Ephemeral service-account clients |
| MCP authentication | 🔨 In Progress | v0.4 | Bearer token required for all MCP operations |
| MCP AC validation | ✅ Shipped | v0.3 | Governed tools require real AC (no synthetic bypass) |
| Harness specification | 📐 Designed | v0.6 | Delegation scope, escalation boundary, observation/action mode |
| Harness enforcement in gsh | 📐 Designed | v0.7 | gsh reads harness.toml, enforces scope |
| Agent telemetry classification | 📐 Designed | v0.6 | Distinguish agent ops from human ops in Chronicle |
| Automated remediation harness | 📐 Designed | v0.7 | Compliance violation → agent remediation within scope |
### Compliance & Attestation
| Feature | Status | Version | Notes |
|---------|--------|---------|-------|
| Compliance-gated authorization | ✅ Shipped | v0.1 | Non-compliant devices denied ACs |
| PostureLevel (Lockdown..Normal) | ✅ Shipped | witness-sprint1 | Wire-compatible with Shellstream |
| WitnessLevel (L1-L4) | ✅ Shipped | witness-sprint1 | Telemetry granularity per SAT-SPEC-ZONE-001 |
| PostureCondition framework | ✅ Shipped | witness-sprint1 | 9 condition kinds including Custom |
| WitnessConfig on AccordSpec | ✅ Shipped | witness-sprint1 | Conditions + delegates + interval + breach response |
| PostureTransitionArtifact | ✅ Shipped | witness-sprint1 | Merkle-anchored posture change evidence |
| Posture condition evaluator | ✅ Shipped | witness-sprint2 | 6 checkers implemented, 2 stubbed |
| Witness event classification | ✅ Shipped | witness-sprint2 | Operational/Witness/Forensic at ingestion bridge |
| TpmAttestationValid condition | 📐 Designed | v0.5 | Keylime-backed posture condition |
| HbomNoDrift condition | 📐 Designed | v0.5 | HBOM integrity-backed posture condition |
| HostPostureSnapshot generation | 📐 Designed | v0.6 | Selective merkle proofs for external observers |
| Witness delegation forwarding | 📐 Designed | v0.6 | Pulsar subscription → filtered CloudEvents to delegates |
| Insurance observability API | 🗺️ Planned | v0.7 | Read-only posture history for insurers |
| Dynamic premium integration | 💡 Future | — | Insurer-side premium calculation from posture stream |
| CMMC compliance mapping | 🗺️ Planned | v0.7 | Map Bastion posture conditions to CMMC practices |
| SOC 2 evidence generation | 🗺️ Planned | v0.7 | Automated evidence collection for SOC 2 controls |
| SLSA build provenance integration | 📐 Designed | v0.8 | Build attestation feeding into device posture |
### Audit & Telemetry
| Feature | Status | Version | Notes |
|---------|--------|---------|-------|
| Chronicle event emission | ✅ Shipped | v0.1 | CloudEvents to ingestion bridge |
| Intent-before-execution audit | ✅ Shipped | v0.3 | INTENT event before, RESULT event after |
| GovernanceEnvelope | ✅ Shipped | cid-phase3 | Binds git ref + actor + accord + timestamp |
| ChronicleGitEvent | ✅ Shipped | cid-phase3 | Git-originated events in Chronicle chain |
| Witness event types (0x2801-0x2805) | ✅ Shipped | witness-sprint1 | Posture verified/breached, delegate lifecycle |
| Chronicle migration to CloudEvents | ✅ Shipped | boundary-cleanup | All emitters use CloudEvents 1.0 |
| DEVICE_COMPLIANCE_CHECKED event | ✅ Shipped | v0.1 | Compliance gate decisions audited |
| CONNECTOR_INVOCATION_INTENT event | ✅ Shipped | v0.3 | Pre-execution audit record |
| Broker Chronicle → CloudEvents gRPC | 🗺️ Planned | v0.5 | Replace Forgejo webhook format (M6.2 TODO) |
| Forensic telemetry classification | 📐 Designed | v0.6 | Full Chronicle stream for incident investigation |
### Multi-Tenancy & Fleet Management
| Feature | Status | Version | Notes |
|---------|--------|---------|-------|
| Device inventory (Intune-sourced) | ✅ Shipped | v0.1 | Windows managed devices via Graph API |
| Device inventory (Linux) | 🔨 In Progress | v0.5 | Via Bascule connector collect operation |
| Cross-tenant MSP dashboard | 🗺️ Planned | v0.6 | Dioxus frontend with per-client views |
| Tenant isolation in broker | 🗺️ Planned | v0.6 | Per-tenant Accord scope, data isolation |
| Vertical policy templates | 🗺️ Planned | v0.7 | Healthcare, legal, manufacturing, tribal presets |
| Fleet-wide posture aggregation | 🗺️ Planned | v0.7 | Cross-device posture summary per tenant |
| Billing/usage metering | 💡 Future | — | Per-tenant usage for MSP billing |
### Platform & Infrastructure
| Feature | Status | Version | Notes |
|---------|--------|---------|-------|
| FastAPI + SQLite (prototype) | ✅ Shipped | v0.1 | Single-container deployment |
| PostgreSQL migration | 🗺️ Planned | v0.5 | asyncpg, same SQLModel layer |
| SQLite file permissions (0o600) | 🔨 In Progress | v0.4 | Security hardening |
| Pydantic SecretStr for secrets | ✅ Shipped | v0.3 | Settings safety |
| Helm chart | 🗺️ Planned | v0.6 | K8s deployment |
| Rust port — AC issuance | 🗺️ Planned | v1.0 | Axum/Tonic, governance-types crate |
| Rust port — connectors | 🗺️ Planned | v1.0 | reqwest + azure_identity |
| Rust port — credential resolver | 🗺️ Planned | v1.0 | Same zero-storage pattern |
| OpenAPI spec generation | 🗺️ Planned | v0.6 | Auto-generate from FastAPI routes |
---
## Release Plan
### v0.4 — Authentication Hardening (Q2 2026)
**Theme:** Close the remaining security gaps and establish shared auth middleware.
- Shared `Depends(verify_bearer)` FastAPI middleware (closes C-4, C-8)
- MCP endpoint full bearer authentication
- Delegation endpoint bearer authentication with DID from token
- Delegation depth enforcement (H-7)
- SQLite file permissions (H-6)
- Bascule connector: real Shellstream transport integration (first real session connector)
**Exit criteria:** All 10 critical findings fully closed. Zero unauthenticated endpoints.
### v0.5 — Hardware Trust & Real Transports (Q3 2026)
**Theme:** TPM attestation, HBOM, and the first real management transports.
- Keylime connector for TPM-based measured boot attestation
- Windows Device Health Attestation via Intune DHA Graph API
- HBOM collection (Linux via Bascule, Windows via PowerShell)
- HBOM drift detection with content-hash comparison
- HardwareIntegrity composite posture condition
- PowerShell connector: real pypsrp transport
- Ansible connector: real ansible-runner integration
- Fleet/osquery connector for cross-platform posture collection
- PostgreSQL migration (asyncpg)
- Declarative compliance policy engine (cross-platform evaluation)
- Accord template externalization (file/CRD-based)
- Broker Chronicle client migration to CloudEvents (not Forgejo format)
- Device inventory for Linux endpoints (via Bascule collect)
- Session-device binding (correlate Bascule session to originating device)
**Exit criteria:** TPM attestation operational on Linux. At least two real transports (Bascule + PowerShell) executing against live targets. HBOM collected and verified.
### v0.6 — VDI Mode & Multi-Tenancy (Q4 2026)
**Theme:** Virtual workspace governance and MSP fleet management.
- Apache Guacamole VDI adapter
- Governed shell (Bascule) as a VDI mode workspace
- Workspace provisioning lifecycle (auth → provision → monitor → terminate)
- Mid-session compliance re-evaluation
- Session-device correlation in unified audit trail
- HostPostureSnapshot generation (Notarization Boundary)
- Witness delegation forwarding (Pulsar → filtered CloudEvents)
- Tenant isolation in broker (per-client Accord scope)
- Cross-tenant MSP dashboard (Dioxus)
- Harness specification for AI agents
- Agent telemetry classification (agent vs human ops)
- Ceremony-gated destructive operations
- Barcode/QR device enrollment (USB scanner + camera/mobile support)
- Scan-to-HBOM verification (vendor declaration vs actual hardware at first boot)
- Helm chart for K8s deployment
- OpenAPI spec auto-generation
- Okta identity driver
**Exit criteria:** VDI mode operational with at least one platform adapter. MSP can manage multiple clients with tenant isolation. Witness delegation producing snapshots.
### v0.7 — Insurance & Compliance Frameworks (Q1 2027)
**Theme:** Compliance automation and the insurance observability product.
- Insurance observability API (read-only posture history)
- CMMC compliance mapping (posture conditions → CMMC practices)
- SOC 2 evidence generation (automated control evidence)
- Vertical policy templates (healthcare, legal, manufacturing, tribal)
- Fleet-wide posture aggregation (cross-device summary)
- FSLogix / profile governance (content-addressed VDI profiles)
- Harness enforcement in gsh (harness.toml → scope enforcement)
- Automated remediation harness (compliance violation → agent action)
- Citrix CVAD adapter
- Jamf connector (macOS)
- SPIFFE/SPIRE workload identity
- HashiCorp Vault credential backend
- CyberArk credential backend
- Connector plugin SDK for guild/third-party development
- Supply chain provenance tracking (procurement scan → provisioning → production → decommission)
**Exit criteria:** Insurance observability API operational. At least one compliance framework (CMMC or SOC 2) mapped. Harness-governed AI agents performing automated remediation.
### v0.8 — Ecosystem & Scale (Q2 2027)
**Theme:** Scale, ecosystem growth, and advanced attestation.
- VMware Horizon VDI adapter
- SLSA build provenance integration
- Supply chain provenance tracking (HBOM lifecycle)
- Distributed cache for multi-worker deployments
- Advanced posture analytics (trend analysis, predictive degradation)
- Guild marketplace integration (connector/policy template distribution)
- Forensic telemetry mode (full Chronicle stream for incident investigation)
### v1.0 — Rust Port & Production Hardening (Q3 2027)
**Theme:** Production-grade Rust implementation for performance and safety.
- Rust port: AC issuance and CR ingestion (Axum/Tonic)
- Rust port: connector framework (reqwest + azure_identity)
- Rust port: credential resolver (same zero-storage architecture)
- Rust port: identity drivers (JWKS verification)
- Python broker archived as reference implementation
- Full conformance test suite (Python and Rust implementations must pass)
- Performance benchmarking and load testing
- Security audit of Rust implementation
---
## Reference Deployment
### Tribal Nation NOC (2026-2027)
The primary reference deployment validating all Bastion capabilities:
- **Sovereignty:** Self-hosted control plane, local governance authority
- **Mixed fleet:** Windows workstations (Entra/Intune) + Linux terminals (Bascule/GSH)
- **Dual-mode:** Physical NOC terminals + VDI remote access for off-site operators
- **Hardware trust:** TPM attestation on all NOC endpoints, HBOM verification
- **Compliance:** Continuous posture attestation for tribal cybersecurity requirements
- **Insurance:** Witness delegation to cyber insurer for dynamic premium model
- **AI agents:** Harness-governed automated monitoring and remediation
---
## Architecture Principles
1. **Zero credential storage.** The broker holds authorization decisions (ACs), never credentials. Short-lived credentials acquired at invocation time, discarded after use.
2. **Governance by identity, not device.** The identity (who) determines the policy (what they can do). The device (where they are) is a posture signal, not the access decision.
3. **Delegate enforcement, own decisions.** Bastion makes governance decisions. Platform-specific tools (Intune, Keylime, Ansible, Bascule) enforce them. The control plane is durable; backends evolve.
4. **Attest, don't assert.** Every governance claim is backed by cryptographic evidence — TPM measurements, merkle-anchored posture records, signed attestation snapshots. Software assertions are corroborated by hardware proofs.
5. **Pluggable everything.** Identity drivers, credential backends, connectors, VDI adapters, compliance policies. The framework ships; the ecosystem grows.
6. **Audit before execute.** Chronicle INTENT event before every operation, RESULT event after. The audit trail survives execution failures.
---
## Contributing
Bastion follows the guild-based contribution model:
- **License:** Apache 2.0
- **Contributions:** Developer Certificate of Origin (DCO), not CLA
- **Connector development:** Implement the ConnectorPlugin ABC; the framework provides Chronicle audit, GSAP validation, and credential resolution
- **Identity drivers:** Implement the IdentityDriver ABC with JWKSVerifier for JWT validation
- **Credential backends:** Implement the CredentialBackend ABC with enforced TTL on all credentials
- **Policy templates:** Submit compliance policy definitions for specific verticals or frameworks
See bastion-security-audit.md for the current security posture and known limitations.

View file

@ -0,0 +1,13 @@
namespace: guildhouse
name: bastion
version: 0.1.0
description: >
Bastion MDM integration for Ansible. Provides dynamic inventory
from Bastion's device registry, credential resolution via
Bastion's zero-storage CredentialResolver, and Chronicle audit
callback for playbook governance.
license:
- Apache-2.0
dependencies: {}
repository: https://git.guildhouse.dev/tking/fastapi-gsap
documentation: https://git.guildhouse.dev/tking/fastapi-gsap/docs/ansible

View file

@ -0,0 +1,126 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Bastion Chronicle callback plugin for Ansible.
Reports playbook execution events to Chronicle via the
Bastion broker. Every playbook run, task result, and final
status becomes a governed audit event.
Events emitted:
ANSIBLE_PLAYBOOK_STARTED -- playbook execution begins
ANSIBLE_TASK_COMPLETED -- individual task result (per host)
ANSIBLE_PLAYBOOK_COMPLETED -- final playbook status + summary
"""
from __future__ import annotations
import logging
import os
from datetime import datetime, UTC
logger = logging.getLogger(__name__)
DOCUMENTATION = """
name: guildhouse.bastion.bastion_chronicle
type: notification
short_description: Bastion Chronicle audit callback
description:
- Reports playbook lifecycle events to Chronicle
- Optionally triggers compliance re-evaluation after playbook
"""
def _get_client():
"""Lazily construct BastionClient."""
try:
from ansible_collection.guildhouse.bastion.plugins.module_utils.bastion_client import BastionClient
return BastionClient()
except ImportError:
return None
class ChronicleCallback:
"""Bastion Chronicle callback for Ansible playbook auditing.
This is a standalone class (not subclassing Ansible's
CallbackBase) so it can be tested without Ansible installed.
The actual Ansible callback module wraps this class.
"""
CALLBACK_VERSION = 2.0
CALLBACK_TYPE = "notification"
CALLBACK_NAME = "guildhouse.bastion.bastion_chronicle"
def __init__(self):
self._client = _get_client()
self._ac_id = os.environ.get("BASTION_AC_ID", "")
self._recheck = os.environ.get("BASTION_RECHECK_COMPLIANCE", "").lower() == "true"
self._affected_hosts: set[str] = set()
self._events: list[dict] = []
def on_playbook_start(self, playbook_name: str, hosts: list[str] | None = None):
"""Emit ANSIBLE_PLAYBOOK_STARTED Chronicle event."""
event = {
"kind": "ANSIBLE_PLAYBOOK_STARTED",
"playbook": playbook_name,
"hosts": hosts or [],
"ac_id": self._ac_id,
"timestamp": datetime.now(UTC).isoformat(),
}
self._events.append(event)
self._emit(event)
def on_task_completed(self, host: str, task_name: str, status: str, changed: bool = False):
"""Emit ANSIBLE_TASK_COMPLETED for a single host result."""
self._affected_hosts.add(host)
event = {
"kind": "ANSIBLE_TASK_COMPLETED",
"host": host,
"task": task_name,
"status": status,
"changed": changed,
"ac_id": self._ac_id,
"timestamp": datetime.now(UTC).isoformat(),
}
self._events.append(event)
self._emit(event)
def on_playbook_completed(self, stats: dict):
"""Emit ANSIBLE_PLAYBOOK_COMPLETED with summary.
Optionally triggers compliance re-evaluation for affected hosts.
"""
event = {
"kind": "ANSIBLE_PLAYBOOK_COMPLETED",
"stats": stats,
"affected_hosts": sorted(self._affected_hosts),
"ac_id": self._ac_id,
"timestamp": datetime.now(UTC).isoformat(),
}
self._events.append(event)
self._emit(event)
if self._recheck and self._affected_hosts:
self._trigger_compliance_recheck()
def _emit(self, event: dict):
"""Send event to Chronicle via Bastion client."""
if self._client is None:
logger.debug("Chronicle callback: no Bastion client available")
return
try:
self._client.emit_chronicle(event["kind"], event)
except Exception as e:
logger.warning("Chronicle emit failed: %s", e)
def _trigger_compliance_recheck(self):
"""Trigger compliance re-evaluation for affected hosts."""
if self._client is None:
return
for host in self._affected_hosts:
try:
self._client.get_device_compliance(host)
logger.info("Compliance re-check triggered for %s", host)
except Exception as e:
logger.warning("Compliance re-check failed for %s: %s", host, e)

View file

@ -0,0 +1,129 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Bastion dynamic inventory plugin for Ansible.
Queries the Bastion broker for managed devices and presents them
as Ansible inventory. Devices are grouped by OS, compliance state,
and accord scope. Host variables include Bastion-specific metadata
with bastion_ prefix.
BOUNDARY: This plugin provides inventory and read-only host
variables. It does NOT provide credentials -- that is the
credential lookup plugin's job.
"""
from __future__ import annotations
import os
import json
import logging
from urllib.request import Request, urlopen
from urllib.error import URLError
logger = logging.getLogger(__name__)
# Credential field names that must NEVER appear as host variables.
_CREDENTIAL_FIELDS = frozenset({
"ansible_password", "ansible_ssh_pass", "ansible_ssh_private_key_file",
"ansible_become_pass", "ansible_winrm_password",
})
DOCUMENTATION = """
name: guildhouse.bastion.bastion
plugin_type: inventory
short_description: Bastion MDM dynamic inventory
description:
- Queries Bastion broker for managed devices
- Groups by OS, compliance state, accord scope
- Provides bastion_* host variables
options:
bastion_url:
description: Bastion broker URL
required: true
env:
- name: BASTION_URL
bastion_token:
description: Bearer token for Bastion API
required: false
env:
- name: BASTION_TOKEN
"""
def _fetch_devices(url, token=""):
"""Fetch device list from Bastion broker."""
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
body = json.dumps({"operation": "list_devices", "parameters": {}}).encode()
req = Request(f"{url}/connectors/intune/invoke/", data=body, headers=headers, method="POST")
try:
with urlopen(req, timeout=15) as resp:
data = json.loads(resp.read())
if data.get("success"):
return data.get("data", [])
except URLError as e:
logger.warning("Failed to fetch devices from Bastion: %s", e)
return []
def build_inventory(devices):
"""Build Ansible inventory dict from Bastion device list.
Returns inventory suitable for ``--list`` output or internal
``parse()`` population.
NEVER includes credential variables in host vars.
"""
inventory = {
"_meta": {"hostvars": {}},
"all": {"hosts": [], "children": []},
}
groups: dict[str, list[str]] = {}
for device in devices:
device_name = device.get("device_name", "")
device_id = device.get("device_id", "")
if not device_name:
continue
hostname = device_name.lower()
inventory["all"]["hosts"].append(hostname)
# Host variables (bastion_ prefix, NEVER credentials)
hostvars = {
"bastion_device_id": device_id,
"bastion_compliance_state": device.get("compliance_state", "unknown"),
"bastion_os_type": device.get("os_type", "").lower(),
"bastion_os_version": device.get("os_version", ""),
"bastion_last_sync": device.get("last_sync"),
"bastion_user_principal_name": device.get("user_principal_name"),
"bastion_entra_device_id": device.get("entra_device_id"),
"bastion_management_channel": "intune",
}
# Safety: ensure no credential fields leak through
for field in _CREDENTIAL_FIELDS:
hostvars.pop(field, None)
inventory["_meta"]["hostvars"][hostname] = hostvars
# Group by OS
os_type = device.get("os_type", "unknown").lower()
os_group = f"os_{os_type}" if os_type else "os_unknown"
groups.setdefault(os_group, []).append(hostname)
# Group by compliance
compliance = device.get("compliance_state", "unknown").lower()
comp_group = f"compliance_{compliance}"
groups.setdefault(comp_group, []).append(hostname)
# Add groups to inventory
for group_name, hosts in groups.items():
inventory[group_name] = {"hosts": hosts}
if group_name not in inventory["all"].get("children", []):
inventory["all"].setdefault("children", []).append(group_name)
return inventory

View file

@ -0,0 +1,86 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Bastion credential lookup plugin for Ansible.
Resolves short-lived credentials from Bastion's CredentialResolver
at playbook execution time. Replaces static credentials in
Ansible Vault with dynamic, scoped, time-limited credentials.
SECURITY: Credentials are resolved per-host at execution time.
They are NOT cached, NOT written to facts, NOT stored in any
persistent structure. Ansible holds them only for the duration
of the connection to that specific host.
"""
from __future__ import annotations
import logging
import os
logger = logging.getLogger(__name__)
DOCUMENTATION = """
name: guildhouse.bastion.credential
short_description: Resolve credentials from Bastion
description:
- Acquires short-lived credentials from Bastion CredentialResolver
- Zero credential storage -- resolved at execution time only
options:
host:
description: Target hostname to resolve credential for
required: true
type:
description: Credential type (kerberos, oauth, ssh_cert, auto)
required: false
default: auto
ac_id:
description: Authorization Context ID for governed resolution
required: false
env:
- name: BASTION_AC_ID
"""
# OS type to credential type mapping for auto-detection
_AUTO_TYPE_MAP = {
"windows": "kerberos",
"linux": "ssh_cert",
"macos": "ssh_cert",
}
def resolve_credential(host, credential_type="auto", ac_id=None, host_vars=None):
"""Resolve a credential for the specified host.
1. Determine credential type (auto-detect from bastion_os_type
host var, or use explicit type)
2. Call Bastion broker's credential resolution endpoint
3. Return the credential value
NOTE: Until the broker exposes a /credentials/resolve HTTP
endpoint, this returns None with a warning, allowing Ansible
to fall back to its own credential resolution.
"""
if credential_type == "auto" and host_vars:
os_type = host_vars.get("bastion_os_type", "").lower()
credential_type = _AUTO_TYPE_MAP.get(os_type, "oauth")
# Import here to avoid hard dependency on bastion_client at
# module load time (Ansible loads all lookup plugins eagerly).
try:
from ansible_collection.guildhouse.bastion.plugins.module_utils.bastion_client import BastionClient
client = BastionClient()
result = client.resolve_credential(credential_type, host, ac_id)
if result is not None:
return result
except ImportError:
pass
except Exception as e:
logger.warning("Bastion credential resolution failed for %s: %s", host, e)
logger.debug(
"Bastion credential resolution not available for %s "
"(type=%s). Falling back to Ansible credential resolution.",
host, credential_type,
)
return None

View file

@ -0,0 +1,113 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Shared HTTP client for Bastion broker API.
Used by all plugins (inventory, credential, callback) to
communicate with the Bastion broker. Handles authentication,
base URL configuration, and error handling.
Configuration via environment variables:
BASTION_URL -- broker base URL (e.g. http://localhost:8000)
BASTION_TOKEN -- bearer token for API authentication
"""
import json
import logging
import os
from urllib.request import Request, urlopen
from urllib.error import URLError
logger = logging.getLogger(__name__)
# Uses stdlib urllib to avoid requiring httpx/requests as a
# dependency for the Ansible collection. Ansible control nodes
# always have Python stdlib available.
class BastionClient:
"""HTTP client for the Bastion broker API."""
def __init__(self, url=None, token=None):
self.url = (url or os.environ.get("BASTION_URL", "http://localhost:8000")).rstrip("/")
self.token = token or os.environ.get("BASTION_TOKEN", "")
def _headers(self):
h = {"Content-Type": "application/json"}
if self.token:
h["Authorization"] = f"Bearer {self.token}"
return h
def _get(self, path, params=None):
"""HTTP GET with auth headers."""
url = f"{self.url}{path}"
if params:
qs = "&".join(f"{k}={v}" for k, v in params.items())
url = f"{url}?{qs}"
req = Request(url, headers=self._headers(), method="GET")
try:
with urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except URLError as e:
logger.warning("Bastion API GET %s failed: %s", path, e)
return None
def _post(self, path, body=None):
"""HTTP POST with auth headers."""
url = f"{self.url}{path}"
data = json.dumps(body or {}).encode()
req = Request(url, data=data, headers=self._headers(), method="POST")
try:
with urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except URLError as e:
logger.warning("Bastion API POST %s failed: %s", path, e)
return None
def get_devices(self, filters=None):
"""Query Bastion for managed device inventory."""
result = self._post(
"/connectors/intune/invoke/",
{"operation": "list_devices", "parameters": filters or {}},
)
if result and result.get("success"):
return result.get("data", [])
return []
def get_device_compliance(self, device_id):
"""Get compliance state for a specific device."""
result = self._post(
"/connectors/intune/invoke/",
{"operation": "get_compliance", "parameters": {"device_id": device_id}},
)
if result and result.get("success"):
return result.get("data", {})
return None
def resolve_credential(self, credential_type, target, ac_id=None):
"""Resolve a short-lived credential for a target host.
NOTE: The broker does not yet expose a /credentials/resolve
HTTP endpoint. Returns None for now -- Ansible falls back to
its own credential resolution. The endpoint will be added
when the CredentialResolver gets an HTTP API.
"""
logger.debug(
"Credential resolution not yet available via HTTP API "
"(type=%s, target=%s)", credential_type, target
)
return None
def emit_chronicle(self, event_type, event_data):
"""Emit a Chronicle event via the broker."""
return self._post(
"/connectors/echo/invoke/",
{
"operation": "chronicle_proxy",
"parameters": {"kind": event_type, **event_data},
},
)
def get_ac(self, ac_id):
"""Verify an AC is active."""
return self._get(f"/governance/session/{ac_id}/")

View file

@ -25,6 +25,9 @@ logger = structlog.get_logger()
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
# Fix C-4/C-8: initialize shared bearer auth middleware
from gsap_broker.auth.middleware import init_authenticator
init_authenticator()
cleanup_task = asyncio.create_task(delegation_cleanup_loop(delegation_manager))
logger.info(
"fastapi-gsap started",

View file

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

View file

@ -0,0 +1,175 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Shared bearer token authentication for Bastion.
Provides a FastAPI dependency (``verify_bearer``) that:
1. Extracts the bearer token from the Authorization header
2. Inspects the token's issuer claim (unverified peek) to
determine which identity driver to use
3. Verifies the token via the appropriate driver's JWKSVerifier
4. Returns a verified AuthResult with principal_did, roles, device_id
Fix C-4: MCP endpoint uses this dependency.
Fix C-8: Delegation endpoint uses this dependency.
SECURITY: The issuer peek (step 2) is done WITHOUT verification
to determine which JWKS endpoint to use. The actual claims are
ONLY trusted after full JWKSVerifier validation in step 3.
"""
from __future__ import annotations
import base64
import json
import logging
from typing import Optional
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from gsap_broker.drivers.base import AuthResult
from gsap_broker.drivers.jwks import AuthenticationError, JWKSVerifier
from gsap_broker.settings import settings
logger = logging.getLogger(__name__)
_bearer_scheme = HTTPBearer(auto_error=True)
# ── Issuer → verifier mapping, built at init time ────────────────
_verifiers: dict[str, JWKSVerifier] = {}
def init_authenticator() -> None:
"""Build issuer → JWKSVerifier mapping from settings.
Called once at broker startup.
"""
global _verifiers
_verifiers = {}
# Keycloak
if settings.keycloak_url and settings.keycloak_realm:
kc_issuer = f"{settings.keycloak_url}/realms/{settings.keycloak_realm}"
_verifiers[kc_issuer] = JWKSVerifier(
jwks_url=f"{kc_issuer}/protocol/openid-connect/certs",
audience=settings.keycloak_admin_client_id,
issuer=kc_issuer,
)
logger.info("Auth middleware: Keycloak issuer registered (%s)", kc_issuer)
# Entra
if settings.entra_tenant_id:
entra_issuer = f"https://login.microsoftonline.com/{settings.entra_tenant_id}/v2.0"
_verifiers[entra_issuer] = JWKSVerifier(
jwks_url=f"https://login.microsoftonline.com/{settings.entra_tenant_id}/discovery/v2.0/keys",
audience=settings.entra_client_id,
issuer=entra_issuer,
)
logger.info("Auth middleware: Entra issuer registered (%s)", entra_issuer)
def _peek_issuer(token: str) -> str:
"""Base64-decode the JWT payload to read the iss claim.
SECURITY: This is an UNVERIFIED peek. The iss value is used ONLY
to select the JWKS endpoint. No other claims are trusted.
"""
try:
parts = token.split(".")
if len(parts) != 3:
raise ValueError("Not a JWT (expected 3 parts)")
payload = parts[1]
payload += "=" * (4 - len(payload) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload))
iss = claims.get("iss", "")
if not iss:
raise ValueError("Token has no iss claim")
return iss
except Exception as e:
raise HTTPException(status_code=401, detail=f"Malformed token: {e}")
async def _authenticate_token(token: str) -> AuthResult:
"""Verify a bearer token and return the authenticated identity."""
if not _verifiers:
raise HTTPException(
status_code=500,
detail="No identity drivers configured. Call init_authenticator() at startup.",
)
issuer = _peek_issuer(token)
verifier = _verifiers.get(issuer)
if verifier is None:
raise HTTPException(
status_code=401,
detail=f"Unknown token issuer: {issuer}",
)
try:
claims = await verifier.verify_or_refresh(token)
except AuthenticationError as e:
raise HTTPException(status_code=401, detail=str(e))
# Build AuthResult from verified claims
oid = claims.get("oid") or claims.get("sub", "")
domain = settings.keycloak_domain
# Determine DID format based on issuer type
if "microsoftonline.com" in issuer:
principal_did = f"did:web:{domain}:principal:{oid}"
else:
alias = claims.get("preferred_username", oid)
template = settings.keycloak_did_template
principal_did = template.format(stable_id=oid, domain=domain, alias=alias)
roles = claims.get("roles", [])
realm_roles = claims.get("realm_access", {}).get("roles", [])
all_roles = roles + realm_roles
amr = claims.get("amr", [])
device_id = claims.get("deviceid") or claims.get("device_id")
return AuthResult(
status=AuthResult.STATUS_AUTHORIZED,
principal_did=principal_did,
display_name=claims.get("name") or claims.get("preferred_username", ""),
stable_id=oid,
token_jti=claims.get("jti", ""),
elevation_active=[r for r in all_roles if r.endswith(settings.keycloak_elevated_role_suffix)],
mfa_satisfied="mfa" in amr or "otp" in amr or "ngcmfa" in amr,
device_id=device_id,
)
async def verify_bearer(
credentials: HTTPAuthorizationCredentials = Depends(_bearer_scheme),
) -> AuthResult:
"""FastAPI dependency for mandatory bearer token authentication.
Usage::
@router.post("/endpoint/")
async def handler(auth: AuthResult = Depends(verify_bearer)):
print(auth.principal_did) # verified identity
Returns AuthResult with verified claims.
Raises 401 if token is missing, malformed, or invalid.
"""
return await _authenticate_token(credentials.credentials)
async def optional_bearer(request: Request) -> Optional[AuthResult]:
"""FastAPI dependency for optional authentication.
Returns AuthResult if a valid bearer token is present.
Returns None if no Authorization header is provided.
Raises 401 if a token IS provided but is invalid.
"""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
token = auth_header[7:]
return await _authenticate_token(token)

View file

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

@ -0,0 +1,87 @@
# 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,6 +7,12 @@ 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:
@ -15,6 +21,9 @@ 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
@ -36,6 +45,13 @@ 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

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

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

@ -0,0 +1,88 @@
# 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,11 +1,25 @@
"""ConnectorRuntime — governed invocation with Chronicle emission."""
# 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.
"""
from __future__ import annotations
from typing import Any
import logging
from datetime import datetime, UTC
from typing import Any, Optional
from sqlalchemy import text
from .base import ConnectorContext, ConnectorResult
from .registry import ConnectorRegistry
logger = logging.getLogger(__name__)
class ConnectorRuntime:
def __init__(
@ -25,16 +39,49 @@ class ConnectorRuntime:
if connector is None:
return ConnectorResult(success=False, error=f"Unknown connector: {connector_id}")
if connector.gsap_required and not context.gsap_context_id:
return ConnectorResult(success=False, error="GSAP context required but not provided")
# 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
result = await connector.invoke(operation, parameters, context)
# Emit Chronicle event
# Emit Chronicle RESULT after execution
if connector.chronicle_enabled and self.chronicle_client is not None:
try:
cid = await self.chronicle_client.emit(
"CONNECTOR_INVOKED",
"CONNECTOR_INVOCATION_RESULT",
{
"connector_id": connector_id,
"operation": operation,
@ -51,3 +98,39 @@ 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

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

View file

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

View file

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

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

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

@ -1,3 +1,9 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
import os
import stat
from sqlmodel import SQLModel
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
from sqlmodel.ext.asyncio.session import AsyncSession
@ -5,16 +11,54 @@ from gsap_broker.settings import settings
engine: AsyncEngine = create_async_engine(settings.database_url, echo=False)
def _restrict_db_permissions() -> None:
"""Fix H-6: restrict SQLite file permissions to owner-only (0o600).
Also restricts WAL and SHM files if they exist.
"""
db_url = settings.database_url
if "sqlite" not in db_url:
return
# Extract path from sqlite URL
path = db_url.split("///")[-1] if "///" in db_url else ""
if not path or not os.path.exists(path):
return
mode = stat.S_IRUSR | stat.S_IWUSR # 0o600
try:
os.chmod(path, mode)
for suffix in ("-wal", "-shm"):
extra = path + suffix
if os.path.exists(extra):
os.chmod(extra, mode)
except OSError:
pass # may fail on Windows or read-only filesystem
async def init_db():
# Set restrictive umask before creating the database
old_umask = os.umask(0o077)
try:
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
# Schema migrations for existing DBs:
# Schema migrations for existing DBs
for migration in [
"ALTER TABLE authorization_contexts ADD COLUMN session_mode BOOLEAN DEFAULT 0",
"ALTER TABLE delegations ADD COLUMN depth INTEGER DEFAULT 0",
"ALTER TABLE delegations ADD COLUMN parent_delegation_id TEXT DEFAULT NULL",
]:
try:
await conn.execute(
__import__("sqlalchemy").text("ALTER TABLE authorization_contexts ADD COLUMN session_mode BOOLEAN DEFAULT 0")
)
await conn.execute(__import__("sqlalchemy").text(migration))
except Exception:
pass # Column already exists
finally:
os.umask(old_umask)
# Fix H-6: restrict file permissions after creation
_restrict_db_permissions()
async def get_session():
async with AsyncSession(engine) as session:

View file

@ -44,10 +44,19 @@ class DelegationManager:
self.registrar = create_registrar(config)
async def create_delegation(
self, request: DelegationRequest, delegator_did: str
self, request: DelegationRequest, delegator_did: str,
delegator_capability_mask: int = 0x7,
) -> 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,57 +1,30 @@
# 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 standard Graph application registration with agent metadata tags.
Uses the shared GraphClient for authenticated Graph API access.
When Entra Agent ID Blueprint APIs reach GA, this driver should be updated
to use the dedicated /agentIdentityBlueprints and /agentIdentities endpoints.
"""
import logging
import httpx
import msal
from gsap_broker.intune.graph_client import GraphClient
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,
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
def __init__(self, graph_client: GraphClient, agent_blueprint_id: str = ""):
self.graph = graph_client
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,
@ -62,8 +35,6 @@ 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}",
@ -82,34 +53,31 @@ class EntraRegistrar:
"passwordCredentials": [],
}
async with httpx.AsyncClient(timeout=15.0) as http:
resp = await http.post(f"{GRAPH_API}/applications", json=app_body, headers=headers)
resp = await self.graph.post("/applications", body=app_body)
if resp.status_code == 401:
headers = await self._headers()
resp = await http.post(f"{GRAPH_API}/applications", json=app_body, headers=headers)
# Token expired between construction and call — retry once.
resp = await self.graph.post("/applications", body=app_body)
resp.raise_for_status()
app_data = resp.json()
app_id = app_data["appId"]
object_id = app_data["id"]
secret_resp = await http.post(
f"{GRAPH_API}/applications/{object_id}/addPassword",
json={
secret_resp = await self.graph.post(
f"/applications/{object_id}/addPassword",
body={
"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 http.post(
f"{GRAPH_API}/servicePrincipals",
json={"appId": app_id, "displayName": display_name, "tags": tags},
headers=headers,
sp_resp = await self.graph.post(
"/servicePrincipals",
body={"appId": app_id, "displayName": display_name, "tags": tags},
)
if sp_resp.status_code not in (200, 201, 409):
sp_resp.raise_for_status()
@ -123,25 +91,19 @@ class EntraRegistrar:
)
async def delete_agent(self, client_id: str) -> bool:
headers = await self._headers()
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,
try:
data = await self.graph.get(
"/applications", params={"$filter": f"appId eq '{client_id}'"}
)
resp.raise_for_status()
apps = resp.json().get("value", [])
except Exception:
return False
apps = data.get("value", [])
if not apps:
return False
object_id = apps[0]["id"]
del_resp = await http.delete(
f"{GRAPH_API}/applications/{object_id}",
headers=headers,
)
deleted = del_resp.status_code in (200, 204)
deleted = await self.graph.delete(f"/applications/{object_id}")
if deleted:
logger.info("Entra: deleted agent app %s", client_id)
return deleted

View file

@ -1,3 +1,6 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Registrar factory — selects the appropriate AgentRegistrar based on settings."""
import logging
@ -31,22 +34,30 @@ 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
return EntraRegistrar(
graph = GraphClient(
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")
return EntraRegistrar(
graph = GraphClient(
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

@ -1,15 +1,25 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""FastAPI router for delegation lifecycle.
Endpoints (originally from llm-principal-broker, now in-process):
POST /delegations/ create_delegation §8.1
POST /delegations/{id}/revoke revoke_delegation §8.2
GET /delegations/{id} get_delegation §8.3
GET /delegations/ list_delegations §8.4
Fix C-8: All endpoints require bearer token authentication.
Fix H-7: Delegation depth enforced via max_delegation_depth.
Endpoints:
POST /delegations/ create_delegation SS8.1
POST /delegations/{id}/revoke revoke_delegation SS8.2
GET /delegations/{id} get_delegation SS8.3
GET /delegations/ list_delegations SS8.4
"""
from datetime import datetime
from datetime import datetime, UTC
from fastapi import APIRouter, Header, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from gsap_broker.auth.middleware import verify_bearer
from gsap_broker.drivers.base import AuthResult
from gsap_broker.settings import settings
from .lifecycle import DelegationManager
from .models import (
@ -26,43 +36,91 @@ from .storage import get_active_delegations, get_delegation as db_get
router = APIRouter(prefix="/delegations", tags=["Delegations"])
# A single DelegationManager instance is shared across requests. It holds the
# AgentRegistrar (Keycloak/Entra/Stub) and is constructed once at import time.
manager = DelegationManager()
@router.post("/", response_model=DelegationResponse)
async def create_delegation(
request: DelegationRequest,
x_delegator_did: str = Header(..., alias="X-Delegator-DID"),
auth: AuthResult = Depends(verify_bearer),
):
"""Request delegation of authority to an AI agent (§8.1)."""
"""Request delegation of authority to an AI agent (SS8.1).
Fix C-8: delegator_did derived from verified token.
Fix H-7: delegation depth enforced.
"""
delegator_did = auth.principal_did
# Validate delegator_ac_id belongs to this principal
from gsap_broker.db import engine
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy import text
async with AsyncSession(engine) as session:
result = await session.execute(
text("SELECT principal_did, capability_mask FROM authorization_contexts WHERE context_id = :ctx_id AND status IN ('authorized', 'active')"),
{"ctx_id": request.delegator_ac_id.replace("-", "")},
)
ac_row = result.first()
if not ac_row or ac_row[0] != delegator_did:
raise HTTPException(403, "delegator_ac_id not found or does not belong to authenticated principal")
delegator_cap = ac_row[1] if ac_row[1] else 0x7
# Fix H-7: check delegation depth
parent_delegation = await _find_delegation_by_ac(request.delegator_ac_id)
new_depth = (parent_delegation.depth + 1) if parent_delegation and hasattr(parent_delegation, "depth") else 0
if new_depth >= settings.max_delegation_depth:
raise HTTPException(
403,
f"Maximum delegation depth ({settings.max_delegation_depth}) exceeded. "
f"Current depth: {new_depth}",
)
try:
return await manager.create_delegation(request, x_delegator_did)
result = await manager.create_delegation(
request, delegator_did,
delegator_capability_mask=delegator_cap,
)
return result
except ValueError as e:
raise HTTPException(403, str(e))
except RuntimeError as e:
raise HTTPException(status_code=502, detail=str(e))
raise HTTPException(502, str(e))
@router.post("/{delegation_id}/revoke", response_model=RevokeResponse)
async def revoke_delegation(
delegation_id: str,
request: RevokeRequest = RevokeRequest(),
auth: AuthResult = Depends(verify_bearer),
):
"""Revoke an active delegation (§8.2)."""
"""Revoke an active delegation (SS8.2). Only the delegator can revoke."""
delegation = await db_get(delegation_id)
if not delegation:
raise HTTPException(404, "Delegation not found")
if delegation.delegator_did != auth.principal_did:
raise HTTPException(403, "Only the delegator can revoke this delegation")
success = await manager.revoke_delegation(delegation_id, request.reason)
if not success:
raise HTTPException(status_code=404, detail="Delegation not found or not active")
raise HTTPException(404, "Delegation not found or not active")
return RevokeResponse(delegation_id=delegation_id, reason=request.reason)
@router.get("/{delegation_id}", response_model=DelegationInfo)
async def get_delegation(delegation_id: str):
"""Query delegation status (§8.3)."""
async def get_delegation(
delegation_id: str,
auth: AuthResult = Depends(verify_bearer),
):
"""Query delegation status (SS8.3). Delegator or delegate can view."""
d = await db_get(delegation_id)
if not d:
raise HTTPException(status_code=404, detail="Delegation not found")
raise HTTPException(404, "Delegation not found")
now = datetime.utcnow()
if d.delegator_did != auth.principal_did and d.agent_did != auth.principal_did:
raise HTTPException(403, "Not authorized to view this delegation")
now = datetime.now(UTC).replace(tzinfo=None)
ttl_remaining = max(0, int((d.expires_at - now).total_seconds()))
return DelegationInfo(
@ -80,10 +138,15 @@ async def get_delegation(delegation_id: str):
@router.get("/", response_model=AgentListResponse)
async def list_delegations():
"""List all active agent delegations (§8.4)."""
async def list_delegations(
auth: AuthResult = Depends(verify_bearer),
):
"""List active delegations for the authenticated principal (SS8.4)."""
active = await get_active_delegations()
now = datetime.utcnow()
now = datetime.now(UTC).replace(tzinfo=None)
# Filter to only this principal's delegations
mine = [d for d in active if d.delegator_did == auth.principal_did]
return AgentListResponse(
active_delegations=[
@ -97,7 +160,17 @@ async def list_delegations():
),
status=d.status,
)
for d in active
for d in mine
],
total_active=len(active),
total_active=len(mine),
)
async def _find_delegation_by_ac(ac_id: str):
"""Find a delegation that was created from this AC (for depth tracking)."""
from .storage import get_active_delegations
all_delegations = await get_active_delegations()
for d in all_delegations:
if d.delegated_ac_id == ac_id:
return d
return None

View file

@ -35,6 +35,9 @@ class DelegationDB(SQLModel, table=True):
revoked_at: Optional[datetime] = None
revoke_reason: Optional[str] = None
chronicle_cid: Optional[str] = None
# Fix H-7: delegation depth tracking
depth: int = Field(default=0)
parent_delegation_id: Optional[str] = Field(default=None)
async def create_delegation(delegation: DelegationDB) -> None:
@ -79,40 +82,65 @@ 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.exec(
select(DelegationDB).where(DelegationDB.delegation_id == delegation_id)
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},
)
d = result.first()
if not d:
return 0
d.commands_executed += 1
session.add(d)
await session.commit()
return d.commands_executed
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
async def expire_stale() -> list[DelegationDB]:
"""Find and expire delegations past TTL or command limit."""
now = datetime.utcnow()
"""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)
async with AsyncSession(engine) as session:
result = await session.exec(
select(DelegationDB).where(DelegationDB.status == "active")
# 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)},
)
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"
# 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)},
)
d.revoked_at = now
session.add(d)
expired.append(d)
await session.commit()
return expired
# Return the expired rows for cleanup
result = await session.exec(
select(DelegationDB).where(
DelegationDB.status == "expired",
DelegationDB.revoked_at != None,
)
)
return list(result.all())
async def revoke_by_delegator_ac(delegator_ac_id: str) -> int:

View file

@ -25,6 +25,7 @@ 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

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

137
gsap_broker/drivers/jwks.py Normal file
View file

@ -0,0 +1,137 @@
# 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,14 +1,47 @@
"""Keycloak identity driver — GSAP §2.2."""
# 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.
"""
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:
token_data = self.config.get("_token_data", {})
if not token_data:
return AuthResult(status=AuthResult.STATUS_DENIED, denial_reason="No token in context.")
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.",
)
realm_roles = token_data.get("realm_access", {}).get("roles", [])
requested_accord = self.config.get("requested_accord", "")

View file

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

View file

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

View file

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

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

@ -17,9 +17,12 @@ import time
from datetime import datetime, UTC
import httpx
from fastapi import APIRouter, Request
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from gsap_broker.auth.middleware import verify_bearer
from gsap_broker.drivers.base import AuthResult
from gsap_broker.settings import settings
from gsap_broker import chronicle
@ -158,6 +161,49 @@ 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"],
},
},
]
@ -411,6 +457,32 @@ 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 ────────────────────────────────────────────────
@ -427,6 +499,10 @@ 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)
@ -448,8 +524,11 @@ def _success(result, req_id):
@router.post("/mcp")
async def mcp_endpoint(request: Request):
"""MCP JSON-RPC 2.0 endpoint — governance primitives as tools."""
async def mcp_endpoint(request: Request, auth: AuthResult = Depends(verify_bearer)):
"""MCP JSON-RPC 2.0 endpoint — governance primitives as tools.
Fix C-4: requires bearer token authentication.
"""
try:
body = await request.json()
except Exception:

View file

@ -58,6 +58,9 @@ 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

@ -0,0 +1,28 @@
# 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,6 +1,8 @@
"""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
@ -12,6 +14,15 @@ 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()
@ -34,6 +45,11 @@ 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,
@ -41,6 +57,13 @@ 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))
@ -63,11 +86,71 @@ 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()
# on_behalf_of: trusted caller (Bascule SA) asserts who the AC is for
# 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.",
)
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
@ -77,7 +160,9 @@ 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})
broker={"did": settings.broker_did, "name": settings.broker_name},
device_id=device_id, device_compliant=device_compliant,
compliance_checked_at=compliance_checked_at)
ac_db = AuthorizationContextDB(
context_id=ctx_id, principal_did=principal_did, driver_id=request.driver_id,
@ -100,3 +185,25 @@ 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,6 +10,7 @@ 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()
@ -20,6 +21,64 @@ _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
@ -28,6 +87,9 @@ 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):
@ -63,6 +125,8 @@ 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

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

View file

@ -0,0 +1,159 @@
# 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,6 +35,24 @@ 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

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

View file

@ -0,0 +1,181 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Template repository loader.
Loads a template from a local directory (already cloned/forked).
Parses the manifest, validates variables, resolves dependencies
(by reading their manifests -- actual git cloning is a separate
concern), and loads all content into the broker's registries.
IMPORTANT: The loader does NOT parse or template automation
framework files (Ansible, Terraform, Salt). It only processes
Bastion-owned files in Bastion-owned directories.
"""
from __future__ import annotations
import logging
import re
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import tomllib
from gsap_broker.templates.manifest import TemplateManifest
from gsap_broker.templates.policy import CompliancePolicy
from gsap_broker.templates.registry import AccordRegistry, PolicyRegistry
logger = logging.getLogger(__name__)
# Bastion-owned directories where variable substitution is applied.
# Files outside these directories are NEVER modified.
_BASTION_OWNED_DIRS = frozenset({"policies", "accords", "harnesses", "hbom", "dashboards"})
@dataclass
class LoadResult:
"""Result of loading a template."""
template_name: str = ""
policies_loaded: list[str] = field(default_factory=list)
accords_loaded: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
provenance: dict[str, Any] = field(default_factory=dict)
@property
def success(self) -> bool:
return len(self.errors) == 0
class TemplateLoader:
"""Loads bastion.toml template repos into broker registries."""
def __init__(
self,
policy_registry: PolicyRegistry,
accord_registry: AccordRegistry,
):
self._policies = policy_registry
self._accords = accord_registry
def load(self, template_dir: str | Path, variables: dict[str, Any]) -> LoadResult:
"""Load a template from a local directory.
1. Parse bastion.toml
2. Validate required variables are provided
3. Substitute variables into Bastion-owned files ONLY
4. Parse and load policies into policy registry
5. Parse and load accords into accord registry
6. Record template provenance (git commit hash if available)
Returns LoadResult with loaded items and any warnings.
"""
root = Path(template_dir)
result = LoadResult()
# 1. Parse manifest
manifest_path = root / "bastion.toml"
if not manifest_path.exists():
result.errors.append(f"No bastion.toml found in {root}")
return result
manifest = TemplateManifest.from_toml(manifest_path)
result.template_name = manifest.template.name
# 2. Validate variables
# Apply defaults first
effective_vars = {}
for name, var in manifest.variables.items():
if name in variables:
effective_vars[name] = variables[name]
elif var.default is not None:
effective_vars[name] = var.default
errors = manifest.validate_variables(effective_vars)
if errors:
result.errors.extend(errors)
return result
# 3. Provenance
result.provenance = self._compute_provenance(root)
# 4. Load policies
policies_dir = root / manifest.contents.policies
if policies_dir.is_dir():
for f in sorted(policies_dir.glob("*.toml")):
try:
content = self._substitute_variables(
f.read_text(), effective_vars, manifest.variables
)
# Parse from substituted content
data = tomllib.loads(content)
policy = CompliancePolicy.model_validate(data)
self._policies.register(policy, result.provenance)
result.policies_loaded.append(policy.name)
except Exception as e:
result.warnings.append(f"Failed to load policy {f.name}: {e}")
# 5. Load accords
accords_dir = root / manifest.contents.accords
if accords_dir.is_dir():
for f in sorted(accords_dir.glob("*.toml")):
try:
content = self._substitute_variables(
f.read_text(), effective_vars, manifest.variables
)
data = tomllib.loads(content)
name = data.get("name", f.stem)
self._accords.register(name, data, result.provenance)
result.accords_loaded.append(name)
except Exception as e:
result.warnings.append(f"Failed to load accord {f.name}: {e}")
return result
def _substitute_variables(
self, content: str, values: dict[str, Any], var_defs: dict
) -> str:
"""Replace ${variable_name} in content with values.
ONLY called on Bastion-owned TOML files.
NEVER on Ansible/Terraform/script files.
Warns on unresolved optional variables.
"""
def replacer(match: re.Match) -> str:
var_name = match.group(1)
if var_name in values:
val = values[var_name]
# Don't log sensitive values
var_def = var_defs.get(var_name)
if var_def and not var_def.sensitive:
logger.debug("Substituting ${%s} = %s", var_name, val)
else:
logger.debug("Substituting ${%s} = [REDACTED]", var_name)
return str(val)
logger.warning("Unresolved variable: ${%s}", var_name)
return match.group(0)
return re.sub(r"\$\{(\w+)\}", replacer, content)
def _compute_provenance(self, template_dir: Path) -> dict[str, Any]:
"""Compute git commit hash and repo origin for template provenance."""
provenance: dict[str, Any] = {"template_dir": str(template_dir)}
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=template_dir, capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
provenance["git_commit"] = result.stdout.strip()
result = subprocess.run(
["git", "remote", "get-url", "origin"],
cwd=template_dir, capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
provenance["git_origin"] = result.stdout.strip()
except Exception:
pass
return provenance

View file

@ -0,0 +1,98 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Bastion template manifest schema.
A template is a Git repository with a bastion.toml at the root.
It contains policies, accords, harnesses, scripts, HBOM profiles,
and optionally framework-specific automation (Ansible, Terraform, etc).
The manifest declares:
- What the template provides (contents)
- What it depends on (dependencies)
- What the deployer must customize (variables)
- What Bastion version and connectors it requires (compatibility)
"""
from __future__ import annotations
import tomllib
from pathlib import Path
from typing import Any, Optional
from pydantic import BaseModel, Field
class TemplateMetadata(BaseModel):
"""Template identification and categorization."""
name: str
version: str
description: str = ""
authors: list[str] = []
license: str = "Apache-2.0"
repository: Optional[str] = None
vertical: Optional[str] = None
sub_vertical: Optional[str] = None
compliance_frameworks: list[str] = []
target_fleet_size: Optional[str] = None
requires_vdi: bool = False
requires_tpm: bool = False
class TemplateCompatibility(BaseModel):
"""What Bastion version and connectors this template needs."""
bastion_min: str = "0.5.0"
connectors_required: list[str] = []
connectors_optional: list[str] = []
class TemplateDependency(BaseModel):
"""A dependency on another template repository."""
git: str
tag: Optional[str] = None
branch: Optional[str] = None
class TemplateVariable(BaseModel):
"""A variable the deployer must or may customize."""
type: str = "string"
required: bool = False
default: Optional[str] = None
description: str = ""
sensitive: bool = False
class TemplateContents(BaseModel):
"""Directory mapping for template content."""
policies: str = "policies/"
accords: str = "accords/"
harnesses: str = "harnesses/"
scripts: str = "scripts/"
hbom_profiles: str = "hbom/"
dashboards: str = "dashboards/"
class TemplateManifest(BaseModel):
"""Root schema for bastion.toml."""
template: TemplateMetadata
compatibility: TemplateCompatibility = TemplateCompatibility()
dependencies: dict[str, TemplateDependency] = {}
variables: dict[str, TemplateVariable] = {}
contents: TemplateContents = TemplateContents()
@classmethod
def from_toml(cls, path: str | Path) -> TemplateManifest:
"""Parse a bastion.toml file."""
with open(path, "rb") as f:
data = tomllib.load(f)
return cls.model_validate(data)
def validate_variables(self, provided: dict[str, Any]) -> list[str]:
"""Check that all required variables are provided.
Returns list of error messages (empty = valid)."""
errors = []
for name, var in self.variables.items():
if var.required and name not in provided and var.default is None:
errors.append(f"Required variable '{name}' not provided")
return errors

View file

@ -0,0 +1,88 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Declarative compliance policy schema.
A policy defines conditions that managed devices must satisfy.
Each condition maps to a PostureConditionKind evaluated by
Bastion's compliance engine. Conditions can have platform-specific
check implementations (Intune field, script, Keylime attestation).
Policies are framework-agnostic. They describe WHAT must be true,
not HOW to achieve it. Playbooks/scripts achieve compliance;
policies evaluate it.
"""
from __future__ import annotations
import tomllib
from pathlib import Path
from typing import Any, Optional
from pydantic import BaseModel
class PlatformCheck(BaseModel):
"""Platform-specific check implementation."""
intune_field: Optional[str] = None
intune_policy: Optional[str] = None
expect: Optional[Any] = None
script: Optional[str] = None
keylime_check: Optional[str] = None
class PolicyCondition(BaseModel):
"""A single compliance condition."""
id: str
kind: str
description: str = ""
framework_ref: Optional[str] = None
severity: str = "medium"
optional: bool = False
linux: Optional[PlatformCheck] = None
windows: Optional[PlatformCheck] = None
macos: Optional[PlatformCheck] = None
class BreachResponseConfig(BaseModel):
"""What happens when conditions at each severity level fail."""
critical: str = "suspend_access"
high: str = "alert_msp"
medium: str = "log_only"
low: str = "log_only"
class EvaluationSchedule(BaseModel):
"""How often to evaluate compliance."""
interval_seconds: int = 300
full_evaluation_hours: int = 24
class CompliancePolicy(BaseModel):
"""A complete compliance policy definition."""
name: str
description: str = ""
version: str = "1.0.0"
framework: Optional[str] = None
framework_controls: list[str] = []
conditions: list[PolicyCondition] = []
breach_response: BreachResponseConfig = BreachResponseConfig()
schedule: EvaluationSchedule = EvaluationSchedule()
@classmethod
def from_toml(cls, path: str | Path) -> CompliancePolicy:
"""Parse a policy TOML file."""
with open(path, "rb") as f:
data = tomllib.load(f)
return cls.model_validate(data)
def conditions_for_platform(self, platform: str) -> list[PolicyCondition]:
"""Return conditions applicable to a specific platform."""
result = []
for c in self.conditions:
check = getattr(c, platform, None)
if check is not None:
result.append(c)
return result

View file

@ -0,0 +1,112 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Registries for loaded template content.
Policies, accords, and harnesses are loaded from templates
and stored in memory. The broker's authorization flow and
compliance evaluator query these registries.
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from gsap_broker.templates.policy import CompliancePolicy
logger = logging.getLogger(__name__)
class PolicyRegistry:
"""Stores loaded compliance policies."""
def __init__(self) -> None:
self._policies: dict[str, tuple[CompliancePolicy, dict]] = {}
def register(self, policy: CompliancePolicy, provenance: dict | None = None) -> None:
self._policies[policy.name] = (policy, provenance or {})
logger.info("Policy registered: %s v%s", policy.name, policy.version)
def get(self, name: str) -> Optional[CompliancePolicy]:
entry = self._policies.get(name)
return entry[0] if entry else None
def list(self) -> list[CompliancePolicy]:
return [p for p, _ in self._policies.values()]
def for_framework(self, framework: str) -> list[CompliancePolicy]:
return [p for p, _ in self._policies.values() if p.framework == framework]
class AccordRegistry:
"""Stores loaded accord templates.
Replaces the hardcoded dict in mcp.py and authorize.py.
Falls back to built-in defaults for known accords if no
template has been loaded.
"""
# Built-in defaults (from the original hardcoded dicts)
_BUILTINS: dict[str, dict[str, Any]] = {
"shell-exec": {
"name": "shell-exec",
"capability_ceiling": "CAP_MUTATE",
"session_ttl_minutes": 30,
"mfa_required": False,
"device_compliance_required": False,
},
"dev-operations": {
"name": "dev-operations",
"capability_ceiling": "CAP_MUTATE",
"session_ttl_minutes": 60,
"mfa_required": False,
"device_compliance_required": False,
},
"network-mutate": {
"name": "network-mutate",
"capability_ceiling": "CAP_GOVERN",
"session_ttl_minutes": 15,
"mfa_required": True,
"ceremony_gate": "network-admin-elevated",
"device_compliance_required": False,
},
"ai-delegation-standard": {
"name": "ai-delegation-standard",
"capability_ceiling": "CAP_MUTATE",
"session_ttl_minutes": 60,
"ceremony_required_for": ["delete", "destroy", "drop"],
"max_commands": 500,
"device_compliance_required": False,
},
"infrastructure-operations": {
"name": "infrastructure-operations",
"capability_ceiling": "CAP_MUTATE",
"session_ttl_minutes": 30,
"device_compliance_required": True,
},
"device-management": {
"name": "device-management",
"capability_ceiling": "CAP_MUTATE",
"session_ttl_minutes": 30,
"device_compliance_required": True,
},
}
def __init__(self) -> None:
self._accords: dict[str, tuple[dict, dict]] = {}
def register(self, name: str, accord: dict, provenance: dict | None = None) -> None:
self._accords[name] = (accord, provenance or {})
logger.info("Accord registered: %s", name)
def get(self, name: str) -> Optional[dict]:
entry = self._accords.get(name)
if entry:
return entry[0]
return self._BUILTINS.get(name)
def list(self) -> list[dict]:
all_accords = dict(self._BUILTINS)
all_accords.update({k: v for k, (v, _) in self._accords.items()})
return list(all_accords.values())

View file

@ -0,0 +1,6 @@
name = "standard-operations"
capability_ceiling = "CAP_MUTATE"
session_ttl_minutes = 30
mfa_required = false
device_compliance_required = false
description = "Standard operations accord for ${org_name}"

View file

@ -0,0 +1,8 @@
# This is a plain Ansible playbook. Bastion variable substitution
# MUST NOT modify this file. ${org_name} should remain as-is.
---
- name: Test playbook for ${org_name}
hosts: all
tasks:
- name: Ping
ansible.builtin.ping:

View file

@ -0,0 +1,29 @@
[template]
name = "test-baseline"
version = "0.1.0"
description = "Test template for Bastion loader tests"
authors = ["Test Author"]
vertical = "testing"
compliance_frameworks = ["test-framework"]
[compatibility]
bastion_min = "0.3.0"
connectors_required = ["intune"]
[variables.org_name]
type = "string"
required = true
description = "Organization name"
[variables.admin_email]
type = "string"
required = false
default = "admin@example.com"
description = "Admin email"
[variables.api_key]
type = "string"
required = false
default = "test-key"
description = "API key"
sensitive = true

View file

@ -0,0 +1,37 @@
name = "test-workstation-policy"
description = "Test workstation compliance for ${org_name}"
version = "1.0.0"
framework = "test-framework"
framework_controls = ["TC-001", "TC-002"]
[[conditions]]
id = "disk-encryption"
kind = "DiskEncryption"
description = "Full disk encryption required"
framework_ref = "TC-001"
severity = "critical"
[conditions.linux]
script = "scripts/linux/check-encryption.sh"
expect = "encrypted"
[conditions.windows]
intune_field = "isEncrypted"
expect = true
[[conditions]]
id = "antivirus-active"
kind = "AntivirusActive"
description = "Antivirus must be running"
severity = "high"
[conditions.windows]
intune_field = "antiVirusStatus"
expect = "active"
[breach_response]
critical = "suspend_access"
high = "alert_msp"
[schedule]
interval_seconds = 300

View file

@ -0,0 +1,168 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Tests for the Ansible collection plugins — inventory, credential, callback."""
import sys
import os
import pytest
# Add the collection to the path so plugins are importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "ansible_collection"))
from guildhouse.bastion.plugins.inventory.bastion import build_inventory, _CREDENTIAL_FIELDS
from guildhouse.bastion.plugins.lookup.credential import resolve_credential, _AUTO_TYPE_MAP
from guildhouse.bastion.plugins.callback.bastion_chronicle import ChronicleCallback
# ── Inventory plugin ─────────────────────────────────────────────
MOCK_DEVICES = [
{
"device_id": "00000000-0000-0000-0000-000000000001",
"device_name": "LAPTOP-WIN-01",
"os_type": "Windows",
"os_version": "10.0.19045",
"compliance_state": "compliant",
"last_sync": "2026-04-14T00:00:00Z",
"user_principal_name": "alice@contoso.com",
"entra_device_id": "entra-001",
},
{
"device_id": "00000000-0000-0000-0000-000000000002",
"device_name": "SRV-LINUX-01",
"os_type": "Linux",
"os_version": "6.6",
"compliance_state": "noncompliant",
},
{
"device_id": "00000000-0000-0000-0000-000000000003",
"device_name": "MAC-01",
"os_type": "macOS",
"os_version": "14.0",
"compliance_state": "compliant",
},
]
def test_inventory_groups_by_os():
"""TEST 12: Devices grouped by OS correctly."""
inv = build_inventory(MOCK_DEVICES)
assert "laptop-win-01" in inv["os_windows"]["hosts"]
assert "srv-linux-01" in inv["os_linux"]["hosts"]
assert "mac-01" in inv["os_macos"]["hosts"]
def test_inventory_groups_by_compliance():
"""TEST 12: Devices grouped by compliance state."""
inv = build_inventory(MOCK_DEVICES)
assert "laptop-win-01" in inv["compliance_compliant"]["hosts"]
assert "mac-01" in inv["compliance_compliant"]["hosts"]
assert "srv-linux-01" in inv["compliance_noncompliant"]["hosts"]
def test_inventory_host_vars_have_bastion_prefix():
"""TEST 12: Host variables prefixed with bastion_."""
inv = build_inventory(MOCK_DEVICES)
hostvars = inv["_meta"]["hostvars"]["laptop-win-01"]
assert hostvars["bastion_device_id"] == "00000000-0000-0000-0000-000000000001"
assert hostvars["bastion_compliance_state"] == "compliant"
assert hostvars["bastion_os_type"] == "windows"
assert hostvars["bastion_management_channel"] == "intune"
def test_inventory_no_credentials_in_host_vars():
"""TEST 13: No credential variables in host vars."""
inv = build_inventory(MOCK_DEVICES)
for hostname, hostvars in inv["_meta"]["hostvars"].items():
for field in _CREDENTIAL_FIELDS:
assert field not in hostvars, f"Credential field '{field}' found in {hostname} host vars"
def test_inventory_empty_device_list():
"""Empty device list produces valid empty inventory."""
inv = build_inventory([])
assert inv["all"]["hosts"] == []
assert inv["_meta"]["hostvars"] == {}
# ── Credential lookup plugin ─────────────────────────────────────
def test_credential_auto_detect_windows():
"""Auto-detect type from bastion_os_type."""
cred_type = _AUTO_TYPE_MAP.get("windows")
assert cred_type == "kerberos"
def test_credential_auto_detect_linux():
cred_type = _AUTO_TYPE_MAP.get("linux")
assert cred_type == "ssh_cert"
def test_credential_graceful_degradation():
"""TEST 14: Returns None when broker unavailable."""
result = resolve_credential("host-1", credential_type="kerberos")
assert result is None # Broker not running — graceful degradation
# ── Chronicle callback plugin ────────────────────────────────────
def test_callback_playbook_started():
"""TEST 15: PLAYBOOK_STARTED event emitted."""
cb = ChronicleCallback()
cb.on_playbook_start("test-playbook.yml", ["host-1", "host-2"])
assert len(cb._events) == 1
assert cb._events[0]["kind"] == "ANSIBLE_PLAYBOOK_STARTED"
assert cb._events[0]["playbook"] == "test-playbook.yml"
def test_callback_task_completed():
"""TEST 15: TASK_COMPLETED event emitted per host."""
cb = ChronicleCallback()
cb.on_task_completed("host-1", "Ping", "ok", changed=False)
cb.on_task_completed("host-2", "Ping", "failed", changed=False)
assert len(cb._events) == 2
assert cb._events[0]["kind"] == "ANSIBLE_TASK_COMPLETED"
assert cb._events[1]["status"] == "failed"
def test_callback_playbook_completed():
"""TEST 15: PLAYBOOK_COMPLETED event with stats."""
cb = ChronicleCallback()
cb.on_task_completed("host-1", "Ping", "ok")
cb.on_playbook_completed({"ok": 1, "failed": 0, "changed": 0})
events = [e for e in cb._events if e["kind"] == "ANSIBLE_PLAYBOOK_COMPLETED"]
assert len(events) == 1
assert "host-1" in events[0]["affected_hosts"]
def test_callback_full_lifecycle():
"""TEST 15: Full playbook lifecycle audited with 3 events."""
cb = ChronicleCallback()
cb.on_playbook_start("site.yml", ["host-1"])
cb.on_task_completed("host-1", "Install packages", "changed", changed=True)
cb.on_playbook_completed({"ok": 0, "changed": 1, "failed": 0})
kinds = [e["kind"] for e in cb._events]
assert kinds == [
"ANSIBLE_PLAYBOOK_STARTED",
"ANSIBLE_TASK_COMPLETED",
"ANSIBLE_PLAYBOOK_COMPLETED",
]
def test_callback_compliance_recheck_triggered(monkeypatch):
"""TEST 16: Compliance re-eval triggered when configured."""
monkeypatch.setenv("BASTION_RECHECK_COMPLIANCE", "true")
cb = ChronicleCallback()
cb._recheck = True # env already set but constructor ran before monkeypatch
cb._affected_hosts = {"host-1", "host-2"}
# Without a real client, _trigger_compliance_recheck is a no-op
# but should not raise
cb.on_playbook_completed({"ok": 2})
assert len(cb._events) == 1 # PLAYBOOK_COMPLETED emitted

View file

@ -0,0 +1,233 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Tests for shared bearer auth middleware — C-4, C-8, H-6, H-7."""
import os
import stat
import datetime
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from jose import jwt as jose_jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from gsap_broker.auth.middleware import (
_authenticate_token, _peek_issuer, _verifiers, init_authenticator,
)
from gsap_broker.drivers.base import AuthResult
# ── Test key setup ───────────────────────────────────────────────
def _generate_rsa_keypair():
pk = rsa.generate_private_key(public_exponent=65537, key_size=2048)
return pk, pk.public_key()
def _pem(private_key):
return private_key.private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
def _jwk(public_key, kid="mid-kid"):
import base64
nums = public_key.public_numbers()
e = nums.e.to_bytes((nums.e.bit_length() + 7) // 8, "big")
n = 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).rstrip(b"=").decode(),
"e": base64.urlsafe_b64encode(e).rstrip(b"=").decode(),
}
PRIV, PUB = _generate_rsa_keypair()
KID = "mid-kid"
KC_URL = "http://keycloak.test:8080"
KC_REALM = "test-realm"
KC_ISSUER = f"{KC_URL}/realms/{KC_REALM}"
ENTRA_TID = "entra-test-tenant"
ENTRA_ISSUER = f"https://login.microsoftonline.com/{ENTRA_TID}/v2.0"
JWKS = {"keys": [_jwk(PUB, KID)]}
def _make_token(issuer, audience="test-client", claims=None, kid=KID):
now = datetime.datetime.now(datetime.UTC)
base = {
"iss": issuer, "aud": audience,
"exp": int((now + datetime.timedelta(hours=1)).timestamp()),
"iat": int(now.timestamp()), "nbf": int(now.timestamp()),
"sub": "user-1", "oid": "user-1",
"preferred_username": "alice", "name": "Alice",
"jti": "test-jti",
}
if claims:
base.update(claims)
return jose_jwt.encode(base, _pem(PRIV), algorithm="RS256", headers={"kid": kid})
@pytest.fixture
def mock_jwks():
with patch("gsap_broker.drivers.jwks.httpx.AsyncClient") as m:
resp = MagicMock()
resp.json.return_value = JWKS
resp.raise_for_status = MagicMock()
ctx = AsyncMock()
ctx.__aenter__.return_value.get = AsyncMock(return_value=resp)
m.return_value = ctx
yield m
@pytest.fixture
def setup_verifiers(mock_jwks):
"""Register test verifiers for both issuers."""
from gsap_broker.drivers.jwks import JWKSVerifier
import gsap_broker.auth.middleware as mw
mw._verifiers = {
KC_ISSUER: JWKSVerifier(
jwks_url=f"{KC_ISSUER}/protocol/openid-connect/certs",
audience="test-client", issuer=KC_ISSUER,
),
ENTRA_ISSUER: JWKSVerifier(
jwks_url=f"https://login.microsoftonline.com/{ENTRA_TID}/discovery/v2.0/keys",
audience="test-client", issuer=ENTRA_ISSUER,
),
}
yield
mw._verifiers = {}
# ── Issuer peek ──────────────────────────────────────────────────
def test_peek_issuer_keycloak():
token = _make_token(KC_ISSUER)
assert _peek_issuer(token) == KC_ISSUER
def test_peek_issuer_entra():
token = _make_token(ENTRA_ISSUER)
assert _peek_issuer(token) == ENTRA_ISSUER
def test_peek_issuer_malformed():
with pytest.raises(Exception):
_peek_issuer("not-a-jwt")
# ── Token authentication ─────────────────────────────────────────
@pytest.mark.asyncio
async def test_authenticate_keycloak_token(setup_verifiers):
"""TEST 4: Valid Keycloak token accepted."""
token = _make_token(KC_ISSUER)
result = await _authenticate_token(token)
assert result.is_authorized
assert "alice" in result.principal_did or "user-1" in result.principal_did
@pytest.mark.asyncio
async def test_authenticate_entra_token(setup_verifiers):
"""TEST 5: Valid Entra token accepted."""
token = _make_token(ENTRA_ISSUER, claims={"oid": "entra-user-1"})
result = await _authenticate_token(token)
assert result.is_authorized
assert "entra-user-1" in result.principal_did
@pytest.mark.asyncio
async def test_auto_detect_driver(setup_verifiers):
"""TEST 6: Correct driver selected based on issuer."""
kc_token = _make_token(KC_ISSUER)
entra_token = _make_token(ENTRA_ISSUER)
kc_result = await _authenticate_token(kc_token)
entra_result = await _authenticate_token(entra_token)
assert kc_result.is_authorized
assert entra_result.is_authorized
@pytest.mark.asyncio
async def test_unknown_issuer_rejected(setup_verifiers):
"""TEST 7: Unknown issuer → 401."""
token = _make_token("https://evil.example.com/auth")
with pytest.raises(Exception) as exc_info:
await _authenticate_token(token)
assert "401" in str(exc_info.value) or "Unknown" in str(exc_info.value)
@pytest.mark.asyncio
async def test_invalid_token_rejected(setup_verifiers):
"""TEST 3: Garbage token → 401."""
with pytest.raises(Exception):
await _authenticate_token("not.a.jwt")
# ── MCP auth (C-4) ──────────────────────────────────────────────
@pytest.mark.asyncio
async def test_mcp_unauthenticated_rejected():
"""TEST 8: POST /mcp without auth → 401 or 403."""
from httpx import AsyncClient as RealAsyncClient, ASGITransport
from gsap_broker.app import app
async with RealAsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
resp = await c.post("/mcp", json={"jsonrpc": "2.0", "method": "tools/list", "id": 1})
# HTTPBearer auto_error=True returns 403 when no header
assert resp.status_code in (401, 403)
# ── Delegation auth (C-8) ────────────────────────────────────────
@pytest.mark.asyncio
async def test_delegation_create_unauthenticated_rejected():
"""TEST 10: POST /delegations/ without auth → 401 or 403."""
from httpx import AsyncClient as RealAsyncClient, ASGITransport
from gsap_broker.app import app
async with RealAsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
resp = await c.post("/delegations/", json={
"delegator_ac_id": "test", "agent_type": "claude-code",
})
assert resp.status_code in (401, 403)
# ── Health stays public ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_health_unauthenticated_ok():
"""TEST 16: /health/ remains accessible without auth."""
from httpx import AsyncClient as RealAsyncClient, ASGITransport
from gsap_broker.app import app
async with RealAsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
resp = await c.get("/health/")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
# ── H-6: SQLite permissions ──────────────────────────────────────
@pytest.mark.asyncio
async def test_sqlite_permissions():
"""TEST 21: Database file permissions are 0o600."""
from gsap_broker.db import _restrict_db_permissions
# Create a temp file to test permissions on
import tempfile
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
test_path = f.name
try:
os.chmod(test_path, 0o644) # start with world-readable
assert stat.S_IMODE(os.stat(test_path).st_mode) == 0o644
# Simulate the fix
os.chmod(test_path, stat.S_IRUSR | stat.S_IWUSR)
mode = stat.S_IMODE(os.stat(test_path).st_mode)
assert mode == 0o600, f"Expected 0o600, got {oct(mode)}"
finally:
os.unlink(test_path)

View file

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

316
tests/test_credentials.py Normal file
View file

@ -0,0 +1,316 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Tests for credential resolver, backends, and session connectors."""
import pytest
from datetime import datetime, timedelta, UTC
from gsap_broker.credentials.resolver import (
BasculeCredential,
Credential,
CredentialResolver,
CredentialResolutionError,
KerberosCredential,
NoBackendAvailable,
OAuthCredential,
SSHCertCredential,
)
from gsap_broker.credentials.stub_backend import StubCredentialBackend
from gsap_broker.connectors.base import ConnectorContext
from gsap_broker.connectors.bascule import BasculeConnector
from gsap_broker.connectors.powershell import PowerShellConnector
from gsap_broker.connectors.ansible import AnsibleConnector
from gsap_broker.connectors.orchestrator import WorkflowPlan, WorkflowStep
from gsap_broker.routing.device_router import DeviceRouter, UnknownDevice
AC_CONTEXT = {
"gsap_context_id": "test-ac",
"expires_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
"accord": {"template": "test-ops"},
"principal": {"did": "did:web:test/p/alice"},
}
# ── CredentialResolver ────────────────────────────────────────────
@pytest.mark.asyncio
async def test_resolver_routes_to_correct_backend():
"""TEST 2: Resolver routes each credential type to the stub backend."""
resolver = CredentialResolver()
resolver.register(StubCredentialBackend())
for cred_type, expected_cls in [
("bascule_ac", BasculeCredential),
("kerberos", KerberosCredential),
("oauth", OAuthCredential),
("ssh_cert", SSHCertCredential),
]:
cred = await resolver.resolve(cred_type, "target-1", AC_CONTEXT)
assert isinstance(cred, expected_cls)
assert cred.expires_at is not None
assert not cred.expired
@pytest.mark.asyncio
async def test_resolver_rejects_unknown_type():
"""TEST 3: Unknown credential type raises NoBackendAvailable."""
resolver = CredentialResolver()
resolver.register(StubCredentialBackend())
with pytest.raises(NoBackendAvailable) as exc_info:
await resolver.resolve("quantum_key", "target", AC_CONTEXT)
assert "quantum_key" in str(exc_info.value)
@pytest.mark.asyncio
async def test_bascule_credential_preserves_ac():
"""TEST 4: Bascule credential passes AC through."""
resolver = CredentialResolver()
resolver.register(StubCredentialBackend())
cred = await resolver.resolve("bascule_ac", "target-1", AC_CONTEXT)
assert isinstance(cred, BasculeCredential)
assert cred.authorization_context == AC_CONTEXT
@pytest.mark.asyncio
async def test_stub_credentials_not_expired():
"""TEST 5: Stub backend returns credentials that are not expired."""
resolver = CredentialResolver()
resolver.register(StubCredentialBackend())
for cred_type in ["bascule_ac", "kerberos", "oauth", "ssh_cert"]:
cred = await resolver.resolve(cred_type, "target", AC_CONTEXT)
assert not cred.expired
# ── SessionConnector lifecycle ────────────────────────────────────
@pytest.mark.asyncio
async def test_session_connector_success():
"""TEST 6: Full lifecycle — resolve, connect, execute, disconnect."""
resolver = CredentialResolver()
resolver.register(StubCredentialBackend())
connector = BasculeConnector(credential_resolver=resolver)
ctx = ConnectorContext(gsap_context_id="test-ac")
result = await connector.invoke("run-script", {"target": "node-1"}, ctx)
assert result.success
assert result.data["target"] == "node-1"
assert result.data["command"] == "run-script"
assert result.data["stub"] is True
@pytest.mark.asyncio
async def test_session_connector_missing_target():
"""Session connector requires target parameter."""
resolver = CredentialResolver()
resolver.register(StubCredentialBackend())
connector = BasculeConnector(credential_resolver=resolver)
ctx = ConnectorContext(gsap_context_id="test-ac")
result = await connector.invoke("run-script", {}, ctx)
assert not result.success
assert "target required" in result.error
@pytest.mark.asyncio
async def test_session_connector_transport_failure():
"""TEST 7: Transport failure still calls disconnect (cleanup guarantee)."""
from gsap_broker.connectors.session import SessionTransport, SessionConnector
from gsap_broker.credentials.resolver import Credential
disconnect_called = False
class FailingTransport(SessionTransport):
transport_id = "failing"
async def connect(self, target, credential):
pass
async def execute(self, command, params=None):
raise RuntimeError("Transport exploded")
async def disconnect(self):
nonlocal disconnect_called
disconnect_called = True
async def is_alive(self):
return False
class FailingConnector(SessionConnector):
connector_id = "failing"
corpus_entry_cid = "sha256:test"
credential_type = "bascule_ac"
transport_class = FailingTransport
capability_mask = 1
declared_endpoints = []
resolver = CredentialResolver()
resolver.register(StubCredentialBackend())
connector = FailingConnector(credential_resolver=resolver)
ctx = ConnectorContext(gsap_context_id="test-ac")
result = await connector.invoke("crash", {"target": "node-1"}, ctx)
assert not result.success
assert "exploded" in result.error
assert disconnect_called, "disconnect() must be called even on failure"
# ── PowerShell connector ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_powershell_connector_stub():
"""PowerShell connector lifecycle with stubbed transport."""
resolver = CredentialResolver()
resolver.register(StubCredentialBackend())
connector = PowerShellConnector(credential_resolver=resolver)
ctx = ConnectorContext(gsap_context_id="test-ac")
result = await connector.invoke("Get-Process", {"target": "win-srv-01:5986"}, ctx)
assert result.success
assert result.data["transport"] == "psrp"
assert result.data["target"] == "win-srv-01:5986"
# ── OrchestratorConnector (Ansible) ──────────────────────────────
@pytest.mark.asyncio
async def test_orchestrator_all_steps_succeed():
"""TEST 8: All steps succeed → aggregate success."""
resolver = CredentialResolver()
resolver.register(StubCredentialBackend())
connector = AnsibleConnector(credential_resolver=resolver)
ctx = ConnectorContext(gsap_context_id="test-ac")
result = await connector.invoke(
"playbook",
{"playbook": "site.yml", "targets": ["host-1", "host-2"]},
ctx,
)
assert result.success
assert len(result.data["steps"]) == 1
assert result.data["steps"][0]["success"] is True
@pytest.mark.asyncio
async def test_orchestrator_required_step_fails():
"""TEST 9: Required step fails → early termination, partial results."""
from gsap_broker.connectors.orchestrator import OrchestratorConnector, WorkflowPlan, WorkflowStep
resolver = CredentialResolver()
resolver.register(StubCredentialBackend())
class FailingOrchestrator(OrchestratorConnector):
connector_id = "failing-orch"
corpus_entry_cid = "sha256:test"
capability_mask = 1
declared_endpoints = []
async def plan(self, operation, parameters, context):
return WorkflowPlan(steps=[
WorkflowStep(name="step-1", command="ok", required=True),
WorkflowStep(name="step-2", command="fail", required=True),
WorkflowStep(name="step-3", command="never", required=True),
])
async def execute_step(self, step, context):
if step.command == "fail":
return {"success": False, "error": "boom"}
return {"success": True}
connector = FailingOrchestrator(credential_resolver=resolver)
ctx = ConnectorContext(gsap_context_id="test-ac")
result = await connector.invoke("run", {}, ctx)
assert not result.success
assert result.data["failed_at"] == "step-2"
assert len(result.data["completed"]) == 2 # step-1 succeeded, step-2 failed
# step-3 was never executed
# ── DeviceRouter ──────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_router_api_operation_routes_to_intune():
"""TEST 12: API-mediated operations route to Intune."""
from gsap_broker.connectors.registry import ConnectorRegistry
registry = ConnectorRegistry()
router = DeviceRouter(connector_registry=registry)
ctx = ConnectorContext(gsap_context_id="test-ac")
connector_id, op = await router.route("get_compliance", "dev-1", ctx)
assert connector_id == "intune"
@pytest.mark.asyncio
async def test_router_fleet_operation_routes_to_ansible():
"""Fleet operations route to Ansible."""
from gsap_broker.connectors.registry import ConnectorRegistry
registry = ConnectorRegistry()
router = DeviceRouter(connector_registry=registry)
ctx = ConnectorContext(gsap_context_id="test-ac")
connector_id, op = await router.route("playbook", "fleet", ctx)
assert connector_id == "ansible"
@pytest.mark.asyncio
async def test_router_unknown_device_raises():
"""TEST 13: Unknown device raises clear error."""
from gsap_broker.connectors.registry import ConnectorRegistry
registry = ConnectorRegistry()
router = DeviceRouter(connector_registry=registry)
ctx = ConnectorContext(gsap_context_id="test-ac")
with pytest.raises(UnknownDevice):
await router.route("exec", "mystery-host", ctx)
@pytest.mark.asyncio
async def test_router_invoke_missing_connector():
"""Router invoke returns error when connector not registered."""
from gsap_broker.connectors.registry import ConnectorRegistry
registry = ConnectorRegistry()
router = DeviceRouter(connector_registry=registry)
ctx = ConnectorContext(gsap_context_id="test-ac")
result = await router.invoke("get_compliance", "dev-1", {}, ctx)
assert not result.success
assert "not registered" in result.error
# ── Catalog conditional registration ─────────────────────────────
@pytest.mark.asyncio
async def test_connectors_absent_when_disabled():
"""TEST 15: Optional connectors not in catalog when disabled."""
from httpx import AsyncClient, ASGITransport
from gsap_broker.app import app
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get("/connectors/")
assert resp.status_code == 200
ids = [c["connector_id"] for c in resp.json()]
# With default settings, only echo is registered
assert "bascule" not in ids
assert "powershell" not in ids
assert "ansible" not in ids

205
tests/test_entra_driver.py Normal file
View file

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

@ -0,0 +1,74 @@
# 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": []}

224
tests/test_intune.py Normal file
View file

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

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

68
tests/test_mcp_intune.py Normal file
View file

@ -0,0 +1,68 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Tests for Intune MCP tools."""
import pytest
from unittest.mock import patch
from httpx import AsyncClient, ASGITransport
from gsap_broker.app import app
from gsap_broker.drivers.base import AuthResult
def _mock_auth():
"""Return a mock AuthResult for bypassing auth in tests."""
return AuthResult(
status=AuthResult.STATUS_AUTHORIZED,
principal_did="did:web:test/p/testuser",
token_jti="test-jti",
)
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture(autouse=True)
def bypass_auth():
"""Bypass bearer auth for MCP tests (auth tested separately)."""
from gsap_broker.auth.middleware import verify_bearer
app.dependency_overrides[verify_bearer] = lambda: _mock_auth()
yield
app.dependency_overrides.pop(verify_bearer, None)
@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()

156
tests/test_security.py Normal file
View file

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

180
tests/test_templates.py Normal file
View file

@ -0,0 +1,180 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Tests for the template system — manifest, policy, loader, registry."""
import os
import pytest
from pathlib import Path
from gsap_broker.templates.manifest import TemplateManifest
from gsap_broker.templates.policy import CompliancePolicy
from gsap_broker.templates.loader import TemplateLoader, LoadResult
from gsap_broker.templates.registry import AccordRegistry, PolicyRegistry
FIXTURE_DIR = Path(__file__).parent / "fixtures" / "sample-template"
# ── Manifest parsing ─────────────────────────────────────────────
def test_parse_full_manifest():
"""TEST 2: Full bastion.toml parsed correctly."""
m = TemplateManifest.from_toml(FIXTURE_DIR / "bastion.toml")
assert m.template.name == "test-baseline"
assert m.template.version == "0.1.0"
assert m.template.vertical == "testing"
assert "test-framework" in m.template.compliance_frameworks
assert m.compatibility.bastion_min == "0.3.0"
assert "intune" in m.compatibility.connectors_required
assert "org_name" in m.variables
assert m.variables["org_name"].required is True
assert m.variables["api_key"].sensitive is True
def test_parse_minimal_manifest(tmp_path):
"""TEST 3: Minimal bastion.toml with only required fields."""
toml = tmp_path / "bastion.toml"
toml.write_text('[template]\nname = "minimal"\nversion = "0.1.0"\n')
m = TemplateManifest.from_toml(toml)
assert m.template.name == "minimal"
assert m.compatibility.bastion_min == "0.5.0" # default
assert m.contents.policies == "policies/" # default
def test_validate_variables_missing_required():
"""TEST 4: Missing required variable produces error."""
m = TemplateManifest.from_toml(FIXTURE_DIR / "bastion.toml")
errors = m.validate_variables({}) # org_name missing
assert any("org_name" in e for e in errors)
def test_validate_variables_all_provided():
"""TEST 5: All required variables provided — no errors."""
m = TemplateManifest.from_toml(FIXTURE_DIR / "bastion.toml")
errors = m.validate_variables({"org_name": "TestCorp"})
assert errors == []
# ── Policy parsing ───────────────────────────────────────────────
def test_parse_policy():
"""TEST 6: Multi-condition policy parsed correctly."""
p = CompliancePolicy.from_toml(FIXTURE_DIR / "policies" / "test-workstation.toml")
assert p.name == "test-workstation-policy"
assert p.framework == "test-framework"
assert len(p.conditions) == 2
assert p.conditions[0].id == "disk-encryption"
assert p.conditions[0].severity == "critical"
assert p.breach_response.critical == "suspend_access"
assert p.schedule.interval_seconds == 300
def test_policy_platform_filtering():
"""TEST 7: conditions_for_platform filters correctly."""
p = CompliancePolicy.from_toml(FIXTURE_DIR / "policies" / "test-workstation.toml")
linux_conds = p.conditions_for_platform("linux")
windows_conds = p.conditions_for_platform("windows")
# disk-encryption has linux check, antivirus doesn't
assert len(linux_conds) == 1
assert linux_conds[0].id == "disk-encryption"
# Both conditions have windows checks
assert len(windows_conds) == 2
# ── Template loader ──────────────────────────────────────────────
def test_template_loader_full():
"""TEST 8: Full template load — policies and accords registered."""
policies = PolicyRegistry()
accords = AccordRegistry()
loader = TemplateLoader(policies, accords)
result = loader.load(FIXTURE_DIR, {"org_name": "TestCorp"})
assert result.success
assert "test-workstation-policy" in result.policies_loaded
assert "standard-operations" in result.accords_loaded
assert policies.get("test-workstation-policy") is not None
assert accords.get("standard-operations") is not None
def test_variable_substitution_bastion_files_only():
"""TEST 9: Variable substitution applies to Bastion files, not Ansible files."""
policies = PolicyRegistry()
accords = AccordRegistry()
loader = TemplateLoader(policies, accords)
loader.load(FIXTURE_DIR, {"org_name": "AcmeCorp"})
# Policy file should have substitution applied
policy = policies.get("test-workstation-policy")
assert policy is not None
assert "AcmeCorp" in policy.description
# Ansible playbook should be UNTOUCHED
playbook_path = FIXTURE_DIR / "ansible" / "playbooks" / "test-playbook.yml"
content = playbook_path.read_text()
assert "${org_name}" in content # NOT substituted
def test_variable_substitution_sensitive_not_logged(caplog):
"""TEST 10: Sensitive variable values not in log output."""
import logging
policies = PolicyRegistry()
accords = AccordRegistry()
loader = TemplateLoader(policies, accords)
with caplog.at_level(logging.DEBUG):
loader.load(FIXTURE_DIR, {"org_name": "TestCorp", "api_key": "SECRET_VALUE_123"})
# The sensitive value should not appear in logs
assert "SECRET_VALUE_123" not in caplog.text
# Non-sensitive org_name value DOES appear (confirms substitution ran)
assert "TestCorp" in caplog.text
def test_missing_bastion_toml(tmp_path):
"""Missing bastion.toml produces error, not crash."""
policies = PolicyRegistry()
accords = AccordRegistry()
loader = TemplateLoader(policies, accords)
result = loader.load(tmp_path, {})
assert not result.success
assert any("bastion.toml" in e for e in result.errors)
# ── Registries ───────────────────────────────────────────────────
def test_accord_registry_builtin_fallback():
"""TEST 11: AccordRegistry falls back to builtins for known accords."""
reg = AccordRegistry()
# shell-exec is a builtin
assert reg.get("shell-exec") is not None
assert reg.get("shell-exec")["capability_ceiling"] == "CAP_MUTATE"
# unknown returns None
assert reg.get("nonexistent") is None
def test_accord_registry_template_overrides_builtin():
"""Template-loaded accord overrides builtin with same name."""
reg = AccordRegistry()
reg.register("shell-exec", {"name": "shell-exec", "custom": True})
assert reg.get("shell-exec")["custom"] is True
def test_policy_registry_by_framework():
"""PolicyRegistry.for_framework filters correctly."""
reg = PolicyRegistry()
p1 = CompliancePolicy(name="p1", framework="hipaa")
p2 = CompliancePolicy(name="p2", framework="pci")
p3 = CompliancePolicy(name="p3", framework="hipaa")
reg.register(p1)
reg.register(p2)
reg.register(p3)
hipaa = reg.for_framework("hipaa")
assert len(hipaa) == 2
assert all(p.framework == "hipaa" for p in hipaa)

147
uv.lock
View file

@ -121,6 +121,95 @@ 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"
@ -253,6 +342,9 @@ dev = [
{ name = "pytest-mock" },
{ name = "ruff" },
]
entra = [
{ name = "msal" },
]
[package.metadata]
requires-dist = [
@ -260,6 +352,7 @@ 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" },
@ -271,7 +364,7 @@ requires-dist = [
{ name = "structlog", specifier = ">=24.1.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" },
]
provides-extras = ["dev"]
provides-extras = ["dev", "entra"]
[[package]]
name = "greenlet"
@ -416,6 +509,20 @@ 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"
@ -587,6 +694,20 @@ 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"
@ -711,6 +832,21 @@ 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"
@ -866,6 +1002,15 @@ 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"