Compare commits
No commits in common. "782f5654acf903892a674097387a8dd521c2f516baa9ce29dde64ea6533a5392" and "eee8740ce85d7c3980826985776d3512b2026f28982c35fed91cac0d9b314cdb" have entirely different histories.
782f5654ac
...
eee8740ce8
51 changed files with 278 additions and 4118 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -6,4 +6,3 @@ dist/
|
|||
*.egg-info/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
|
|
|
|||
366
ROADMAP.md
366
ROADMAP.md
|
|
@ -1,366 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
# 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)
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
# 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}/")
|
||||
|
|
@ -25,9 +25,6 @@ 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",
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
# 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)
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Ansible connector — fleet management via Ansible playbooks.
|
||||
|
||||
The Ansible connector is an OrchestratorConnector: it plans a
|
||||
sequence of steps (playbook runs, ad-hoc commands, fact collection)
|
||||
and executes them via ansible-runner.
|
||||
|
||||
Credential model:
|
||||
Ansible does NOT receive credentials from the broker. Instead,
|
||||
the connector passes a credential callback that ansible-runner's
|
||||
credential plugin calls at runtime. This callback resolves
|
||||
credentials via the broker's CredentialResolver for each target
|
||||
host at the moment the task runs — not at planning time.
|
||||
|
||||
This means:
|
||||
- The Ansible inventory file contains NO passwords or keys.
|
||||
- Each host's credentials are resolved just-in-time from the
|
||||
secrets backend (Entra, Vault, etc).
|
||||
- If a credential expires mid-playbook, the next task for that
|
||||
host will resolve a fresh credential.
|
||||
|
||||
Real integration:
|
||||
Library: ``ansible-runner`` (execution library)
|
||||
Auth: Per-host credential resolution via custom credential plugin
|
||||
Inventory: Dynamic inventory from Intune device cache or Bascule
|
||||
fleet registry
|
||||
|
||||
Stubbed in this sprint — steps return placeholder results.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from gsap_broker.connectors.base import ConnectorContext
|
||||
from gsap_broker.connectors.orchestrator import (
|
||||
OrchestratorConnector,
|
||||
WorkflowPlan,
|
||||
WorkflowStep,
|
||||
)
|
||||
from gsap_broker.credentials.resolver import CredentialResolver
|
||||
|
||||
|
||||
class AnsibleConnector(OrchestratorConnector):
|
||||
"""Fleet management via Ansible playbooks."""
|
||||
|
||||
connector_id = "ansible"
|
||||
corpus_entry_cid = "sha256:ansible-connector-v1"
|
||||
capability_mask = 0x7 # READ | PROPOSE | MUTATE
|
||||
declared_endpoints = ["ansible://*"]
|
||||
accord_template = "fleet-management"
|
||||
gsap_required = True
|
||||
chronicle_enabled = True
|
||||
|
||||
def __init__(self, credential_resolver: CredentialResolver):
|
||||
super().__init__(credential_resolver)
|
||||
|
||||
async def plan(
|
||||
self, operation: str, parameters: dict[str, Any], context: ConnectorContext
|
||||
) -> WorkflowPlan:
|
||||
"""Map operations to workflow plans.
|
||||
|
||||
Operations:
|
||||
"playbook" → single step running the named playbook
|
||||
"adhoc" → single step running a module on targets
|
||||
"collect" → single step gathering facts
|
||||
"role" → single step applying a role
|
||||
"""
|
||||
targets = parameters.get("targets", [])
|
||||
if isinstance(targets, str):
|
||||
targets = [targets]
|
||||
|
||||
if operation == "playbook":
|
||||
return WorkflowPlan(steps=[
|
||||
WorkflowStep(
|
||||
name=f"playbook:{parameters.get('playbook', 'site.yml')}",
|
||||
command=parameters.get("playbook", "site.yml"),
|
||||
targets=targets,
|
||||
extra_vars=parameters.get("extra_vars", {}),
|
||||
)
|
||||
])
|
||||
|
||||
if operation == "adhoc":
|
||||
return WorkflowPlan(steps=[
|
||||
WorkflowStep(
|
||||
name=f"adhoc:{parameters.get('module', 'ping')}",
|
||||
command=parameters.get("module", "ping"),
|
||||
targets=targets,
|
||||
extra_vars=parameters.get("args", {}),
|
||||
)
|
||||
])
|
||||
|
||||
if operation == "collect":
|
||||
return WorkflowPlan(steps=[
|
||||
WorkflowStep(
|
||||
name="collect:facts",
|
||||
command="setup",
|
||||
targets=targets,
|
||||
required=False, # fact collection is best-effort
|
||||
)
|
||||
])
|
||||
|
||||
if operation == "role":
|
||||
return WorkflowPlan(steps=[
|
||||
WorkflowStep(
|
||||
name=f"role:{parameters.get('role', '')}",
|
||||
command=parameters.get("role", ""),
|
||||
targets=targets,
|
||||
extra_vars=parameters.get("extra_vars", {}),
|
||||
)
|
||||
])
|
||||
|
||||
return WorkflowPlan(steps=[
|
||||
WorkflowStep(name=f"unknown:{operation}", command=operation, targets=targets)
|
||||
])
|
||||
|
||||
async def execute_step(
|
||||
self, step: WorkflowStep, context: ConnectorContext
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a single workflow step via ansible-runner.
|
||||
|
||||
Stubbed — actual ansible-runner integration in a future sprint.
|
||||
"""
|
||||
# TODO: use ansible_runner.run() with:
|
||||
# - playbook=step.command (for playbook operation)
|
||||
# - module=step.command, module_args=step.extra_vars (for adhoc)
|
||||
# - inventory from dynamic source (Intune cache, Bascule fleet)
|
||||
# - credential plugin that calls self._resolver.resolve()
|
||||
# per-host at runtime
|
||||
return {
|
||||
"success": True,
|
||||
"stub": True,
|
||||
"step": step.name,
|
||||
"targets": step.targets,
|
||||
}
|
||||
|
|
@ -7,12 +7,6 @@ from abc import ABC, abstractmethod
|
|||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# Fix C-6: capability constants for operation-level enforcement
|
||||
CAP_READ = 0x1
|
||||
CAP_PROPOSE = 0x2
|
||||
CAP_MUTATE = 0x4
|
||||
CAP_ADMIN = 0x8
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectorContext:
|
||||
|
|
@ -21,9 +15,6 @@ class ConnectorContext:
|
|||
credentials: dict[str, Any] = field(default_factory=dict)
|
||||
pipeline_run_id: str = ""
|
||||
dag_id: str = ""
|
||||
# Fix C-6: capability_mask from the AC, enforced by ConnectorRuntime
|
||||
capability_mask: int = 0
|
||||
principal_did: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -45,13 +36,6 @@ class ConnectorPlugin(ABC):
|
|||
accord_template: str = ""
|
||||
gsap_required: bool = True
|
||||
chronicle_enabled: bool = True
|
||||
# Fix C-6: per-operation capability requirements
|
||||
operation_capabilities: dict[str, int] = {}
|
||||
|
||||
def capability_for_operation(self, operation: str) -> int:
|
||||
"""Return the capability mask required for an operation.
|
||||
Defaults to CAP_READ if not explicitly mapped."""
|
||||
return self.operation_capabilities.get(operation, CAP_READ)
|
||||
|
||||
@abstractmethod
|
||||
async def invoke(
|
||||
|
|
|
|||
|
|
@ -14,12 +14,7 @@ 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.connectors.base import 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
|
||||
|
|
@ -28,21 +23,6 @@ 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"
|
||||
|
|
@ -54,16 +34,6 @@ class IntuneConnector(ConnectorPlugin):
|
|||
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
|
||||
|
|
@ -124,20 +94,18 @@ class IntuneConnector(ConnectorPlugin):
|
|||
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))
|
||||
device_id = params.get("device_id", "")
|
||||
if not device_id:
|
||||
return ConnectorResult(success=False, error="device_id required")
|
||||
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))
|
||||
device_id = params.get("device_id", "")
|
||||
if not device_id:
|
||||
return ConnectorResult(success=False, error="device_id required")
|
||||
|
||||
# Check cache first
|
||||
cached = await self.cache.get(device_id)
|
||||
|
|
@ -164,10 +132,9 @@ class IntuneConnector(ConnectorPlugin):
|
|||
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))
|
||||
device_id = params.get("device_id", "")
|
||||
if not device_id:
|
||||
return ConnectorResult(success=False, error="device_id required")
|
||||
resp = await self.graph.post(f"{_GRAPH_DEVICES}/{device_id}/syncDevice")
|
||||
if resp.status_code in (200, 204):
|
||||
await self.cache.invalidate(device_id)
|
||||
|
|
@ -179,10 +146,9 @@ class IntuneConnector(ConnectorPlugin):
|
|||
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))
|
||||
device_id = params.get("device_id", "")
|
||||
if not device_id:
|
||||
return ConnectorResult(success=False, error="device_id required")
|
||||
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})
|
||||
|
|
@ -191,10 +157,9 @@ class IntuneConnector(ConnectorPlugin):
|
|||
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))
|
||||
device_id = params.get("device_id", "")
|
||||
if not device_id:
|
||||
return ConnectorResult(success=False, error="device_id required")
|
||||
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})
|
||||
|
|
@ -203,10 +168,9 @@ class IntuneConnector(ConnectorPlugin):
|
|||
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))
|
||||
device_id = params.get("device_id", "")
|
||||
if not device_id:
|
||||
return ConnectorResult(success=False, error="device_id required")
|
||||
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})
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Orchestrator connector framework — multi-step workflow execution.
|
||||
|
||||
Orchestrator connectors manage workflows that span multiple steps
|
||||
and potentially multiple targets. Each step may acquire its own
|
||||
credentials via the CredentialResolver.
|
||||
|
||||
Unlike ``SessionConnector`` (single target, single credential,
|
||||
single command), an ``OrchestratorConnector``:
|
||||
- Plans a sequence of steps before execution
|
||||
- Executes steps in order, stopping on required-step failure
|
||||
- Reports partial results (which steps completed before failure)
|
||||
- Can target different hosts per step
|
||||
|
||||
Rust port note:
|
||||
``WorkflowStep`` and ``WorkflowPlan`` map to plain structs.
|
||||
``OrchestratorConnector`` maps to an async trait with
|
||||
``plan()`` and ``execute_step()`` methods.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from gsap_broker.connectors.base import ConnectorContext, ConnectorPlugin, ConnectorResult
|
||||
from gsap_broker.credentials.resolver import CredentialResolver
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowStep:
|
||||
"""A single step in a workflow plan."""
|
||||
|
||||
name: str
|
||||
command: str
|
||||
targets: list[str] = field(default_factory=list)
|
||||
required: bool = True
|
||||
extra_vars: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowPlan:
|
||||
"""Ordered sequence of steps for a workflow."""
|
||||
|
||||
steps: list[WorkflowStep] = field(default_factory=list)
|
||||
|
||||
|
||||
class OrchestratorConnector(ConnectorPlugin):
|
||||
"""Base for multi-step workflow connectors (Ansible, Terraform, etc).
|
||||
|
||||
Subclasses implement ``plan()`` to convert an operation + parameters
|
||||
into a ``WorkflowPlan``, and ``execute_step()`` to run each step.
|
||||
|
||||
The base ``invoke()`` handles:
|
||||
- Planning the workflow
|
||||
- Executing steps in order
|
||||
- Stopping on required-step failure
|
||||
- Aggregating results with partial-completion reporting
|
||||
"""
|
||||
|
||||
def __init__(self, credential_resolver: CredentialResolver):
|
||||
self._resolver = credential_resolver
|
||||
|
||||
async def invoke(
|
||||
self, operation: str, parameters: dict[str, Any], context: ConnectorContext
|
||||
) -> ConnectorResult:
|
||||
try:
|
||||
plan = await self.plan(operation, parameters, context)
|
||||
except Exception as e:
|
||||
return ConnectorResult(success=False, error=f"Planning failed: {e}")
|
||||
|
||||
if not plan.steps:
|
||||
return ConnectorResult(success=True, data={"steps": []})
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for step in plan.steps:
|
||||
result = await self.execute_step(step, context)
|
||||
results.append({"step": step.name, **result})
|
||||
|
||||
if not result.get("success") and step.required:
|
||||
return ConnectorResult(
|
||||
success=False,
|
||||
data={"completed": results, "failed_at": step.name},
|
||||
)
|
||||
|
||||
return ConnectorResult(success=True, data={"steps": results})
|
||||
|
||||
@abstractmethod
|
||||
async def plan(
|
||||
self, operation: str, parameters: dict[str, Any], context: ConnectorContext
|
||||
) -> WorkflowPlan:
|
||||
"""Convert an operation into a step-by-step execution plan."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def execute_step(
|
||||
self, step: WorkflowStep, context: ConnectorContext
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a single workflow step.
|
||||
|
||||
Returns a dict with at minimum a ``success: bool`` key.
|
||||
"""
|
||||
...
|
||||
|
||||
def health_check(self) -> bool:
|
||||
return True
|
||||
|
|
@ -1,25 +1,11 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""ConnectorRuntime — governed invocation with Chronicle emission.
|
||||
|
||||
Fix C-6: capability_mask enforcement per operation.
|
||||
Fix C-7: AC validated against database before invocation.
|
||||
Fix H-4: Chronicle INTENT emitted before execution.
|
||||
"""
|
||||
"""ConnectorRuntime — governed invocation with Chronicle emission."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, UTC
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import text
|
||||
from typing import Any
|
||||
|
||||
from .base import ConnectorContext, ConnectorResult
|
||||
from .registry import ConnectorRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConnectorRuntime:
|
||||
def __init__(
|
||||
|
|
@ -39,49 +25,16 @@ class ConnectorRuntime:
|
|||
if connector is None:
|
||||
return ConnectorResult(success=False, error=f"Unknown connector: {connector_id}")
|
||||
|
||||
# Fix C-7: validate AC exists and is active
|
||||
if connector.gsap_required:
|
||||
if not context.gsap_context_id:
|
||||
return ConnectorResult(success=False, error="GSAP context required")
|
||||
ac_valid = await self._validate_ac(context.gsap_context_id)
|
||||
if not ac_valid:
|
||||
return ConnectorResult(
|
||||
success=False,
|
||||
error=f"AC '{context.gsap_context_id}' not found, expired, or consumed",
|
||||
)
|
||||
|
||||
# Fix C-6: enforce capability_mask (only for governed connectors)
|
||||
required_cap = connector.capability_for_operation(operation) if connector.gsap_required else 0
|
||||
if required_cap and not (context.capability_mask & required_cap):
|
||||
cap_names = {0x1: "READ", 0x2: "PROPOSE", 0x4: "MUTATE", 0x8: "ADMIN"}
|
||||
return ConnectorResult(
|
||||
success=False,
|
||||
error=f"Operation '{operation}' requires {cap_names.get(required_cap, hex(required_cap))} "
|
||||
f"capability, AC has mask={context.capability_mask}",
|
||||
)
|
||||
|
||||
# Fix H-4: emit Chronicle INTENT before execution
|
||||
if connector.chronicle_enabled and self.chronicle_client is not None:
|
||||
try:
|
||||
await self.chronicle_client.emit(
|
||||
"CONNECTOR_INVOCATION_INTENT",
|
||||
{
|
||||
"connector_id": connector_id,
|
||||
"operation": operation,
|
||||
"principal_did": context.principal_did,
|
||||
"gsap_context_id": context.gsap_context_id,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass # Chronicle failure must not block invocation
|
||||
if connector.gsap_required and not context.gsap_context_id:
|
||||
return ConnectorResult(success=False, error="GSAP context required but not provided")
|
||||
|
||||
result = await connector.invoke(operation, parameters, context)
|
||||
|
||||
# Emit Chronicle RESULT after execution
|
||||
# Emit Chronicle event
|
||||
if connector.chronicle_enabled and self.chronicle_client is not None:
|
||||
try:
|
||||
cid = await self.chronicle_client.emit(
|
||||
"CONNECTOR_INVOCATION_RESULT",
|
||||
"CONNECTOR_INVOKED",
|
||||
{
|
||||
"connector_id": connector_id,
|
||||
"operation": operation,
|
||||
|
|
@ -98,39 +51,3 @@ class ConnectorRuntime:
|
|||
pass # Chronicle failure must not break invocation
|
||||
|
||||
return result
|
||||
|
||||
async def _validate_ac(self, context_id: str) -> bool:
|
||||
"""Fix C-7: validate AC exists and is active in the database."""
|
||||
# Skip validation for internal context IDs (compliance gate)
|
||||
if context_id == "compliance-gate":
|
||||
return True
|
||||
try:
|
||||
from gsap_broker.db import engine
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.execute(
|
||||
text(
|
||||
"SELECT status, expires_at FROM authorization_contexts "
|
||||
"WHERE context_id = :ctx_id"
|
||||
),
|
||||
{"ctx_id": context_id.replace("-", "")},
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
return False
|
||||
status = row[0]
|
||||
if status not in ("authorized", "active"):
|
||||
return False
|
||||
# Check expiry
|
||||
expires_str = row[1]
|
||||
if expires_str:
|
||||
try:
|
||||
expires = datetime.fromisoformat(str(expires_str))
|
||||
if expires < datetime.now(UTC).replace(tzinfo=None):
|
||||
return False
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("AC validation failed: %s", e)
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -148,16 +148,27 @@ class EntraCredentialBackend(CredentialBackend):
|
|||
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:
|
||||
Stubbed — 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
|
||||
- Direct KDC: use kinit with the OBO token (requires
|
||||
krb5.conf pointing to the right KDC)
|
||||
|
||||
Implementation deferred to hybrid environment sprint.
|
||||
For now returns a placeholder ticket. The PowerShell
|
||||
connector's transport is also stubbed, so this is
|
||||
consistent — both stubs will be replaced together when
|
||||
PSRemoting integration lands.
|
||||
"""
|
||||
raise CredentialResolutionError(
|
||||
"Kerberos credential resolution not yet implemented. "
|
||||
"Configure an on-premises KDC or use OAuth credentials."
|
||||
logger.warning(
|
||||
"Kerberos credential resolution is stubbed — "
|
||||
"returning placeholder for target=%s",
|
||||
target,
|
||||
)
|
||||
return KerberosCredential(
|
||||
target=target,
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=5),
|
||||
scoped_to=target,
|
||||
ticket=b"STUB_KERBEROS_TICKET",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -76,36 +76,46 @@ class BasculeCredential(Credential):
|
|||
"""
|
||||
|
||||
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)
|
||||
authorization_context: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class KerberosCredential(Credential):
|
||||
"""Short-lived Kerberos ticket for WinRM / PSRemoting."""
|
||||
"""Short-lived Kerberos ticket for WinRM / PSRemoting.
|
||||
|
||||
Acquired via Entra Kerberos proxy (cloud trust) or via
|
||||
on-behalf-of flow to an on-prem KDC. The ticket bytes
|
||||
are an opaque blob consumed by pypsrp or similar.
|
||||
"""
|
||||
|
||||
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)
|
||||
ticket: bytes = b""
|
||||
|
||||
|
||||
@dataclass
|
||||
class OAuthCredential(Credential):
|
||||
"""Short-lived OAuth token for API access."""
|
||||
"""Short-lived OAuth token for API access.
|
||||
|
||||
Acquired via MSAL on-behalf-of flow, scoped to a specific
|
||||
resource (e.g. ``https://graph.microsoft.com``).
|
||||
"""
|
||||
|
||||
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)
|
||||
access_token: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSHCertCredential(Credential):
|
||||
"""Short-lived SSH certificate."""
|
||||
"""Short-lived SSH certificate.
|
||||
|
||||
The private key is ephemeral — generated per session,
|
||||
signed by the CA (Vault, SPIRE, or Entra), and discarded
|
||||
after disconnect.
|
||||
"""
|
||||
|
||||
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)
|
||||
certificate: str = ""
|
||||
private_key: str = ""
|
||||
|
||||
|
||||
# ── Errors ───────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,9 +1,3 @@
|
|||
# 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
|
||||
|
|
@ -11,54 +5,16 @@ from gsap_broker.settings import settings
|
|||
|
||||
engine: AsyncEngine = create_async_engine(settings.database_url, echo=False)
|
||||
|
||||
|
||||
def _restrict_db_permissions() -> None:
|
||||
"""Fix H-6: restrict SQLite file permissions to owner-only (0o600).
|
||||
|
||||
Also restricts WAL and SHM files if they exist.
|
||||
"""
|
||||
db_url = settings.database_url
|
||||
if "sqlite" not in db_url:
|
||||
return
|
||||
|
||||
# Extract path from sqlite URL
|
||||
path = db_url.split("///")[-1] if "///" in db_url else ""
|
||||
if not path or not os.path.exists(path):
|
||||
return
|
||||
|
||||
mode = stat.S_IRUSR | stat.S_IWUSR # 0o600
|
||||
try:
|
||||
os.chmod(path, mode)
|
||||
for suffix in ("-wal", "-shm"):
|
||||
extra = path + suffix
|
||||
if os.path.exists(extra):
|
||||
os.chmod(extra, mode)
|
||||
except OSError:
|
||||
pass # may fail on Windows or read-only filesystem
|
||||
|
||||
|
||||
async def init_db():
|
||||
# Set restrictive umask before creating the database
|
||||
old_umask = os.umask(0o077)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
# Schema migrations for existing DBs
|
||||
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 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
|
||||
|
||||
async def get_session():
|
||||
async with AsyncSession(engine) as session:
|
||||
|
|
|
|||
|
|
@ -44,19 +44,10 @@ class DelegationManager:
|
|||
self.registrar = create_registrar(config)
|
||||
|
||||
async def create_delegation(
|
||||
self, request: DelegationRequest, delegator_did: str,
|
||||
delegator_capability_mask: int = 0x7,
|
||||
self, request: DelegationRequest, delegator_did: str
|
||||
) -> DelegationResponse:
|
||||
delegation_id = f"del-{uuid.uuid4().hex[:8]}"
|
||||
scope = request.scope or DelegationScope()
|
||||
|
||||
# Fix C-9: delegated capability cannot exceed delegator's
|
||||
requested_mask = _capability_mask_for(scope.capability_ceiling)
|
||||
if requested_mask & ~delegator_capability_mask:
|
||||
raise ValueError(
|
||||
f"Delegated capability ({scope.capability_ceiling} = {requested_mask}) "
|
||||
f"exceeds delegator's capability ({delegator_capability_mask})"
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
expires_at = now + timedelta(minutes=scope.max_ttl_minutes)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,15 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""FastAPI router for delegation lifecycle.
|
||||
|
||||
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
|
||||
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
|
||||
"""
|
||||
|
||||
from datetime import datetime, UTC
|
||||
from datetime import datetime
|
||||
|
||||
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 fastapi import APIRouter, Header, HTTPException
|
||||
|
||||
from .lifecycle import DelegationManager
|
||||
from .models import (
|
||||
|
|
@ -36,91 +26,43 @@ 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,
|
||||
auth: AuthResult = Depends(verify_bearer),
|
||||
x_delegator_did: str = Header(..., alias="X-Delegator-DID"),
|
||||
):
|
||||
"""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}",
|
||||
)
|
||||
|
||||
"""Request delegation of authority to an AI agent (§8.1)."""
|
||||
try:
|
||||
result = await manager.create_delegation(
|
||||
request, delegator_did,
|
||||
delegator_capability_mask=delegator_cap,
|
||||
)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(403, str(e))
|
||||
return await manager.create_delegation(request, x_delegator_did)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(502, str(e))
|
||||
raise HTTPException(status_code=502, detail=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 (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")
|
||||
|
||||
"""Revoke an active delegation (§8.2)."""
|
||||
success = await manager.revoke_delegation(delegation_id, request.reason)
|
||||
if not success:
|
||||
raise HTTPException(404, "Delegation not found or not active")
|
||||
raise HTTPException(status_code=404, detail="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,
|
||||
auth: AuthResult = Depends(verify_bearer),
|
||||
):
|
||||
"""Query delegation status (SS8.3). Delegator or delegate can view."""
|
||||
async def get_delegation(delegation_id: str):
|
||||
"""Query delegation status (§8.3)."""
|
||||
d = await db_get(delegation_id)
|
||||
if not d:
|
||||
raise HTTPException(404, "Delegation not found")
|
||||
raise HTTPException(status_code=404, detail="Delegation not found")
|
||||
|
||||
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)
|
||||
now = datetime.utcnow()
|
||||
ttl_remaining = max(0, int((d.expires_at - now).total_seconds()))
|
||||
|
||||
return DelegationInfo(
|
||||
|
|
@ -138,15 +80,10 @@ async def get_delegation(
|
|||
|
||||
|
||||
@router.get("/", response_model=AgentListResponse)
|
||||
async def list_delegations(
|
||||
auth: AuthResult = Depends(verify_bearer),
|
||||
):
|
||||
"""List active delegations for the authenticated principal (SS8.4)."""
|
||||
async def list_delegations():
|
||||
"""List all active agent delegations (§8.4)."""
|
||||
active = await get_active_delegations()
|
||||
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]
|
||||
now = datetime.utcnow()
|
||||
|
||||
return AgentListResponse(
|
||||
active_delegations=[
|
||||
|
|
@ -160,17 +97,7 @@ async def list_delegations(
|
|||
),
|
||||
status=d.status,
|
||||
)
|
||||
for d in mine
|
||||
for d in active
|
||||
],
|
||||
total_active=len(mine),
|
||||
total_active=len(active),
|
||||
)
|
||||
|
||||
|
||||
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,9 +35,6 @@ 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:
|
||||
|
|
@ -82,65 +79,40 @@ async def get_active_delegations() -> list[DelegationDB]:
|
|||
|
||||
|
||||
async def increment_commands(delegation_id: str) -> int:
|
||||
"""Fix C-10: atomic increment with SQL-level limit check."""
|
||||
from sqlalchemy import text as sa_text
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.execute(
|
||||
sa_text(
|
||||
"UPDATE delegations "
|
||||
"SET commands_executed = commands_executed + 1 "
|
||||
"WHERE delegation_id = :id "
|
||||
"AND commands_executed < max_commands "
|
||||
"AND status = 'active'"
|
||||
),
|
||||
{"id": delegation_id},
|
||||
result = await session.exec(
|
||||
select(DelegationDB).where(DelegationDB.delegation_id == delegation_id)
|
||||
)
|
||||
d = result.first()
|
||||
if not d:
|
||||
return 0
|
||||
d.commands_executed += 1
|
||||
session.add(d)
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
return -1 # limit reached or delegation not found/active
|
||||
# Read back the new count
|
||||
row = await session.execute(
|
||||
sa_text("SELECT commands_executed FROM delegations WHERE delegation_id = :id"),
|
||||
{"id": delegation_id},
|
||||
)
|
||||
r = row.first()
|
||||
return r[0] if r else 0
|
||||
return d.commands_executed
|
||||
|
||||
|
||||
async def expire_stale() -> list[DelegationDB]:
|
||||
"""Find and expire delegations past TTL or command limit.
|
||||
Fix M-23: uses atomic SQL update."""
|
||||
from sqlalchemy import text as sa_text
|
||||
from datetime import datetime, UTC
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
"""Find and expire delegations past TTL or command limit."""
|
||||
now = datetime.utcnow()
|
||||
async with AsyncSession(engine) as session:
|
||||
# Atomically expire by TTL
|
||||
await session.execute(
|
||||
sa_text(
|
||||
"UPDATE delegations SET status = 'expired', "
|
||||
"revoke_reason = 'ttl_elapsed', revoked_at = :now "
|
||||
"WHERE status = 'active' AND expires_at < :now"
|
||||
),
|
||||
{"now": str(now)},
|
||||
)
|
||||
# Atomically expire by command limit
|
||||
await session.execute(
|
||||
sa_text(
|
||||
"UPDATE delegations SET status = 'expired', "
|
||||
"revoke_reason = 'command_limit', revoked_at = :now "
|
||||
"WHERE status = 'active' AND commands_executed >= max_commands"
|
||||
),
|
||||
{"now": str(now)},
|
||||
)
|
||||
await session.commit()
|
||||
# Return the expired rows for cleanup
|
||||
result = await session.exec(
|
||||
select(DelegationDB).where(
|
||||
DelegationDB.status == "expired",
|
||||
DelegationDB.revoked_at != None,
|
||||
)
|
||||
select(DelegationDB).where(DelegationDB.status == "active")
|
||||
)
|
||||
return list(result.all())
|
||||
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
|
||||
|
||||
|
||||
async def revoke_by_delegator_ac(delegator_ac_id: str) -> int:
|
||||
|
|
|
|||
|
|
@ -6,56 +6,98 @@
|
|||
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
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from jose import JWTError, jwt as jose_jwt
|
||||
|
||||
from .base import AuthResult, ElevationRequired, IdentityDriver
|
||||
from .jwks import AuthenticationError, JWKSVerifier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# JWKS cache: {tenant_id: (keys, fetched_at)}
|
||||
_jwks_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
||||
_JWKS_TTL = 86400 # 24 hours
|
||||
|
||||
|
||||
async def _get_jwks(tenant_id: str) -> dict[str, Any]:
|
||||
"""Fetch and cache Entra JWKS keys."""
|
||||
cached = _jwks_cache.get(tenant_id)
|
||||
if cached and (time.time() - cached[1]) < _JWKS_TTL:
|
||||
return cached[0]
|
||||
|
||||
url = f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
jwks = resp.json()
|
||||
|
||||
_jwks_cache[tenant_id] = (jwks, time.time())
|
||||
return jwks
|
||||
|
||||
|
||||
def _find_signing_key(jwks: dict[str, Any], kid: str) -> Optional[dict[str, Any]]:
|
||||
"""Find the key matching the JWT kid header."""
|
||||
for key in jwks.get("keys", []):
|
||||
if key.get("kid") == kid:
|
||||
return key
|
||||
return None
|
||||
|
||||
|
||||
class EntraDriver(IdentityDriver):
|
||||
"""Identity driver for direct Entra JWT validation."""
|
||||
|
||||
async def authenticate(self) -> AuthResult:
|
||||
token_data = self.config.get("_token_data", {})
|
||||
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:
|
||||
if not token_data:
|
||||
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.",
|
||||
)
|
||||
# If we have raw_token and tenant_id, perform JWKS verification.
|
||||
if raw_token and tenant_id:
|
||||
try:
|
||||
jwks = await _get_jwks(tenant_id)
|
||||
|
||||
# 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 kid from unverified header
|
||||
unverified_header = jose_jwt.get_unverified_header(raw_token)
|
||||
kid = unverified_header.get("kid", "")
|
||||
signing_key = _find_signing_key(jwks, kid)
|
||||
if not signing_key:
|
||||
return AuthResult(
|
||||
status=AuthResult.STATUS_DENIED,
|
||||
denial_reason=f"No matching signing key for kid={kid}",
|
||||
)
|
||||
|
||||
# Extract claims from VERIFIED token data
|
||||
# Verify signature, exp, nbf, iss, aud
|
||||
verified = jose_jwt.decode(
|
||||
raw_token,
|
||||
signing_key,
|
||||
algorithms=["RS256"],
|
||||
audience=expected_audience,
|
||||
issuer=f"https://login.microsoftonline.com/{tenant_id}/v2.0",
|
||||
)
|
||||
# Use verified claims instead of unverified decode
|
||||
token_data = verified
|
||||
except JWTError as e:
|
||||
return AuthResult(
|
||||
status=AuthResult.STATUS_DENIED,
|
||||
denial_reason=f"JWT verification failed: {e}",
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("JWKS fetch failed, falling back to unverified: %s", e)
|
||||
# Fall through to use the unverified token_data
|
||||
|
||||
# Extract claims
|
||||
oid = token_data.get("oid", "")
|
||||
tid = token_data.get("tid", tenant_id)
|
||||
roles = token_data.get("roles", [])
|
||||
|
|
|
|||
|
|
@ -1,137 +0,0 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Shared JWKS verification for identity drivers.
|
||||
|
||||
Fetches, caches, and verifies JWTs against JWKS endpoints.
|
||||
Used by both Keycloak and Entra drivers.
|
||||
|
||||
SECURITY: Never falls back to unverified claims on JWKS failure.
|
||||
A JWKS fetch failure MUST result in authentication denial.
|
||||
|
||||
Fix C-1: Keycloak tokens are now verified via this module.
|
||||
Fix C-3: Entra tokens are denied on JWKS failure (no fallback).
|
||||
Fix H-10: JWKS cache refreshes on kid miss for key rotation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from jose import JWTError, jwt as jose_jwt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""JWT verification failed. The request MUST be denied."""
|
||||
|
||||
|
||||
class JWKSKeyNotFound(AuthenticationError):
|
||||
"""The JWT's kid does not match any key in the cached JWKS."""
|
||||
|
||||
|
||||
class JWKSVerifier:
|
||||
"""Verifies JWTs against a remote JWKS endpoint.
|
||||
|
||||
Cache TTL defaults to 1 hour. On kid miss, the cache is
|
||||
invalidated and JWKS is re-fetched once before rejecting.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
jwks_url: str,
|
||||
audience: str,
|
||||
issuer: str,
|
||||
cache_ttl: int = 3600,
|
||||
):
|
||||
self._jwks_url = jwks_url
|
||||
self._audience = audience
|
||||
self._issuer = issuer
|
||||
self._cache_ttl = cache_ttl
|
||||
self._jwks_cache: Optional[dict[str, Any]] = None
|
||||
self._cache_fetched_at: float = 0.0
|
||||
|
||||
async def verify_token(self, raw_token: str) -> dict[str, Any]:
|
||||
"""Verify JWT signature and standard claims.
|
||||
|
||||
Returns the verified claims dict.
|
||||
Raises AuthenticationError on ANY failure — NEVER falls back
|
||||
to unverified claims.
|
||||
"""
|
||||
if not raw_token:
|
||||
raise AuthenticationError("No token provided")
|
||||
|
||||
jwks = await self._fetch_jwks()
|
||||
|
||||
try:
|
||||
unverified_header = jose_jwt.get_unverified_header(raw_token)
|
||||
except JWTError as e:
|
||||
raise AuthenticationError(f"Malformed JWT header: {e}")
|
||||
|
||||
kid = unverified_header.get("kid", "")
|
||||
signing_key = self._find_key(jwks, kid)
|
||||
if signing_key is None:
|
||||
raise JWKSKeyNotFound(f"No matching key for kid={kid}")
|
||||
|
||||
try:
|
||||
# algorithms=["RS256"] blocks alg=none and HMAC confusion
|
||||
return jose_jwt.decode(
|
||||
raw_token,
|
||||
signing_key,
|
||||
algorithms=["RS256"],
|
||||
audience=self._audience,
|
||||
issuer=self._issuer,
|
||||
options={"require_exp": True},
|
||||
)
|
||||
except JWTError as e:
|
||||
raise AuthenticationError(f"JWT verification failed: {e}")
|
||||
|
||||
async def verify_or_refresh(self, raw_token: str) -> dict[str, Any]:
|
||||
"""Verify with cache; on kid miss, refresh JWKS once and retry.
|
||||
|
||||
Fix H-10: handles key rotation gracefully.
|
||||
"""
|
||||
try:
|
||||
return await self.verify_token(raw_token)
|
||||
except JWKSKeyNotFound:
|
||||
# kid not in cache — force refresh and retry once
|
||||
self._invalidate_cache()
|
||||
return await self.verify_token(raw_token)
|
||||
|
||||
async def _fetch_jwks(self) -> dict[str, Any]:
|
||||
"""Fetch and cache JWKS. Raises on failure — never falls back."""
|
||||
if self._cache_valid():
|
||||
return self._jwks_cache
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(self._jwks_url)
|
||||
resp.raise_for_status()
|
||||
self._jwks_cache = resp.json()
|
||||
self._cache_fetched_at = time.time()
|
||||
return self._jwks_cache
|
||||
except Exception as e:
|
||||
# Fix C-3: NEVER fall back — deny on failure
|
||||
raise AuthenticationError(
|
||||
f"JWKS fetch failed from {self._jwks_url}: {e}. "
|
||||
"Cannot verify token — denying."
|
||||
)
|
||||
|
||||
def _cache_valid(self) -> bool:
|
||||
return (
|
||||
self._jwks_cache is not None
|
||||
and (time.time() - self._cache_fetched_at) < self._cache_ttl
|
||||
)
|
||||
|
||||
def _invalidate_cache(self) -> None:
|
||||
self._jwks_cache = None
|
||||
self._cache_fetched_at = 0.0
|
||||
|
||||
@staticmethod
|
||||
def _find_key(jwks: dict[str, Any], kid: str) -> Optional[dict[str, Any]]:
|
||||
for key in jwks.get("keys", []):
|
||||
if key.get("kid") == kid:
|
||||
return key
|
||||
return None
|
||||
|
|
@ -1,47 +1,14 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Keycloak identity driver — GSAP §2.2.
|
||||
|
||||
Fix C-1: JWT signatures are now verified via JWKS.
|
||||
Previously this driver accepted any base64-decoded JWT without
|
||||
signature verification. Now uses shared JWKSVerifier.
|
||||
"""
|
||||
|
||||
"""Keycloak identity driver — GSAP §2.2."""
|
||||
import logging
|
||||
from .base import IdentityDriver, AuthResult, ElevationRequired
|
||||
from .jwks import AuthenticationError, JWKSVerifier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KeycloakDriver(IdentityDriver):
|
||||
async def authenticate(self) -> AuthResult:
|
||||
raw_token = self.config.get("_raw_token", "")
|
||||
keycloak_url = self.config.get("keycloak_url", "http://localhost:8080")
|
||||
keycloak_realm = self.config.get("keycloak_realm", "substrate")
|
||||
keycloak_client_id = self.config.get("keycloak_client_id", "")
|
||||
|
||||
# Fix C-1: verify JWT signature via JWKS before trusting claims.
|
||||
if raw_token and keycloak_url and keycloak_realm:
|
||||
verifier = JWKSVerifier(
|
||||
jwks_url=f"{keycloak_url}/realms/{keycloak_realm}/protocol/openid-connect/certs",
|
||||
audience=keycloak_client_id,
|
||||
issuer=f"{keycloak_url}/realms/{keycloak_realm}",
|
||||
)
|
||||
try:
|
||||
token_data = await verifier.verify_or_refresh(raw_token)
|
||||
except AuthenticationError as e:
|
||||
return AuthResult(
|
||||
status=AuthResult.STATUS_DENIED,
|
||||
denial_reason=str(e),
|
||||
)
|
||||
else:
|
||||
# No raw token or no Keycloak config — cannot verify
|
||||
return AuthResult(
|
||||
status=AuthResult.STATUS_DENIED,
|
||||
denial_reason="No token or Keycloak configuration missing.",
|
||||
)
|
||||
token_data = self.config.get("_token_data", {})
|
||||
if not token_data:
|
||||
return AuthResult(status=AuthResult.STATUS_DENIED, denial_reason="No token in context.")
|
||||
|
||||
realm_roles = token_data.get("realm_access", {}).get("roles", [])
|
||||
requested_accord = self.config.get("requested_accord", "")
|
||||
|
|
|
|||
|
|
@ -17,12 +17,9 @@ import time
|
|||
from datetime import datetime, UTC
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, 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
|
||||
|
||||
|
|
@ -524,11 +521,8 @@ def _success(result, req_id):
|
|||
|
||||
|
||||
@router.post("/mcp")
|
||||
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.
|
||||
"""
|
||||
async def mcp_endpoint(request: Request):
|
||||
"""MCP JSON-RPC 2.0 endpoint — governance primitives as tools."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -60,10 +60,6 @@ async def authorize(body: AuthorizeRequest, http_request: Request, db: AsyncSess
|
|||
"_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))
|
||||
|
|
@ -140,17 +136,7 @@ async def authorize(body: AuthorizeRequest, http_request: Request, db: AsyncSess
|
|||
expires = now + timedelta(minutes=settings.ac_ttl_minutes)
|
||||
ctx_id = uuid.uuid4()
|
||||
|
||||
# Fix C-2: on_behalf_of requires gsap:impersonate role
|
||||
if request.on_behalf_of:
|
||||
caller_roles = getattr(auth_result, "elevation_active", [])
|
||||
# Check for impersonation role in JWT roles (passed through auth_result)
|
||||
token_roles = token_data.get("roles", []) + token_data.get("realm_access", {}).get("roles", [])
|
||||
if "gsap:impersonate" not in token_roles:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="on_behalf_of requires gsap:impersonate role.",
|
||||
)
|
||||
|
||||
# on_behalf_of: trusted caller (Bascule SA) asserts who the AC is for
|
||||
principal_did = request.on_behalf_of or auth_result.principal_did
|
||||
display_name = request.on_behalf_of.rsplit("/", 1)[-1] if request.on_behalf_of else auth_result.display_name
|
||||
|
||||
|
|
|
|||
|
|
@ -21,35 +21,7 @@ _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)
|
||||
# Conditionally register Intune connector
|
||||
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
|
||||
|
|
@ -64,21 +36,6 @@ if settings.intune_enabled and settings.entra_client_secret:
|
|||
_intune_connector = IntuneConnector(graph_client=_intune_graph, cache=_intune_cache)
|
||||
_registry.register(_intune_connector)
|
||||
|
||||
# Bascule (session-based — uses CredentialResolver)
|
||||
if settings.bascule_enabled:
|
||||
from gsap_broker.connectors.bascule import BasculeConnector
|
||||
_registry.register(BasculeConnector(credential_resolver=_credential_resolver))
|
||||
|
||||
# PowerShell (session-based — uses CredentialResolver)
|
||||
if settings.powershell_enabled:
|
||||
from gsap_broker.connectors.powershell import PowerShellConnector
|
||||
_registry.register(PowerShellConnector(credential_resolver=_credential_resolver))
|
||||
|
||||
# Ansible (orchestrator — uses CredentialResolver)
|
||||
if settings.ansible_enabled:
|
||||
from gsap_broker.connectors.ansible import AnsibleConnector
|
||||
_registry.register(AnsibleConnector(credential_resolver=_credential_resolver))
|
||||
|
||||
|
||||
class InvokeRequest(BaseModel):
|
||||
operation: str
|
||||
|
|
@ -87,9 +44,6 @@ class InvokeRequest(BaseModel):
|
|||
gsap_context_id: str = ""
|
||||
pipeline_run_id: str = ""
|
||||
dag_id: str = ""
|
||||
# Fix C-6: caller must declare the AC's capability_mask
|
||||
capability_mask: int = 0
|
||||
principal_did: str = ""
|
||||
|
||||
|
||||
class InvokeResponse(BaseModel):
|
||||
|
|
@ -125,8 +79,6 @@ async def invoke_connector(connector_id: str, body: InvokeRequest) -> InvokeResp
|
|||
gsap_context_id=body.gsap_context_id,
|
||||
pipeline_run_id=body.pipeline_run_id,
|
||||
dag_id=body.dag_id,
|
||||
capability_mask=body.capability_mask,
|
||||
principal_did=body.principal_did,
|
||||
)
|
||||
result = await _runtime.invoke(connector_id, body.operation, body.parameters, ctx)
|
||||
return InvokeResponse(
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""DeviceRouter — automatic connector selection based on target device.
|
||||
|
||||
The DeviceRouter inspects the target device's characteristics (OS,
|
||||
management channel, enrollment status) and routes operations to the
|
||||
appropriate connector.
|
||||
|
||||
Routing decision logic:
|
||||
1. If the operation is API-mediated (compliance check, inventory
|
||||
query), route to the Intune connector regardless of device OS.
|
||||
2. If the device is Windows and the operation is session-based
|
||||
(execute, configure), route to PowerShell connector.
|
||||
3. If the device is Linux/macOS and the operation is session-based,
|
||||
route to Bascule connector.
|
||||
4. If the operation is multi-host (fleet-wide playbook), route to
|
||||
Ansible connector.
|
||||
5. If the device is unknown, raise ``UnknownDevice``.
|
||||
|
||||
The device inventory comes from:
|
||||
- Intune device cache (Windows/iOS/Android managed devices)
|
||||
- Bascule fleet registry (Linux governed endpoints) — future
|
||||
- Manual registration (for unmanaged devices) — future
|
||||
|
||||
Rust port note:
|
||||
``DeviceRouter`` maps to a struct with connector registry and
|
||||
device cache references. The routing logic is a match statement
|
||||
on ``(operation_type, device_os)``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from gsap_broker.connectors.base import ConnectorContext, ConnectorResult
|
||||
from gsap_broker.connectors.registry import ConnectorRegistry
|
||||
from gsap_broker.intune.device_cache import DeviceComplianceCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Operations that are API-mediated (go through Intune Graph API
|
||||
# regardless of device OS).
|
||||
_API_OPERATIONS = frozenset({
|
||||
"get_compliance", "list_devices", "get_device",
|
||||
"sync_device", "retire_device", "wipe_device",
|
||||
})
|
||||
|
||||
# Operations that span multiple hosts (go through Ansible).
|
||||
_FLEET_OPERATIONS = frozenset({
|
||||
"playbook", "adhoc", "role", "collect",
|
||||
})
|
||||
|
||||
# OS → preferred session connector mapping.
|
||||
_OS_CONNECTOR_MAP = {
|
||||
"windows": "powershell",
|
||||
"linux": "bascule",
|
||||
"macos": "bascule",
|
||||
"ios": "intune", # mobile — API-only
|
||||
"android": "intune", # mobile — API-only
|
||||
}
|
||||
|
||||
|
||||
class UnknownDevice(Exception):
|
||||
"""Target device is not in any inventory."""
|
||||
|
||||
def __init__(self, target: str):
|
||||
self.target = target
|
||||
super().__init__(f"Unknown device: {target}")
|
||||
|
||||
|
||||
class DeviceRouter:
|
||||
"""Routes operations to the appropriate connector based on target.
|
||||
|
||||
Combines the device inventory (Intune cache) with the connector
|
||||
registry to select the best management channel for each operation.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connector_registry: ConnectorRegistry,
|
||||
device_cache: Optional[DeviceComplianceCache] = None,
|
||||
):
|
||||
self._connectors = connector_registry
|
||||
self._devices = device_cache
|
||||
|
||||
async def route(
|
||||
self, operation: str, target: str, context: ConnectorContext
|
||||
) -> tuple[str, str]:
|
||||
"""Determine which connector handles this operation.
|
||||
|
||||
Returns:
|
||||
(connector_id, mapped_operation) — the connector to use
|
||||
and the operation name to pass to it (may differ from the
|
||||
input if the router translates operations).
|
||||
|
||||
Raises:
|
||||
UnknownDevice: if the target is not in any inventory and
|
||||
cannot be routed.
|
||||
"""
|
||||
# API-mediated operations always go to Intune
|
||||
if operation in _API_OPERATIONS:
|
||||
return ("intune", operation)
|
||||
|
||||
# Fleet operations go to Ansible
|
||||
if operation in _FLEET_OPERATIONS:
|
||||
return ("ansible", operation)
|
||||
|
||||
# Session operations: route based on device OS
|
||||
device_os = await self._detect_os(target)
|
||||
if device_os is None:
|
||||
raise UnknownDevice(target)
|
||||
|
||||
connector_id = _OS_CONNECTOR_MAP.get(device_os.lower(), "bascule")
|
||||
return (connector_id, operation)
|
||||
|
||||
async def invoke(
|
||||
self,
|
||||
operation: str,
|
||||
target: str,
|
||||
parameters: dict[str, Any],
|
||||
context: ConnectorContext,
|
||||
) -> ConnectorResult:
|
||||
"""Route and invoke in one call."""
|
||||
try:
|
||||
connector_id, mapped_op = await self.route(operation, target, context)
|
||||
except UnknownDevice as e:
|
||||
return ConnectorResult(success=False, error=str(e))
|
||||
|
||||
connector = self._connectors.get(connector_id)
|
||||
if connector is None:
|
||||
return ConnectorResult(
|
||||
success=False,
|
||||
error=f"Connector '{connector_id}' not registered",
|
||||
)
|
||||
|
||||
parameters["target"] = target
|
||||
return await connector.invoke(mapped_op, parameters, context)
|
||||
|
||||
async def _detect_os(self, target: str) -> Optional[str]:
|
||||
"""Look up the device OS from the Intune cache.
|
||||
|
||||
Future: also check Bascule fleet registry, manual
|
||||
registration, DNS-based hints, etc.
|
||||
"""
|
||||
if self._devices is None:
|
||||
return None
|
||||
|
||||
cached = await self._devices.get(target)
|
||||
if cached is not None:
|
||||
# ComplianceState doesn't carry OS — the cache is keyed
|
||||
# by device_id but the Intune connector's list_devices
|
||||
# response has os_type. For now, return None and let the
|
||||
# caller handle it. A proper DeviceInventory cache (not
|
||||
# just compliance) is needed — tracked for next sprint.
|
||||
return None
|
||||
|
||||
return None
|
||||
|
|
@ -41,18 +41,6 @@ class Settings(BaseSettings):
|
|||
intune_compliance_strict: bool = False # reject if no device_id present
|
||||
intune_compliance_cache_ttl: int = 300 # seconds
|
||||
|
||||
# ── Session connectors ──
|
||||
bascule_enabled: bool = False
|
||||
powershell_enabled: bool = False
|
||||
ansible_enabled: bool = False
|
||||
|
||||
# ── Credential backend ──
|
||||
# "auto" | "entra" | "stub"
|
||||
# auto: use Entra if entra_client_secret is set, else stub
|
||||
credential_backend: str = "auto"
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
# 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())
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
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}"
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
# 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
29
tests/fixtures/sample-template/bastion.toml
vendored
|
|
@ -1,29 +0,0 @@
|
|||
[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
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
# 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)
|
||||
|
|
@ -1,316 +0,0 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Tests for credential resolver, backends, and session connectors."""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, UTC
|
||||
|
||||
from gsap_broker.credentials.resolver import (
|
||||
BasculeCredential,
|
||||
Credential,
|
||||
CredentialResolver,
|
||||
CredentialResolutionError,
|
||||
KerberosCredential,
|
||||
NoBackendAvailable,
|
||||
OAuthCredential,
|
||||
SSHCertCredential,
|
||||
)
|
||||
from gsap_broker.credentials.stub_backend import StubCredentialBackend
|
||||
from gsap_broker.connectors.base import ConnectorContext
|
||||
from gsap_broker.connectors.bascule import BasculeConnector
|
||||
from gsap_broker.connectors.powershell import PowerShellConnector
|
||||
from gsap_broker.connectors.ansible import AnsibleConnector
|
||||
from gsap_broker.connectors.orchestrator import WorkflowPlan, WorkflowStep
|
||||
from gsap_broker.routing.device_router import DeviceRouter, UnknownDevice
|
||||
|
||||
|
||||
AC_CONTEXT = {
|
||||
"gsap_context_id": "test-ac",
|
||||
"expires_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
|
||||
"accord": {"template": "test-ops"},
|
||||
"principal": {"did": "did:web:test/p/alice"},
|
||||
}
|
||||
|
||||
|
||||
# ── CredentialResolver ────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_routes_to_correct_backend():
|
||||
"""TEST 2: Resolver routes each credential type to the stub backend."""
|
||||
resolver = CredentialResolver()
|
||||
resolver.register(StubCredentialBackend())
|
||||
|
||||
for cred_type, expected_cls in [
|
||||
("bascule_ac", BasculeCredential),
|
||||
("kerberos", KerberosCredential),
|
||||
("oauth", OAuthCredential),
|
||||
("ssh_cert", SSHCertCredential),
|
||||
]:
|
||||
cred = await resolver.resolve(cred_type, "target-1", AC_CONTEXT)
|
||||
assert isinstance(cred, expected_cls)
|
||||
assert cred.expires_at is not None
|
||||
assert not cred.expired
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_rejects_unknown_type():
|
||||
"""TEST 3: Unknown credential type raises NoBackendAvailable."""
|
||||
resolver = CredentialResolver()
|
||||
resolver.register(StubCredentialBackend())
|
||||
|
||||
with pytest.raises(NoBackendAvailable) as exc_info:
|
||||
await resolver.resolve("quantum_key", "target", AC_CONTEXT)
|
||||
assert "quantum_key" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bascule_credential_preserves_ac():
|
||||
"""TEST 4: Bascule credential passes AC through."""
|
||||
resolver = CredentialResolver()
|
||||
resolver.register(StubCredentialBackend())
|
||||
|
||||
cred = await resolver.resolve("bascule_ac", "target-1", AC_CONTEXT)
|
||||
assert isinstance(cred, BasculeCredential)
|
||||
assert cred.authorization_context == AC_CONTEXT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stub_credentials_not_expired():
|
||||
"""TEST 5: Stub backend returns credentials that are not expired."""
|
||||
resolver = CredentialResolver()
|
||||
resolver.register(StubCredentialBackend())
|
||||
|
||||
for cred_type in ["bascule_ac", "kerberos", "oauth", "ssh_cert"]:
|
||||
cred = await resolver.resolve(cred_type, "target", AC_CONTEXT)
|
||||
assert not cred.expired
|
||||
|
||||
|
||||
# ── SessionConnector lifecycle ────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_connector_success():
|
||||
"""TEST 6: Full lifecycle — resolve, connect, execute, disconnect."""
|
||||
resolver = CredentialResolver()
|
||||
resolver.register(StubCredentialBackend())
|
||||
|
||||
connector = BasculeConnector(credential_resolver=resolver)
|
||||
ctx = ConnectorContext(gsap_context_id="test-ac")
|
||||
|
||||
result = await connector.invoke("run-script", {"target": "node-1"}, ctx)
|
||||
|
||||
assert result.success
|
||||
assert result.data["target"] == "node-1"
|
||||
assert result.data["command"] == "run-script"
|
||||
assert result.data["stub"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_connector_missing_target():
|
||||
"""Session connector requires target parameter."""
|
||||
resolver = CredentialResolver()
|
||||
resolver.register(StubCredentialBackend())
|
||||
|
||||
connector = BasculeConnector(credential_resolver=resolver)
|
||||
ctx = ConnectorContext(gsap_context_id="test-ac")
|
||||
|
||||
result = await connector.invoke("run-script", {}, ctx)
|
||||
assert not result.success
|
||||
assert "target required" in result.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_connector_transport_failure():
|
||||
"""TEST 7: Transport failure still calls disconnect (cleanup guarantee)."""
|
||||
from gsap_broker.connectors.session import SessionTransport, SessionConnector
|
||||
from gsap_broker.credentials.resolver import Credential
|
||||
|
||||
disconnect_called = False
|
||||
|
||||
class FailingTransport(SessionTransport):
|
||||
transport_id = "failing"
|
||||
|
||||
async def connect(self, target, credential):
|
||||
pass
|
||||
|
||||
async def execute(self, command, params=None):
|
||||
raise RuntimeError("Transport exploded")
|
||||
|
||||
async def disconnect(self):
|
||||
nonlocal disconnect_called
|
||||
disconnect_called = True
|
||||
|
||||
async def is_alive(self):
|
||||
return False
|
||||
|
||||
class FailingConnector(SessionConnector):
|
||||
connector_id = "failing"
|
||||
corpus_entry_cid = "sha256:test"
|
||||
credential_type = "bascule_ac"
|
||||
transport_class = FailingTransport
|
||||
capability_mask = 1
|
||||
declared_endpoints = []
|
||||
|
||||
resolver = CredentialResolver()
|
||||
resolver.register(StubCredentialBackend())
|
||||
|
||||
connector = FailingConnector(credential_resolver=resolver)
|
||||
ctx = ConnectorContext(gsap_context_id="test-ac")
|
||||
|
||||
result = await connector.invoke("crash", {"target": "node-1"}, ctx)
|
||||
|
||||
assert not result.success
|
||||
assert "exploded" in result.error
|
||||
assert disconnect_called, "disconnect() must be called even on failure"
|
||||
|
||||
|
||||
# ── PowerShell connector ──────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_powershell_connector_stub():
|
||||
"""PowerShell connector lifecycle with stubbed transport."""
|
||||
resolver = CredentialResolver()
|
||||
resolver.register(StubCredentialBackend())
|
||||
|
||||
connector = PowerShellConnector(credential_resolver=resolver)
|
||||
ctx = ConnectorContext(gsap_context_id="test-ac")
|
||||
|
||||
result = await connector.invoke("Get-Process", {"target": "win-srv-01:5986"}, ctx)
|
||||
|
||||
assert result.success
|
||||
assert result.data["transport"] == "psrp"
|
||||
assert result.data["target"] == "win-srv-01:5986"
|
||||
|
||||
|
||||
# ── OrchestratorConnector (Ansible) ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orchestrator_all_steps_succeed():
|
||||
"""TEST 8: All steps succeed → aggregate success."""
|
||||
resolver = CredentialResolver()
|
||||
resolver.register(StubCredentialBackend())
|
||||
|
||||
connector = AnsibleConnector(credential_resolver=resolver)
|
||||
ctx = ConnectorContext(gsap_context_id="test-ac")
|
||||
|
||||
result = await connector.invoke(
|
||||
"playbook",
|
||||
{"playbook": "site.yml", "targets": ["host-1", "host-2"]},
|
||||
ctx,
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert len(result.data["steps"]) == 1
|
||||
assert result.data["steps"][0]["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orchestrator_required_step_fails():
|
||||
"""TEST 9: Required step fails → early termination, partial results."""
|
||||
from gsap_broker.connectors.orchestrator import OrchestratorConnector, WorkflowPlan, WorkflowStep
|
||||
|
||||
resolver = CredentialResolver()
|
||||
resolver.register(StubCredentialBackend())
|
||||
|
||||
class FailingOrchestrator(OrchestratorConnector):
|
||||
connector_id = "failing-orch"
|
||||
corpus_entry_cid = "sha256:test"
|
||||
capability_mask = 1
|
||||
declared_endpoints = []
|
||||
|
||||
async def plan(self, operation, parameters, context):
|
||||
return WorkflowPlan(steps=[
|
||||
WorkflowStep(name="step-1", command="ok", required=True),
|
||||
WorkflowStep(name="step-2", command="fail", required=True),
|
||||
WorkflowStep(name="step-3", command="never", required=True),
|
||||
])
|
||||
|
||||
async def execute_step(self, step, context):
|
||||
if step.command == "fail":
|
||||
return {"success": False, "error": "boom"}
|
||||
return {"success": True}
|
||||
|
||||
connector = FailingOrchestrator(credential_resolver=resolver)
|
||||
ctx = ConnectorContext(gsap_context_id="test-ac")
|
||||
|
||||
result = await connector.invoke("run", {}, ctx)
|
||||
|
||||
assert not result.success
|
||||
assert result.data["failed_at"] == "step-2"
|
||||
assert len(result.data["completed"]) == 2 # step-1 succeeded, step-2 failed
|
||||
# step-3 was never executed
|
||||
|
||||
|
||||
# ── DeviceRouter ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_api_operation_routes_to_intune():
|
||||
"""TEST 12: API-mediated operations route to Intune."""
|
||||
from gsap_broker.connectors.registry import ConnectorRegistry
|
||||
registry = ConnectorRegistry()
|
||||
router = DeviceRouter(connector_registry=registry)
|
||||
|
||||
ctx = ConnectorContext(gsap_context_id="test-ac")
|
||||
connector_id, op = await router.route("get_compliance", "dev-1", ctx)
|
||||
assert connector_id == "intune"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_fleet_operation_routes_to_ansible():
|
||||
"""Fleet operations route to Ansible."""
|
||||
from gsap_broker.connectors.registry import ConnectorRegistry
|
||||
registry = ConnectorRegistry()
|
||||
router = DeviceRouter(connector_registry=registry)
|
||||
|
||||
ctx = ConnectorContext(gsap_context_id="test-ac")
|
||||
connector_id, op = await router.route("playbook", "fleet", ctx)
|
||||
assert connector_id == "ansible"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_unknown_device_raises():
|
||||
"""TEST 13: Unknown device raises clear error."""
|
||||
from gsap_broker.connectors.registry import ConnectorRegistry
|
||||
registry = ConnectorRegistry()
|
||||
router = DeviceRouter(connector_registry=registry)
|
||||
|
||||
ctx = ConnectorContext(gsap_context_id="test-ac")
|
||||
with pytest.raises(UnknownDevice):
|
||||
await router.route("exec", "mystery-host", ctx)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_invoke_missing_connector():
|
||||
"""Router invoke returns error when connector not registered."""
|
||||
from gsap_broker.connectors.registry import ConnectorRegistry
|
||||
registry = ConnectorRegistry()
|
||||
router = DeviceRouter(connector_registry=registry)
|
||||
|
||||
ctx = ConnectorContext(gsap_context_id="test-ac")
|
||||
result = await router.invoke("get_compliance", "dev-1", {}, ctx)
|
||||
assert not result.success
|
||||
assert "not registered" in result.error
|
||||
|
||||
|
||||
# ── Catalog conditional registration ─────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connectors_absent_when_disabled():
|
||||
"""TEST 15: Optional connectors not in catalog when disabled."""
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from gsap_broker.app import app
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get("/connectors/")
|
||||
assert resp.status_code == 200
|
||||
ids = [c["connector_id"] for c in resp.json()]
|
||||
# With default settings, only echo is registered
|
||||
assert "bascule" not in ids
|
||||
assert "powershell" not in ids
|
||||
assert "ansible" not in ids
|
||||
|
|
@ -1,18 +1,19 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Tests for the Entra identity driver — C-3, H-10."""
|
||||
"""Tests for the Entra identity driver."""
|
||||
|
||||
import time
|
||||
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
|
||||
from gsap_broker.drivers.entra import EntraDriver, _jwks_cache
|
||||
|
||||
|
||||
def _generate_rsa_keypair():
|
||||
"""Generate an RSA key pair for test JWT signing."""
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
public_key = private_key.public_key()
|
||||
return private_key, public_key
|
||||
|
|
@ -27,6 +28,7 @@ def _private_key_pem(private_key):
|
|||
|
||||
|
||||
def _public_numbers_to_jwk(public_key, kid="test-kid-1"):
|
||||
"""Convert RSA public key to JWK dict."""
|
||||
import base64
|
||||
nums = public_key.public_numbers()
|
||||
e_bytes = nums.e.to_bytes((nums.e.bit_length() + 7) // 8, "big")
|
||||
|
|
@ -49,6 +51,7 @@ JWKS = {"keys": [_public_numbers_to_jwk(PUBLIC_KEY, KID)]}
|
|||
|
||||
|
||||
def _make_token(claims: dict, kid: str = KID, expired: bool = False) -> str:
|
||||
"""Create a signed test JWT."""
|
||||
import datetime
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
base_claims = {
|
||||
|
|
@ -74,7 +77,18 @@ def _make_token(claims: dict, kid: str = KID, expired: bool = False) -> str:
|
|||
|
||||
|
||||
def _driver_config(raw_token: str = "", extra: dict = None) -> dict:
|
||||
"""Build config dict as the authorize router would."""
|
||||
import base64, json
|
||||
token_data = {}
|
||||
if raw_token:
|
||||
try:
|
||||
payload = raw_token.split(".")[1]
|
||||
payload += "=" * (4 - len(payload) % 4)
|
||||
token_data = json.loads(base64.urlsafe_b64decode(payload))
|
||||
except Exception:
|
||||
pass
|
||||
config = {
|
||||
"_token_data": token_data,
|
||||
"_raw_token": raw_token,
|
||||
"entra_tenant_id": TENANT_ID,
|
||||
"entra_client_id": CLIENT_ID,
|
||||
|
|
@ -85,71 +99,84 @@ def _driver_config(raw_token: str = "", extra: dict = None) -> dict:
|
|||
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()
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_jwks_cache():
|
||||
_jwks_cache.clear()
|
||||
yield
|
||||
_jwks_cache.clear()
|
||||
|
||||
ctx_manager = AsyncMock()
|
||||
ctx_manager.__aenter__.return_value.get = AsyncMock(return_value=mock_resp)
|
||||
mock_http.return_value = ctx_manager
|
||||
yield mock_http
|
||||
|
||||
@pytest.fixture
|
||||
def mock_jwks():
|
||||
"""Mock the JWKS fetch to return test keys."""
|
||||
with patch("gsap_broker.drivers.entra._get_jwks", new_callable=AsyncMock) as m:
|
||||
m.return_value = JWKS
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_valid_token(mock_jwks_fetch):
|
||||
async def test_authenticate_valid_token(mock_jwks):
|
||||
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.display_name == "Alice Smith"
|
||||
assert result.stable_id == "user-oid-1"
|
||||
assert result.token_jti == "test-jti"
|
||||
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):
|
||||
async def test_authenticate_extracts_device_id(mock_jwks):
|
||||
token = _make_token({"deviceid": "device-abc-123"})
|
||||
driver = EntraDriver(config=_driver_config(raw_token=token))
|
||||
result = await driver.authenticate()
|
||||
|
||||
assert result.is_authorized
|
||||
assert result.device_id == "device-abc-123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_no_device_id(mock_jwks):
|
||||
token = _make_token({})
|
||||
driver = EntraDriver(config=_driver_config(raw_token=token))
|
||||
result = await driver.authenticate()
|
||||
|
||||
assert result.is_authorized
|
||||
assert result.device_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_expired_token(mock_jwks):
|
||||
token = _make_token({}, expired=True)
|
||||
driver = EntraDriver(config=_driver_config(raw_token=token))
|
||||
result = await driver.authenticate()
|
||||
|
||||
assert not result.is_authorized
|
||||
assert "expired" in result.denial_reason.lower() or "verification failed" in result.denial_reason.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_no_token():
|
||||
driver = EntraDriver(config={"_raw_token": "", "entra_tenant_id": TENANT_ID})
|
||||
driver = EntraDriver(config={"_token_data": {}, "_raw_token": ""})
|
||||
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):
|
||||
async def test_authenticate_mfa_detection(mock_jwks):
|
||||
# With MFA
|
||||
token = _make_token({"amr": ["pwd", "mfa"]})
|
||||
driver = EntraDriver(config=_driver_config(raw_token=token))
|
||||
result = await driver.authenticate()
|
||||
assert result.mfa_satisfied is True
|
||||
|
||||
# Without MFA
|
||||
token = _make_token({"amr": ["pwd"]})
|
||||
driver = EntraDriver(config=_driver_config(raw_token=token))
|
||||
result = await driver.authenticate()
|
||||
|
|
@ -157,7 +184,7 @@ async def test_authenticate_mfa_detection(mock_jwks_fetch):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_elevation_required(mock_jwks_fetch):
|
||||
async def test_authenticate_elevation_required(mock_jwks):
|
||||
token = _make_token({"roles": ["reader"]})
|
||||
config = _driver_config(raw_token=token, extra={
|
||||
"requested_accord": "admin-ops",
|
||||
|
|
@ -165,41 +192,26 @@ async def test_authenticate_elevation_required(mock_jwks_fetch):
|
|||
})
|
||||
driver = EntraDriver(config=config)
|
||||
result = await driver.authenticate()
|
||||
|
||||
assert result.needs_elevation
|
||||
assert result.elevation_required.role == "admin-role"
|
||||
assert result.elevation_required.mechanism == "entra_pim"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_did_construction(mock_jwks_fetch):
|
||||
async def test_did_construction(mock_jwks):
|
||||
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."""
|
||||
async def test_wrong_kid_rejected(mock_jwks):
|
||||
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
|
||||
assert "signing key" in result.denial_reason.lower()
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ async def test_get_compliance_compliant(connector, mock_graph, ctx):
|
|||
"lastSyncDateTime": "2026-04-14T00:00:00Z",
|
||||
}
|
||||
|
||||
result = await connector.invoke("get_compliance", {"device_id": "00000000-0000-0000-0000-000000000001"}, ctx)
|
||||
result = await connector.invoke("get_compliance", {"device_id": "dev-1"}, ctx)
|
||||
|
||||
assert result.success
|
||||
assert result.data["compliant"] is True
|
||||
|
|
@ -101,7 +101,7 @@ async def test_get_compliance_noncompliant(connector, mock_graph, ctx):
|
|||
"complianceState": "noncompliant",
|
||||
}
|
||||
|
||||
result = await connector.invoke("get_compliance", {"device_id": "00000000-0000-0000-0000-000000000001"}, ctx)
|
||||
result = await connector.invoke("get_compliance", {"device_id": "dev-1"}, ctx)
|
||||
|
||||
assert result.success
|
||||
assert result.data["compliant"] is False
|
||||
|
|
@ -112,12 +112,12 @@ async def test_get_compliance_noncompliant(connector, mock_graph, ctx):
|
|||
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",
|
||||
device_id="dev-cached", compliant=True, state="compliant",
|
||||
last_evaluated=datetime.now(UTC),
|
||||
)
|
||||
await cache.set("00000000-0000-0000-0000-00000000000c", state)
|
||||
await cache.set("dev-cached", state)
|
||||
|
||||
result = await connector.invoke("get_compliance", {"device_id": "00000000-0000-0000-0000-00000000000c"}, ctx)
|
||||
result = await connector.invoke("get_compliance", {"device_id": "dev-cached"}, ctx)
|
||||
|
||||
assert result.success
|
||||
assert result.data["compliant"] is True
|
||||
|
|
@ -140,7 +140,7 @@ async def test_remote_lock(connector, mock_graph, ctx):
|
|||
resp.status_code = 204
|
||||
mock_graph.post.return_value = resp
|
||||
|
||||
result = await connector.invoke("remote_lock", {"device_id": "00000000-0000-0000-0000-000000000001"}, ctx)
|
||||
result = await connector.invoke("remote_lock", {"device_id": "dev-1"}, ctx)
|
||||
|
||||
assert result.success
|
||||
assert result.data["locked"] is True
|
||||
|
|
|
|||
|
|
@ -1,173 +0,0 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Tests for the Keycloak identity driver — C-1: JWKS verification."""
|
||||
|
||||
import datetime
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from jose import jwt as jose_jwt
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from gsap_broker.drivers.keycloak import KeycloakDriver
|
||||
|
||||
|
||||
def _generate_rsa_keypair():
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
return private_key, private_key.public_key()
|
||||
|
||||
|
||||
def _private_key_pem(private_key):
|
||||
return private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
|
||||
|
||||
def _public_numbers_to_jwk(public_key, kid="kc-kid-1"):
|
||||
import base64
|
||||
nums = public_key.public_numbers()
|
||||
e_bytes = nums.e.to_bytes((nums.e.bit_length() + 7) // 8, "big")
|
||||
n_bytes = nums.n.to_bytes((nums.n.bit_length() + 7) // 8, "big")
|
||||
return {
|
||||
"kty": "RSA", "kid": kid, "use": "sig", "alg": "RS256",
|
||||
"n": base64.urlsafe_b64encode(n_bytes).rstrip(b"=").decode(),
|
||||
"e": base64.urlsafe_b64encode(e_bytes).rstrip(b"=").decode(),
|
||||
}
|
||||
|
||||
|
||||
PRIVATE_KEY, PUBLIC_KEY = _generate_rsa_keypair()
|
||||
KID = "kc-kid-1"
|
||||
KC_URL = "http://keycloak.test:8080"
|
||||
KC_REALM = "test-realm"
|
||||
KC_CLIENT_ID = "test-kc-client"
|
||||
JWKS = {"keys": [_public_numbers_to_jwk(PUBLIC_KEY, KID)]}
|
||||
|
||||
|
||||
def _make_kc_token(claims: dict, kid: str = KID, expired: bool = False) -> str:
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
base_claims = {
|
||||
"iss": f"{KC_URL}/realms/{KC_REALM}",
|
||||
"aud": KC_CLIENT_ID,
|
||||
"iat": int(now.timestamp()),
|
||||
"nbf": int(now.timestamp()),
|
||||
"exp": int((now + datetime.timedelta(hours=1)).timestamp()),
|
||||
"sub": "user-sub-1",
|
||||
"preferred_username": "bob",
|
||||
"name": "Bob Smith",
|
||||
"jti": "kc-jti-1",
|
||||
"realm_access": {"roles": ["user"]},
|
||||
}
|
||||
if expired:
|
||||
base_claims["exp"] = int((now - datetime.timedelta(hours=1)).timestamp())
|
||||
base_claims["nbf"] = int((now - datetime.timedelta(hours=2)).timestamp())
|
||||
base_claims["iat"] = int((now - datetime.timedelta(hours=2)).timestamp())
|
||||
base_claims.update(claims)
|
||||
return jose_jwt.encode(
|
||||
base_claims, _private_key_pem(PRIVATE_KEY), algorithm="RS256", headers={"kid": kid}
|
||||
)
|
||||
|
||||
|
||||
def _driver_config(raw_token: str = "") -> dict:
|
||||
return {
|
||||
"_raw_token": raw_token,
|
||||
"keycloak_url": KC_URL,
|
||||
"keycloak_realm": KC_REALM,
|
||||
"keycloak_client_id": KC_CLIENT_ID,
|
||||
"domain": "example.com",
|
||||
"did_template": "did:web:{domain}/principal/{alias}",
|
||||
"elevated_suffix": "-elevated",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_jwks_fetch():
|
||||
with patch("gsap_broker.drivers.jwks.httpx.AsyncClient") as mock_http:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = JWKS
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
ctx_manager = AsyncMock()
|
||||
ctx_manager.__aenter__.return_value.get = AsyncMock(return_value=mock_resp)
|
||||
mock_http.return_value = ctx_manager
|
||||
yield mock_http
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_keycloak_jwt_accepted(mock_jwks_fetch):
|
||||
"""C-1: Valid signed Keycloak JWT is accepted."""
|
||||
token = _make_kc_token({})
|
||||
driver = KeycloakDriver(config=_driver_config(raw_token=token))
|
||||
result = await driver.authenticate()
|
||||
assert result.is_authorized
|
||||
assert "bob" in result.principal_did
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forged_keycloak_jwt_rejected(mock_jwks_fetch):
|
||||
"""C-1: Forged JWT (wrong signature) is rejected."""
|
||||
# Create a token signed with a DIFFERENT key
|
||||
other_key, _ = _generate_rsa_keypair()
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
forged = jose_jwt.encode(
|
||||
{
|
||||
"iss": f"{KC_URL}/realms/{KC_REALM}",
|
||||
"aud": KC_CLIENT_ID,
|
||||
"exp": int((now + datetime.timedelta(hours=1)).timestamp()),
|
||||
"sub": "attacker",
|
||||
"preferred_username": "hacker",
|
||||
},
|
||||
_private_key_pem(other_key),
|
||||
algorithm="RS256",
|
||||
headers={"kid": KID},
|
||||
)
|
||||
driver = KeycloakDriver(config=_driver_config(raw_token=forged))
|
||||
result = await driver.authenticate()
|
||||
assert not result.is_authorized
|
||||
assert "verification failed" in result.denial_reason.lower() or "signature" in result.denial_reason.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_keycloak_jwt_rejected(mock_jwks_fetch):
|
||||
token = _make_kc_token({}, expired=True)
|
||||
driver = KeycloakDriver(config=_driver_config(raw_token=token))
|
||||
result = await driver.authenticate()
|
||||
assert not result.is_authorized
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_token_rejected():
|
||||
driver = KeycloakDriver(config=_driver_config(raw_token=""))
|
||||
result = await driver.authenticate()
|
||||
assert not result.is_authorized
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwks_unreachable_rejected():
|
||||
"""JWKS fetch failure denies — no fallback."""
|
||||
with patch("gsap_broker.drivers.jwks.httpx.AsyncClient") as mock_http:
|
||||
ctx = AsyncMock()
|
||||
ctx.__aenter__.return_value.get = AsyncMock(side_effect=Exception("DNS failure"))
|
||||
mock_http.return_value = ctx
|
||||
|
||||
token = _make_kc_token({})
|
||||
driver = KeycloakDriver(config=_driver_config(raw_token=token))
|
||||
result = await driver.authenticate()
|
||||
assert not result.is_authorized
|
||||
assert "JWKS fetch failed" in result.denial_reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alg_none_rejected(mock_jwks_fetch):
|
||||
"""alg=none attack is blocked."""
|
||||
import base64, json
|
||||
header = base64.urlsafe_b64encode(json.dumps({"alg": "none", "typ": "JWT"}).encode()).rstrip(b"=").decode()
|
||||
payload = base64.urlsafe_b64encode(json.dumps({
|
||||
"sub": "attacker", "iss": f"{KC_URL}/realms/{KC_REALM}",
|
||||
"aud": KC_CLIENT_ID, "exp": 9999999999,
|
||||
}).encode()).rstrip(b"=").decode()
|
||||
forged = f"{header}.{payload}."
|
||||
|
||||
driver = KeycloakDriver(config=_driver_config(raw_token=forged))
|
||||
result = await driver.authenticate()
|
||||
assert not result.is_authorized
|
||||
|
|
@ -4,19 +4,8 @@
|
|||
"""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
|
||||
|
|
@ -25,15 +14,6 @@ async def client():
|
|||
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."""
|
||||
|
|
|
|||
|
|
@ -1,156 +0,0 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Security regression tests for audit findings C-2, C-6, C-7, C-9, H-1, H-5."""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, UTC
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from gsap_broker.connectors.base import CAP_MUTATE, CAP_READ, ConnectorContext, ConnectorResult
|
||||
from gsap_broker.connectors.intune import IntuneConnector, _validate_device_id
|
||||
from gsap_broker.credentials.resolver import (
|
||||
BasculeCredential, KerberosCredential, OAuthCredential, SSHCertCredential,
|
||||
)
|
||||
|
||||
|
||||
# ── H-1: Credential repr does not leak secrets ──────────────────
|
||||
|
||||
def test_oauth_credential_repr_hides_token():
|
||||
"""H-1: access_token must not appear in repr."""
|
||||
cred = OAuthCredential(
|
||||
target="target", expires_at=datetime.now(UTC) + timedelta(hours=1),
|
||||
access_token="super-secret-token-123",
|
||||
)
|
||||
r = repr(cred)
|
||||
assert "super-secret-token-123" not in r
|
||||
|
||||
|
||||
def test_kerberos_credential_repr_hides_ticket():
|
||||
"""H-1: ticket bytes must not appear in repr."""
|
||||
cred = KerberosCredential(
|
||||
target="target", expires_at=datetime.now(UTC) + timedelta(hours=1),
|
||||
ticket=b"SECRET_KERBEROS_TICKET_BYTES",
|
||||
)
|
||||
r = repr(cred)
|
||||
assert "SECRET_KERBEROS_TICKET_BYTES" not in r
|
||||
|
||||
|
||||
def test_ssh_credential_repr_hides_key():
|
||||
"""H-1: private_key must not appear in repr."""
|
||||
cred = SSHCertCredential(
|
||||
target="target", expires_at=datetime.now(UTC) + timedelta(hours=1),
|
||||
certificate="cert-data", private_key="PRIVATE-KEY-MATERIAL",
|
||||
)
|
||||
r = repr(cred)
|
||||
assert "PRIVATE-KEY-MATERIAL" not in r
|
||||
|
||||
|
||||
# ── H-5: device_id path traversal rejected ──────────────────────
|
||||
|
||||
def test_device_id_path_traversal_rejected():
|
||||
"""H-5: path traversal in device_id is rejected."""
|
||||
with pytest.raises(ValueError, match="UUID format"):
|
||||
_validate_device_id("../../users/admin")
|
||||
|
||||
|
||||
def test_device_id_valid_uuid_accepted():
|
||||
"""H-5: valid UUID device_id is accepted."""
|
||||
result = _validate_device_id("550e8400-e29b-41d4-a716-446655440000")
|
||||
assert result == "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
|
||||
def test_device_id_empty_rejected():
|
||||
with pytest.raises(ValueError, match="device_id required"):
|
||||
_validate_device_id("")
|
||||
|
||||
|
||||
def test_device_id_not_uuid_rejected():
|
||||
with pytest.raises(ValueError, match="UUID format"):
|
||||
_validate_device_id("not-a-uuid")
|
||||
|
||||
|
||||
# ── C-6: capability_mask enforcement ─────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_only_ac_cannot_invoke_wipe():
|
||||
"""C-6: READ-only AC must be denied for MUTATE operations."""
|
||||
mock_graph = MagicMock()
|
||||
mock_graph.tenant_id = "t"
|
||||
mock_graph.client_id = "c"
|
||||
connector = IntuneConnector(graph_client=mock_graph)
|
||||
|
||||
# Verify the connector declares wipe as MUTATE
|
||||
assert connector.capability_for_operation("wipe_device") == CAP_MUTATE
|
||||
assert connector.capability_for_operation("remote_lock") == CAP_MUTATE
|
||||
assert connector.capability_for_operation("retire_device") == CAP_MUTATE
|
||||
|
||||
# Verify READ operations are READ
|
||||
assert connector.capability_for_operation("list_devices") == CAP_READ
|
||||
assert connector.capability_for_operation("get_compliance") == CAP_READ
|
||||
|
||||
|
||||
# ── C-9: delegation capability bounding ──────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegation_capability_exceeding_delegator_rejected():
|
||||
"""C-9: delegated capability cannot exceed delegator's."""
|
||||
from gsap_broker.delegations.lifecycle import DelegationManager, _capability_mask_for
|
||||
from gsap_broker.delegations.models import DelegationRequest, DelegationScope
|
||||
|
||||
manager = DelegationManager()
|
||||
request = DelegationRequest(
|
||||
delegator_ac_id="test-ac",
|
||||
agent_type="claude-code",
|
||||
scope=DelegationScope(capability_ceiling="CAP_ADMIN"),
|
||||
)
|
||||
|
||||
# Delegator has only READ capability (mask=1)
|
||||
with pytest.raises(ValueError, match="exceeds delegator"):
|
||||
await manager.create_delegation(
|
||||
request, delegator_did="did:web:test/p/alice",
|
||||
delegator_capability_mask=CAP_READ,
|
||||
)
|
||||
|
||||
|
||||
# ── C-2: on_behalf_of gate ───────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_behalf_of_without_impersonate_role_rejected(mocker):
|
||||
"""C-2: on_behalf_of without gsap:impersonate role is rejected."""
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlmodel import SQLModel
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from gsap_broker.app import app
|
||||
from gsap_broker import db as db_module
|
||||
from gsap_broker.drivers.base import AuthResult
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///./test_security.db")
|
||||
db_module.engine = engine
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
try:
|
||||
mocker.patch(
|
||||
"gsap_broker.drivers.keycloak.KeycloakDriver.authenticate",
|
||||
return_value=AuthResult(
|
||||
status=AuthResult.STATUS_AUTHORIZED,
|
||||
principal_did="did:web:test/p/alice",
|
||||
token_jti="jti",
|
||||
),
|
||||
)
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.post("/governance/authorize/", json={
|
||||
"playbook": "test",
|
||||
"corpus_entry_cid": "sha256:" + "a" * 64,
|
||||
"parameters_cid": "sha256:" + "b" * 64,
|
||||
"accord_template": "test",
|
||||
"driver_id": "keycloak",
|
||||
"on_behalf_of": "did:web:test/p/admin",
|
||||
})
|
||||
assert resp.status_code == 403
|
||||
assert "gsap:impersonate" in resp.json()["detail"]
|
||||
finally:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.drop_all)
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
# 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,95 +121,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.1"
|
||||
|
|
@ -342,9 +253,6 @@ dev = [
|
|||
{ name = "pytest-mock" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
entra = [
|
||||
{ name = "msal" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
|
|
@ -352,7 +260,6 @@ requires-dist = [
|
|||
{ name = "fastapi", specifier = ">=0.111.0" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
|
||||
{ name = "msal", marker = "extra == 'entra'", specifier = ">=1.28.0" },
|
||||
{ name = "pydantic", specifier = ">=2.7.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.2.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
||||
|
|
@ -364,7 +271,7 @@ requires-dist = [
|
|||
{ name = "structlog", specifier = ">=24.1.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" },
|
||||
]
|
||||
provides-extras = ["dev", "entra"]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
|
|
@ -509,20 +416,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "msal"
|
||||
version = "1.36.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/cb/b02b0f748ac668922364ccb3c3bff5b71628a05f5adfec2ba2a5c3031483/msal-1.36.0.tar.gz", hash = "sha256:3f6a4af2b036b476a4215111c4297b4e6e236ed186cd804faefba23e4990978b", size = 174217, upload-time = "2026-04-09T10:20:33.525Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/d3/414d1f0a5f6f4fe5313c2b002c54e78a3332970feb3f5fed14237aa17064/msal-1.36.0-py3-none-any.whl", hash = "sha256:36ecac30e2ff4322d956029aabce3c82301c29f0acb1ad89b94edcabb0e58ec4", size = 121547, upload-time = "2026-04-09T10:20:32.336Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
|
|
@ -694,20 +587,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
crypto = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
|
|
@ -832,21 +711,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.33.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "4.9.1"
|
||||
|
|
@ -1002,15 +866,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.42.0"
|
||||
|
|
|
|||
Loading…
Reference in a new issue