Compare commits
21 commits
main
...
feat/basti
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
782f5654ac | ||
|
|
85afbd8d61 | ||
|
|
77964e4042 | ||
|
|
d62974f1b7 | ||
|
|
f82000e0f6 | ||
|
|
e744336385 | ||
|
|
5015f3dd43 | ||
|
|
4dff879c84 | ||
|
|
5adc55aff5 | ||
|
|
2ac5aa3b85 | ||
|
|
eee8740ce8 | ||
|
|
5a759f5e12 | ||
|
|
24eefe1699 | ||
|
|
043693652a | ||
|
|
1d24019544 | ||
|
|
6cfe5f7d9a | ||
|
|
e24a87db6f | ||
|
|
03a99b4aff | ||
|
|
871541f0eb | ||
|
|
8196396ce6 | ||
|
|
1ab47417c9 |
68 changed files with 6346 additions and 162 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -6,3 +6,4 @@ dist/
|
|||
*.egg-info/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
|
|
|
|||
86
INTUNE.md
Normal file
86
INTUNE.md
Normal 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
366
ROADMAP.md
Normal 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.
|
||||
13
ansible_collection/guildhouse/bastion/galaxy.yml
Normal file
13
ansible_collection/guildhouse/bastion/galaxy.yml
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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}/")
|
||||
0
ansible_collection/guildhouse/bastion/tests/__init__.py
Normal file
0
ansible_collection/guildhouse/bastion/tests/__init__.py
Normal 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",
|
||||
|
|
|
|||
2
gsap_broker/auth/__init__.py
Normal file
2
gsap_broker/auth/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
175
gsap_broker/auth/middleware.py
Normal file
175
gsap_broker/auth/middleware.py
Normal 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)
|
||||
137
gsap_broker/connectors/ansible.py
Normal file
137
gsap_broker/connectors/ansible.py
Normal 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,
|
||||
}
|
||||
87
gsap_broker/connectors/bascule.py
Normal file
87
gsap_broker/connectors/bascule.py
Normal 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)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
213
gsap_broker/connectors/intune.py
Normal file
213
gsap_broker/connectors/intune.py
Normal 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}")
|
||||
112
gsap_broker/connectors/orchestrator.py
Normal file
112
gsap_broker/connectors/orchestrator.py
Normal 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
|
||||
88
gsap_broker/connectors/powershell.py
Normal file
88
gsap_broker/connectors/powershell.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
138
gsap_broker/connectors/session.py
Normal file
138
gsap_broker/connectors/session.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Session-based connector framework.
|
||||
|
||||
Session connectors establish stateful connections to target endpoints
|
||||
(SSH, WinRM/PSRP, Shellstream/Bascule) and execute commands over them.
|
||||
|
||||
Credential lifecycle:
|
||||
1. ``SessionConnector.invoke()`` is called with an operation and
|
||||
target.
|
||||
2. The connector calls ``CredentialResolver.resolve()`` to acquire
|
||||
a short-lived, scoped credential for that target.
|
||||
3. The credential is passed to ``SessionTransport.connect()`` which
|
||||
uses it to establish the session.
|
||||
4. The command is executed via ``SessionTransport.execute()``.
|
||||
5. ``SessionTransport.disconnect()`` is called in a finally block
|
||||
— guaranteed even on failure.
|
||||
6. The credential goes out of scope and is garbage-collected.
|
||||
No reference is stored anywhere in the broker.
|
||||
|
||||
Rust port note:
|
||||
``SessionTransport`` maps to an async trait with an associated
|
||||
error type. ``SessionConnector`` becomes a generic struct
|
||||
parameterized by the transport type. The finally-block cleanup
|
||||
maps to Drop + an async shutdown method (or a wrapper that calls
|
||||
disconnect on drop via ``tokio::spawn``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Optional, Type
|
||||
|
||||
from gsap_broker.connectors.base import ConnectorContext, ConnectorPlugin, ConnectorResult
|
||||
from gsap_broker.credentials.resolver import Credential, CredentialResolver
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SessionTransport(ABC):
|
||||
"""A stateful connection to a target endpoint.
|
||||
|
||||
Implementations wrap protocol-specific clients:
|
||||
- ``BasculeTransport``: Shellstream via Bascule proxy
|
||||
- ``PowerShellTransport``: PSRP via pypsrp
|
||||
- ``SSHTransport``: SSH via asyncssh (future)
|
||||
|
||||
Transports are ephemeral — created per invocation, not pooled.
|
||||
"""
|
||||
|
||||
transport_id: str = ""
|
||||
|
||||
@abstractmethod
|
||||
async def connect(self, target: str, credential: Credential) -> None:
|
||||
"""Establish the session using the provided credential."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, command: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||||
"""Execute a command over the established session.
|
||||
|
||||
Returns a dict with at minimum ``stdout``, ``stderr``,
|
||||
``exit_code`` keys (for shell transports) or
|
||||
transport-specific structured output.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def disconnect(self) -> None:
|
||||
"""Tear down the session. MUST be idempotent."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def is_alive(self) -> bool:
|
||||
"""Check if the session is still usable."""
|
||||
...
|
||||
|
||||
|
||||
class SessionConnector(ConnectorPlugin):
|
||||
"""Base for connectors that establish sessions to endpoints.
|
||||
|
||||
Subclasses set ``credential_type`` and ``transport_class``
|
||||
to wire the connector to a specific transport and credential
|
||||
backend.
|
||||
|
||||
The ``invoke()`` method handles the full lifecycle:
|
||||
credential acquisition → transport connect → execute →
|
||||
disconnect, with guaranteed cleanup on failure.
|
||||
"""
|
||||
|
||||
credential_type: str = ""
|
||||
transport_class: Type[SessionTransport] = SessionTransport # overridden by subclass
|
||||
|
||||
def __init__(self, credential_resolver: CredentialResolver):
|
||||
self._resolver = credential_resolver
|
||||
|
||||
async def invoke(
|
||||
self, operation: str, parameters: dict[str, Any], context: ConnectorContext
|
||||
) -> ConnectorResult:
|
||||
target = parameters.get("target", "")
|
||||
if not target:
|
||||
return ConnectorResult(success=False, error="target required for session connector")
|
||||
|
||||
# Build an AC-like context dict for the resolver.
|
||||
ac_context = {
|
||||
"gsap_context_id": context.gsap_context_id,
|
||||
"accord": {"template": getattr(self, "accord_template", "")},
|
||||
}
|
||||
|
||||
try:
|
||||
credential = await self._resolver.resolve(
|
||||
self.credential_type, target, ac_context
|
||||
)
|
||||
except Exception as e:
|
||||
return ConnectorResult(success=False, error=f"Credential resolution failed: {e}")
|
||||
|
||||
transport = self.transport_class()
|
||||
try:
|
||||
await transport.connect(target, credential)
|
||||
result = await transport.execute(operation, parameters)
|
||||
return ConnectorResult(success=True, data=result)
|
||||
except Exception as e:
|
||||
logger.error("Session connector %s failed: %s", self.connector_id, e)
|
||||
return ConnectorResult(success=False, error=str(e))
|
||||
finally:
|
||||
try:
|
||||
await transport.disconnect()
|
||||
except Exception as cleanup_err:
|
||||
logger.warning(
|
||||
"Transport disconnect failed for %s: %s",
|
||||
self.connector_id,
|
||||
cleanup_err,
|
||||
)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
return True
|
||||
2
gsap_broker/credentials/__init__.py
Normal file
2
gsap_broker/credentials/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
163
gsap_broker/credentials/entra_backend.py
Normal file
163
gsap_broker/credentials/entra_backend.py
Normal 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."
|
||||
)
|
||||
235
gsap_broker/credentials/resolver.py
Normal file
235
gsap_broker/credentials/resolver.py
Normal 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
|
||||
84
gsap_broker/credentials/stub_backend.py
Normal file
84
gsap_broker/credentials/stub_backend.py
Normal 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)
|
||||
|
|
@ -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():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
# Schema migrations for existing DBs:
|
||||
try:
|
||||
await conn.execute(
|
||||
__import__("sqlalchemy").text("ALTER TABLE authorization_contexts ADD COLUMN session_mode BOOLEAN DEFAULT 0")
|
||||
)
|
||||
except Exception:
|
||||
pass # Column already exists
|
||||
# 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
|
||||
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(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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,37 +53,34 @@ 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)
|
||||
if resp.status_code == 401:
|
||||
headers = await self._headers()
|
||||
resp = await http.post(f"{GRAPH_API}/applications", json=app_body, headers=headers)
|
||||
resp.raise_for_status()
|
||||
resp = await self.graph.post("/applications", body=app_body)
|
||||
if resp.status_code == 401:
|
||||
# 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"]
|
||||
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={
|
||||
"passwordCredential": {
|
||||
"displayName": f"delegation-{delegation_id}",
|
||||
"endDateTime": expires_at,
|
||||
}
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
secret_resp.raise_for_status()
|
||||
client_secret = secret_resp.json().get("secretText", "")
|
||||
secret_resp = await self.graph.post(
|
||||
f"/applications/{object_id}/addPassword",
|
||||
body={
|
||||
"passwordCredential": {
|
||||
"displayName": f"delegation-{delegation_id}",
|
||||
"endDateTime": expires_at,
|
||||
}
|
||||
},
|
||||
)
|
||||
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,
|
||||
)
|
||||
if sp_resp.status_code not in (200, 201, 409):
|
||||
sp_resp.raise_for_status()
|
||||
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()
|
||||
|
||||
logger.info("Entra: registered agent %s (appId=%s)", display_name, app_id)
|
||||
return AgentCredentials(
|
||||
|
|
@ -123,28 +91,22 @@ 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", [])
|
||||
if not apps:
|
||||
return False
|
||||
except Exception:
|
||||
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)
|
||||
if deleted:
|
||||
logger.info("Entra: deleted agent app %s", client_id)
|
||||
return deleted
|
||||
apps = data.get("value", [])
|
||||
if not apps:
|
||||
return False
|
||||
|
||||
object_id = apps[0]["id"]
|
||||
deleted = await self.graph.delete(f"/applications/{object_id}")
|
||||
if deleted:
|
||||
logger.info("Entra: deleted agent app %s", client_id)
|
||||
return deleted
|
||||
|
||||
async def get_agent_token(self, client_id: str) -> str | None:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)},
|
||||
)
|
||||
# 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)},
|
||||
)
|
||||
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"
|
||||
)
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
110
gsap_broker/drivers/entra.py
Normal file
110
gsap_broker/drivers/entra.py
Normal 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
137
gsap_broker/drivers/jwks.py
Normal 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
|
||||
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
gsap_broker/intune/__init__.py
Normal file
2
gsap_broker/intune/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
39
gsap_broker/intune/device_cache.py
Normal file
39
gsap_broker/intune/device_cache.py
Normal 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)
|
||||
103
gsap_broker/intune/graph_client.py
Normal file
103
gsap_broker/intune/graph_client.py
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
28
gsap_broker/models/intune.py
Normal file
28
gsap_broker/models/intune.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
2
gsap_broker/routing/__init__.py
Normal file
2
gsap_broker/routing/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
159
gsap_broker/routing/device_router.py
Normal file
159
gsap_broker/routing/device_router.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
2
gsap_broker/templates/__init__.py
Normal file
2
gsap_broker/templates/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
181
gsap_broker/templates/loader.py
Normal file
181
gsap_broker/templates/loader.py
Normal 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
|
||||
98
gsap_broker/templates/manifest.py
Normal file
98
gsap_broker/templates/manifest.py
Normal 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
|
||||
88
gsap_broker/templates/policy.py
Normal file
88
gsap_broker/templates/policy.py
Normal 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
|
||||
112
gsap_broker/templates/registry.py
Normal file
112
gsap_broker/templates/registry.py
Normal 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())
|
||||
6
tests/fixtures/sample-template/accords/standard-operations.toml
vendored
Normal file
6
tests/fixtures/sample-template/accords/standard-operations.toml
vendored
Normal 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}"
|
||||
8
tests/fixtures/sample-template/ansible/playbooks/test-playbook.yml
vendored
Normal file
8
tests/fixtures/sample-template/ansible/playbooks/test-playbook.yml
vendored
Normal 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:
|
||||
29
tests/fixtures/sample-template/bastion.toml
vendored
Normal file
29
tests/fixtures/sample-template/bastion.toml
vendored
Normal 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
|
||||
37
tests/fixtures/sample-template/policies/test-workstation.toml
vendored
Normal file
37
tests/fixtures/sample-template/policies/test-workstation.toml
vendored
Normal 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
|
||||
168
tests/test_ansible_collection.py
Normal file
168
tests/test_ansible_collection.py
Normal 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
|
||||
233
tests/test_auth_middleware.py
Normal file
233
tests/test_auth_middleware.py
Normal 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)
|
||||
210
tests/test_compliance_gate.py
Normal file
210
tests/test_compliance_gate.py
Normal 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
316
tests/test_credentials.py
Normal 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
205
tests/test_entra_driver.py
Normal 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
|
||||
74
tests/test_graph_client.py
Normal file
74
tests/test_graph_client.py
Normal 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
224
tests/test_intune.py
Normal 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
|
||||
173
tests/test_keycloak_driver.py
Normal file
173
tests/test_keycloak_driver.py
Normal 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
68
tests/test_mcp_intune.py
Normal 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
156
tests/test_security.py
Normal 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
180
tests/test_templates.py
Normal 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
147
uv.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue