Compare commits

..

10 commits

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

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

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

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

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

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

Shared BastionClient for all plugins using stdlib urllib.

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

BOUNDARY: loader never touches automation framework files.

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

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

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

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

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

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

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

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

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 06:00:48 -04:00
51 changed files with 4105 additions and 265 deletions

1
.gitignore vendored
View file

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

366
ROADMAP.md Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -25,6 +25,9 @@ logger = structlog.get_logger()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await init_db() 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)) cleanup_task = asyncio.create_task(delegation_cleanup_loop(delegation_manager))
logger.info( logger.info(
"fastapi-gsap started", "fastapi-gsap started",

View file

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

View file

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

View file

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

View file

@ -7,6 +7,12 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any 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 @dataclass
class ConnectorContext: class ConnectorContext:
@ -15,6 +21,9 @@ class ConnectorContext:
credentials: dict[str, Any] = field(default_factory=dict) credentials: dict[str, Any] = field(default_factory=dict)
pipeline_run_id: str = "" pipeline_run_id: str = ""
dag_id: str = "" dag_id: str = ""
# Fix C-6: capability_mask from the AC, enforced by ConnectorRuntime
capability_mask: int = 0
principal_did: str = ""
@dataclass @dataclass
@ -36,6 +45,13 @@ class ConnectorPlugin(ABC):
accord_template: str = "" accord_template: str = ""
gsap_required: bool = True gsap_required: bool = True
chronicle_enabled: 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 @abstractmethod
async def invoke( async def invoke(

View file

@ -14,7 +14,12 @@ import logging
from datetime import datetime, UTC from datetime import datetime, UTC
from typing import Any from typing import Any
from gsap_broker.connectors.base import ConnectorContext, ConnectorPlugin, ConnectorResult import re
from gsap_broker.connectors.base import (
CAP_MUTATE, CAP_PROPOSE, CAP_READ,
ConnectorContext, ConnectorPlugin, ConnectorResult,
)
from gsap_broker.intune.device_cache import DeviceComplianceCache from gsap_broker.intune.device_cache import DeviceComplianceCache
from gsap_broker.intune.graph_client import GraphClient from gsap_broker.intune.graph_client import GraphClient
from gsap_broker.models.intune import ComplianceState, DeviceSummary from gsap_broker.models.intune import ComplianceState, DeviceSummary
@ -23,6 +28,21 @@ logger = logging.getLogger(__name__)
_GRAPH_DEVICES = "/deviceManagement/managedDevices" _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): class IntuneConnector(ConnectorPlugin):
connector_id = "intune" connector_id = "intune"
@ -34,6 +54,16 @@ class IntuneConnector(ConnectorPlugin):
accord_template = "device-management" accord_template = "device-management"
gsap_required = True gsap_required = True
chronicle_enabled = 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): def __init__(self, graph_client: GraphClient, cache: DeviceComplianceCache | None = None):
self.graph = graph_client self.graph = graph_client
@ -94,18 +124,20 @@ class IntuneConnector(ConnectorPlugin):
async def _get_device( async def _get_device(
self, params: dict[str, Any], ctx: ConnectorContext self, params: dict[str, Any], ctx: ConnectorContext
) -> ConnectorResult: ) -> ConnectorResult:
device_id = params.get("device_id", "") try:
if not device_id: device_id = _validate_device_id(params.get("device_id", ""))
return ConnectorResult(success=False, error="device_id required") except ValueError as e:
return ConnectorResult(success=False, error=str(e))
data = await self.graph.get(f"{_GRAPH_DEVICES}/{device_id}") data = await self.graph.get(f"{_GRAPH_DEVICES}/{device_id}")
return ConnectorResult(success=True, data=data) return ConnectorResult(success=True, data=data)
async def _get_compliance( async def _get_compliance(
self, params: dict[str, Any], ctx: ConnectorContext self, params: dict[str, Any], ctx: ConnectorContext
) -> ConnectorResult: ) -> ConnectorResult:
device_id = params.get("device_id", "") try:
if not device_id: device_id = _validate_device_id(params.get("device_id", ""))
return ConnectorResult(success=False, error="device_id required") except ValueError as e:
return ConnectorResult(success=False, error=str(e))
# Check cache first # Check cache first
cached = await self.cache.get(device_id) cached = await self.cache.get(device_id)
@ -132,9 +164,10 @@ class IntuneConnector(ConnectorPlugin):
async def _sync_device( async def _sync_device(
self, params: dict[str, Any], ctx: ConnectorContext self, params: dict[str, Any], ctx: ConnectorContext
) -> ConnectorResult: ) -> ConnectorResult:
device_id = params.get("device_id", "") try:
if not device_id: device_id = _validate_device_id(params.get("device_id", ""))
return ConnectorResult(success=False, error="device_id required") except ValueError as e:
return ConnectorResult(success=False, error=str(e))
resp = await self.graph.post(f"{_GRAPH_DEVICES}/{device_id}/syncDevice") resp = await self.graph.post(f"{_GRAPH_DEVICES}/{device_id}/syncDevice")
if resp.status_code in (200, 204): if resp.status_code in (200, 204):
await self.cache.invalidate(device_id) await self.cache.invalidate(device_id)
@ -146,9 +179,10 @@ class IntuneConnector(ConnectorPlugin):
async def _remote_lock( async def _remote_lock(
self, params: dict[str, Any], ctx: ConnectorContext self, params: dict[str, Any], ctx: ConnectorContext
) -> ConnectorResult: ) -> ConnectorResult:
device_id = params.get("device_id", "") try:
if not device_id: device_id = _validate_device_id(params.get("device_id", ""))
return ConnectorResult(success=False, error="device_id required") except ValueError as e:
return ConnectorResult(success=False, error=str(e))
resp = await self.graph.post(f"{_GRAPH_DEVICES}/{device_id}/remoteLock") resp = await self.graph.post(f"{_GRAPH_DEVICES}/{device_id}/remoteLock")
if resp.status_code in (200, 204): if resp.status_code in (200, 204):
return ConnectorResult(success=True, data={"locked": True}) return ConnectorResult(success=True, data={"locked": True})
@ -157,9 +191,10 @@ class IntuneConnector(ConnectorPlugin):
async def _retire_device( async def _retire_device(
self, params: dict[str, Any], ctx: ConnectorContext self, params: dict[str, Any], ctx: ConnectorContext
) -> ConnectorResult: ) -> ConnectorResult:
device_id = params.get("device_id", "") try:
if not device_id: device_id = _validate_device_id(params.get("device_id", ""))
return ConnectorResult(success=False, error="device_id required") except ValueError as e:
return ConnectorResult(success=False, error=str(e))
resp = await self.graph.post(f"{_GRAPH_DEVICES}/{device_id}/retire") resp = await self.graph.post(f"{_GRAPH_DEVICES}/{device_id}/retire")
if resp.status_code in (200, 204): if resp.status_code in (200, 204):
return ConnectorResult(success=True, data={"retired": True}) return ConnectorResult(success=True, data={"retired": True})
@ -168,9 +203,10 @@ class IntuneConnector(ConnectorPlugin):
async def _wipe_device( async def _wipe_device(
self, params: dict[str, Any], ctx: ConnectorContext self, params: dict[str, Any], ctx: ConnectorContext
) -> ConnectorResult: ) -> ConnectorResult:
device_id = params.get("device_id", "") try:
if not device_id: device_id = _validate_device_id(params.get("device_id", ""))
return ConnectorResult(success=False, error="device_id required") except ValueError as e:
return ConnectorResult(success=False, error=str(e))
resp = await self.graph.post(f"{_GRAPH_DEVICES}/{device_id}/wipe") resp = await self.graph.post(f"{_GRAPH_DEVICES}/{device_id}/wipe")
if resp.status_code in (200, 204): if resp.status_code in (200, 204):
return ConnectorResult(success=True, data={"wiped": True}) return ConnectorResult(success=True, data={"wiped": True})

View file

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

View file

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

View file

@ -148,27 +148,16 @@ class EntraCredentialBackend(CredentialBackend):
async def _resolve_kerberos(self, target: str, ac_context: dict) -> KerberosCredential: async def _resolve_kerberos(self, target: str, ac_context: dict) -> KerberosCredential:
"""Acquire Kerberos ticket via Entra cloud trust. """Acquire Kerberos ticket via Entra cloud trust.
Stubbed actual implementation depends on the hybrid Fix H-3: raises instead of returning stub ticket. Actual
environment: implementation depends on the hybrid environment:
- Pure Entra: use Entra Kerberos proxy (preview API) - Pure Entra: use Entra Kerberos proxy (preview API)
- Hybrid with on-prem AD: use OBO to get a token, then - Hybrid with on-prem AD: use OBO to get a token, then
exchange for a Kerberos ticket via the KDC proxy exchange for a Kerberos ticket via the KDC proxy
- Direct KDC: use kinit with the OBO token (requires - Direct KDC: use kinit with the OBO token
krb5.conf pointing to the right KDC)
For now returns a placeholder ticket. The PowerShell Implementation deferred to hybrid environment sprint.
connector's transport is also stubbed, so this is
consistent both stubs will be replaced together when
PSRemoting integration lands.
""" """
logger.warning( raise CredentialResolutionError(
"Kerberos credential resolution is stubbed — " "Kerberos credential resolution not yet implemented. "
"returning placeholder for target=%s", "Configure an on-premises KDC or use OAuth credentials."
target,
)
return KerberosCredential(
target=target,
expires_at=datetime.now(UTC) + timedelta(minutes=5),
scoped_to=target,
ticket=b"STUB_KERBEROS_TICKET",
) )

View file

@ -76,46 +76,36 @@ class BasculeCredential(Credential):
""" """
credential_type: str = field(default="bascule_ac", init=False) credential_type: str = field(default="bascule_ac", init=False)
authorization_context: dict = field(default_factory=dict) # Fix H-1: repr=False prevents AC data leaking into logs
authorization_context: dict = field(default_factory=dict, repr=False)
@dataclass @dataclass
class KerberosCredential(Credential): 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) credential_type: str = field(default="kerberos", init=False)
ticket: bytes = b"" # Fix H-1: repr=False prevents ticket bytes leaking into logs
ticket: bytes = field(default=b"", repr=False)
@dataclass @dataclass
class OAuthCredential(Credential): 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) credential_type: str = field(default="oauth", init=False)
access_token: str = "" # Fix H-1: repr=False prevents access token leaking into logs
access_token: str = field(default="", repr=False)
@dataclass @dataclass
class SSHCertCredential(Credential): 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) credential_type: str = field(default="ssh_cert", init=False)
certificate: str = "" # Fix H-1: repr=False prevents key material leaking into logs
private_key: str = "" certificate: str = field(default="", repr=False)
private_key: str = field(default="", repr=False)
# ── Errors ─────────────────────────────────────────────────────── # ── Errors ───────────────────────────────────────────────────────

View file

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

View file

@ -44,10 +44,19 @@ class DelegationManager:
self.registrar = create_registrar(config) self.registrar = create_registrar(config)
async def create_delegation( async def create_delegation(
self, request: DelegationRequest, delegator_did: str self, request: DelegationRequest, delegator_did: str,
delegator_capability_mask: int = 0x7,
) -> DelegationResponse: ) -> DelegationResponse:
delegation_id = f"del-{uuid.uuid4().hex[:8]}" delegation_id = f"del-{uuid.uuid4().hex[:8]}"
scope = request.scope or DelegationScope() 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) now = datetime.now(UTC)
expires_at = now + timedelta(minutes=scope.max_ttl_minutes) expires_at = now + timedelta(minutes=scope.max_ttl_minutes)

View file

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

View file

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

View file

@ -6,98 +6,56 @@
Validates Entra-issued JWTs directly via JWKS verification. Validates Entra-issued JWTs directly via JWKS verification.
Extracts device_id for compliance gating, MFA status, roles, Extracts device_id for compliance gating, MFA status, roles,
and constructs DID from Entra tenant + oid. 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 import logging
import time from typing import Optional
from typing import Any, Optional
import httpx
from jose import JWTError, jwt as jose_jwt
from .base import AuthResult, ElevationRequired, IdentityDriver from .base import AuthResult, ElevationRequired, IdentityDriver
from .jwks import AuthenticationError, JWKSVerifier
logger = logging.getLogger(__name__) 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): class EntraDriver(IdentityDriver):
"""Identity driver for direct Entra JWT validation.""" """Identity driver for direct Entra JWT validation."""
async def authenticate(self) -> AuthResult: async def authenticate(self) -> AuthResult:
token_data = self.config.get("_token_data", {})
raw_token = self.config.get("_raw_token", "") raw_token = self.config.get("_raw_token", "")
tenant_id = self.config.get("entra_tenant_id", "") tenant_id = self.config.get("entra_tenant_id", "")
expected_audience = self.config.get("entra_client_id", "") expected_audience = self.config.get("entra_client_id", "")
if not token_data: if not raw_token:
return AuthResult( return AuthResult(
status=AuthResult.STATUS_DENIED, status=AuthResult.STATUS_DENIED,
denial_reason="No token in context.", denial_reason="No token in context.",
) )
# If we have raw_token and tenant_id, perform JWKS verification. if not tenant_id:
if raw_token and tenant_id: return AuthResult(
try: status=AuthResult.STATUS_DENIED,
jwks = await _get_jwks(tenant_id) denial_reason="Entra tenant_id not configured.",
)
# Extract kid from unverified header # Fix C-3: verify via JWKS — no fallback on failure
unverified_header = jose_jwt.get_unverified_header(raw_token) verifier = JWKSVerifier(
kid = unverified_header.get("kid", "") jwks_url=f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys",
signing_key = _find_signing_key(jwks, kid) audience=expected_audience,
if not signing_key: issuer=f"https://login.microsoftonline.com/{tenant_id}/v2.0",
return AuthResult( )
status=AuthResult.STATUS_DENIED, try:
denial_reason=f"No matching signing key for kid={kid}", token_data = await verifier.verify_or_refresh(raw_token)
) except AuthenticationError as e:
return AuthResult(
status=AuthResult.STATUS_DENIED,
denial_reason=str(e),
)
# Verify signature, exp, nbf, iss, aud # Extract claims from VERIFIED token data
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", "") oid = token_data.get("oid", "")
tid = token_data.get("tid", tenant_id) tid = token_data.get("tid", tenant_id)
roles = token_data.get("roles", []) roles = token_data.get("roles", [])

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

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

View file

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

View file

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

View file

@ -60,6 +60,10 @@ async def authorize(body: AuthorizeRequest, http_request: Request, db: AsyncSess
"_raw_token": raw_token, "_raw_token": raw_token,
"entra_tenant_id": settings.entra_tenant_id, "entra_tenant_id": settings.entra_tenant_id,
"entra_client_id": settings.entra_client_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: except KeyError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@ -136,7 +140,17 @@ async def authorize(body: AuthorizeRequest, http_request: Request, db: AsyncSess
expires = now + timedelta(minutes=settings.ac_ttl_minutes) expires = now + timedelta(minutes=settings.ac_ttl_minutes)
ctx_id = uuid.uuid4() ctx_id = uuid.uuid4()
# on_behalf_of: trusted caller (Bascule SA) asserts who the AC is for # Fix C-2: on_behalf_of requires gsap:impersonate role
if request.on_behalf_of:
caller_roles = getattr(auth_result, "elevation_active", [])
# Check for impersonation role in JWT roles (passed through auth_result)
token_roles = token_data.get("roles", []) + token_data.get("realm_access", {}).get("roles", [])
if "gsap:impersonate" not in token_roles:
raise HTTPException(
status_code=403,
detail="on_behalf_of requires gsap:impersonate role.",
)
principal_did = request.on_behalf_of or auth_result.principal_did 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 display_name = request.on_behalf_of.rsplit("/", 1)[-1] if request.on_behalf_of else auth_result.display_name

View file

@ -21,7 +21,35 @@ _runtime = ConnectorRuntime(registry=_registry)
# Register built-in connectors # Register built-in connectors
_registry.register(EchoConnector()) _registry.register(EchoConnector())
# Conditionally register Intune connector # ── Credential resolver (shared by session connectors) ──────────
from gsap_broker.credentials.resolver import CredentialResolver
_credential_resolver = CredentialResolver()
if settings.credential_backend == "stub" or (
settings.credential_backend == "auto" and not settings.entra_client_secret
):
# Fix H-2: stub backend requires explicit opt-in
if not settings.allow_stub_credentials:
import logging as _logging
_logging.getLogger(__name__).warning(
"StubCredentialBackend would activate but ALLOW_STUB_CREDENTIALS is not set. "
"Session connectors will have no credential backend."
)
else:
from gsap_broker.credentials.stub_backend import StubCredentialBackend
_credential_resolver.register(StubCredentialBackend())
elif settings.entra_client_secret:
from gsap_broker.credentials.entra_backend import EntraCredentialBackend
_credential_resolver.register(EntraCredentialBackend(
tenant_id=settings.entra_tenant_id,
client_id=settings.entra_client_id,
client_secret=settings.entra_client_secret,
))
# ── Conditionally register connectors ───────────────────────────
# Intune (API-mediated — uses GraphClient, not CredentialResolver)
if settings.intune_enabled and settings.entra_client_secret: if settings.intune_enabled and settings.entra_client_secret:
from gsap_broker.intune.graph_client import GraphClient from gsap_broker.intune.graph_client import GraphClient
from gsap_broker.intune.device_cache import DeviceComplianceCache from gsap_broker.intune.device_cache import DeviceComplianceCache
@ -36,6 +64,21 @@ if settings.intune_enabled and settings.entra_client_secret:
_intune_connector = IntuneConnector(graph_client=_intune_graph, cache=_intune_cache) _intune_connector = IntuneConnector(graph_client=_intune_graph, cache=_intune_cache)
_registry.register(_intune_connector) _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): class InvokeRequest(BaseModel):
operation: str operation: str
@ -44,6 +87,9 @@ class InvokeRequest(BaseModel):
gsap_context_id: str = "" gsap_context_id: str = ""
pipeline_run_id: str = "" pipeline_run_id: str = ""
dag_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): class InvokeResponse(BaseModel):
@ -79,6 +125,8 @@ async def invoke_connector(connector_id: str, body: InvokeRequest) -> InvokeResp
gsap_context_id=body.gsap_context_id, gsap_context_id=body.gsap_context_id,
pipeline_run_id=body.pipeline_run_id, pipeline_run_id=body.pipeline_run_id,
dag_id=body.dag_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) result = await _runtime.invoke(connector_id, body.operation, body.parameters, ctx)
return InvokeResponse( return InvokeResponse(

View file

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

View file

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

View file

@ -41,6 +41,18 @@ class Settings(BaseSettings):
intune_compliance_strict: bool = False # reject if no device_id present intune_compliance_strict: bool = False # reject if no device_id present
intune_compliance_cache_ttl: int = 300 # seconds 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 # Delegation defaults
default_delegation_ttl_minutes: int = 60 default_delegation_ttl_minutes: int = 60
default_max_commands: int = 500 default_max_commands: int = 500

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

316
tests/test_credentials.py Normal file
View file

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

View file

@ -1,19 +1,18 @@
# Copyright 2026 Guildhouse Dev # Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
"""Tests for the Entra identity driver.""" """Tests for the Entra identity driver — C-3, H-10."""
import time
import pytest import pytest
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from jose import jwt as jose_jwt from jose import jwt as jose_jwt
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from gsap_broker.drivers.entra import EntraDriver, _jwks_cache from gsap_broker.drivers.entra import EntraDriver
from gsap_broker.drivers.jwks import AuthenticationError
def _generate_rsa_keypair(): 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) private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key() public_key = private_key.public_key()
return private_key, public_key return private_key, public_key
@ -28,7 +27,6 @@ def _private_key_pem(private_key):
def _public_numbers_to_jwk(public_key, kid="test-kid-1"): def _public_numbers_to_jwk(public_key, kid="test-kid-1"):
"""Convert RSA public key to JWK dict."""
import base64 import base64
nums = public_key.public_numbers() nums = public_key.public_numbers()
e_bytes = nums.e.to_bytes((nums.e.bit_length() + 7) // 8, "big") e_bytes = nums.e.to_bytes((nums.e.bit_length() + 7) // 8, "big")
@ -51,7 +49,6 @@ JWKS = {"keys": [_public_numbers_to_jwk(PUBLIC_KEY, KID)]}
def _make_token(claims: dict, kid: str = KID, expired: bool = False) -> str: def _make_token(claims: dict, kid: str = KID, expired: bool = False) -> str:
"""Create a signed test JWT."""
import datetime import datetime
now = datetime.datetime.now(datetime.UTC) now = datetime.datetime.now(datetime.UTC)
base_claims = { base_claims = {
@ -77,18 +74,7 @@ def _make_token(claims: dict, kid: str = KID, expired: bool = False) -> str:
def _driver_config(raw_token: str = "", extra: dict = None) -> dict: 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 = { config = {
"_token_data": token_data,
"_raw_token": raw_token, "_raw_token": raw_token,
"entra_tenant_id": TENANT_ID, "entra_tenant_id": TENANT_ID,
"entra_client_id": CLIENT_ID, "entra_client_id": CLIENT_ID,
@ -99,84 +85,71 @@ def _driver_config(raw_token: str = "", extra: dict = None) -> dict:
return config return config
@pytest.fixture(autouse=True)
def clear_jwks_cache():
_jwks_cache.clear()
yield
_jwks_cache.clear()
@pytest.fixture @pytest.fixture
def mock_jwks(): def mock_jwks_fetch():
"""Mock the JWKS fetch to return test keys.""" """Mock the JWKS HTTP fetch to return test keys."""
with patch("gsap_broker.drivers.entra._get_jwks", new_callable=AsyncMock) as m: with patch("gsap_broker.drivers.jwks.httpx.AsyncClient") as mock_http:
m.return_value = JWKS import unittest.mock
yield m mock_resp = unittest.mock.MagicMock()
mock_resp.json.return_value = JWKS
mock_resp.raise_for_status = unittest.mock.MagicMock()
ctx_manager = AsyncMock()
ctx_manager.__aenter__.return_value.get = AsyncMock(return_value=mock_resp)
mock_http.return_value = ctx_manager
yield mock_http
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_authenticate_valid_token(mock_jwks): async def test_authenticate_valid_token(mock_jwks_fetch):
token = _make_token({"roles": ["admin"], "amr": ["pwd", "mfa"]}) token = _make_token({"roles": ["admin"], "amr": ["pwd", "mfa"]})
driver = EntraDriver(config=_driver_config(raw_token=token)) driver = EntraDriver(config=_driver_config(raw_token=token))
result = await driver.authenticate() result = await driver.authenticate()
assert result.is_authorized assert result.is_authorized
assert result.principal_did == "did:web:contoso.com:principal:user-oid-1" 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 assert result.mfa_satisfied is True
assert result.device_id is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_authenticate_extracts_device_id(mock_jwks): async def test_authenticate_extracts_device_id(mock_jwks_fetch):
token = _make_token({"deviceid": "device-abc-123"}) token = _make_token({"deviceid": "device-abc-123"})
driver = EntraDriver(config=_driver_config(raw_token=token)) driver = EntraDriver(config=_driver_config(raw_token=token))
result = await driver.authenticate() result = await driver.authenticate()
assert result.is_authorized
assert result.device_id == "device-abc-123" assert result.device_id == "device-abc-123"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_authenticate_no_device_id(mock_jwks): async def test_authenticate_no_device_id(mock_jwks_fetch):
token = _make_token({}) token = _make_token({})
driver = EntraDriver(config=_driver_config(raw_token=token)) driver = EntraDriver(config=_driver_config(raw_token=token))
result = await driver.authenticate() result = await driver.authenticate()
assert result.is_authorized
assert result.device_id is None assert result.device_id is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_authenticate_expired_token(mock_jwks): async def test_authenticate_expired_token(mock_jwks_fetch):
token = _make_token({}, expired=True) token = _make_token({}, expired=True)
driver = EntraDriver(config=_driver_config(raw_token=token)) driver = EntraDriver(config=_driver_config(raw_token=token))
result = await driver.authenticate() result = await driver.authenticate()
assert not result.is_authorized assert not result.is_authorized
assert "expired" in result.denial_reason.lower() or "verification failed" in result.denial_reason.lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_authenticate_no_token(): async def test_authenticate_no_token():
driver = EntraDriver(config={"_token_data": {}, "_raw_token": ""}) driver = EntraDriver(config={"_raw_token": "", "entra_tenant_id": TENANT_ID})
result = await driver.authenticate() result = await driver.authenticate()
assert not result.is_authorized assert not result.is_authorized
assert "No token" in result.denial_reason assert "No token" in result.denial_reason
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_authenticate_mfa_detection(mock_jwks): async def test_authenticate_mfa_detection(mock_jwks_fetch):
# With MFA
token = _make_token({"amr": ["pwd", "mfa"]}) token = _make_token({"amr": ["pwd", "mfa"]})
driver = EntraDriver(config=_driver_config(raw_token=token)) driver = EntraDriver(config=_driver_config(raw_token=token))
result = await driver.authenticate() result = await driver.authenticate()
assert result.mfa_satisfied is True assert result.mfa_satisfied is True
# Without MFA
token = _make_token({"amr": ["pwd"]}) token = _make_token({"amr": ["pwd"]})
driver = EntraDriver(config=_driver_config(raw_token=token)) driver = EntraDriver(config=_driver_config(raw_token=token))
result = await driver.authenticate() result = await driver.authenticate()
@ -184,7 +157,7 @@ async def test_authenticate_mfa_detection(mock_jwks):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_authenticate_elevation_required(mock_jwks): async def test_authenticate_elevation_required(mock_jwks_fetch):
token = _make_token({"roles": ["reader"]}) token = _make_token({"roles": ["reader"]})
config = _driver_config(raw_token=token, extra={ config = _driver_config(raw_token=token, extra={
"requested_accord": "admin-ops", "requested_accord": "admin-ops",
@ -192,26 +165,41 @@ async def test_authenticate_elevation_required(mock_jwks):
}) })
driver = EntraDriver(config=config) driver = EntraDriver(config=config)
result = await driver.authenticate() result = await driver.authenticate()
assert result.needs_elevation assert result.needs_elevation
assert result.elevation_required.role == "admin-role"
assert result.elevation_required.mechanism == "entra_pim"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_did_construction(mock_jwks): async def test_did_construction(mock_jwks_fetch):
token = _make_token({"oid": "unique-user-oid"}) token = _make_token({"oid": "unique-user-oid"})
driver = EntraDriver(config=_driver_config(raw_token=token, extra={"domain": "example.dev"})) driver = EntraDriver(config=_driver_config(raw_token=token, extra={"domain": "example.dev"}))
result = await driver.authenticate() result = await driver.authenticate()
assert result.principal_did == "did:web:example.dev:principal:unique-user-oid" assert result.principal_did == "did:web:example.dev:principal:unique-user-oid"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_wrong_kid_rejected(mock_jwks): async def test_wrong_kid_rejected_then_refreshed(mock_jwks_fetch):
"""H-10: kid miss triggers JWKS refresh. With only one JWKS response,
the second fetch still has the same keys, so unknown kid is rejected."""
token = _make_token({}, kid="unknown-kid") token = _make_token({}, kid="unknown-kid")
driver = EntraDriver(config=_driver_config(raw_token=token)) driver = EntraDriver(config=_driver_config(raw_token=token))
result = await driver.authenticate() result = await driver.authenticate()
assert not result.is_authorized assert not result.is_authorized
assert "signing key" in result.denial_reason.lower() assert "signing key" in result.denial_reason.lower() or "key" in result.denial_reason.lower()
@pytest.mark.asyncio
async def test_jwks_failure_denies_no_fallback():
"""C-3: JWKS fetch failure results in denial, no fallback."""
with patch("gsap_broker.drivers.jwks.httpx.AsyncClient") as mock_http:
ctx_manager = AsyncMock()
ctx_manager.__aenter__.return_value.get = AsyncMock(
side_effect=Exception("Network unreachable")
)
mock_http.return_value = ctx_manager
token = _make_token({})
driver = EntraDriver(config=_driver_config(raw_token=token))
result = await driver.authenticate()
assert not result.is_authorized
assert "JWKS fetch failed" in result.denial_reason

View file

@ -87,7 +87,7 @@ async def test_get_compliance_compliant(connector, mock_graph, ctx):
"lastSyncDateTime": "2026-04-14T00:00:00Z", "lastSyncDateTime": "2026-04-14T00:00:00Z",
} }
result = await connector.invoke("get_compliance", {"device_id": "dev-1"}, ctx) result = await connector.invoke("get_compliance", {"device_id": "00000000-0000-0000-0000-000000000001"}, ctx)
assert result.success assert result.success
assert result.data["compliant"] is True assert result.data["compliant"] is True
@ -101,7 +101,7 @@ async def test_get_compliance_noncompliant(connector, mock_graph, ctx):
"complianceState": "noncompliant", "complianceState": "noncompliant",
} }
result = await connector.invoke("get_compliance", {"device_id": "dev-1"}, ctx) result = await connector.invoke("get_compliance", {"device_id": "00000000-0000-0000-0000-000000000001"}, ctx)
assert result.success assert result.success
assert result.data["compliant"] is False 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): async def test_get_compliance_uses_cache(connector, mock_graph, cache, ctx):
# Pre-populate cache # Pre-populate cache
state = ComplianceState( state = ComplianceState(
device_id="dev-cached", compliant=True, state="compliant", device_id="00000000-0000-0000-0000-00000000000c", compliant=True, state="compliant",
last_evaluated=datetime.now(UTC), last_evaluated=datetime.now(UTC),
) )
await cache.set("dev-cached", state) await cache.set("00000000-0000-0000-0000-00000000000c", state)
result = await connector.invoke("get_compliance", {"device_id": "dev-cached"}, ctx) result = await connector.invoke("get_compliance", {"device_id": "00000000-0000-0000-0000-00000000000c"}, ctx)
assert result.success assert result.success
assert result.data["compliant"] is True assert result.data["compliant"] is True
@ -140,7 +140,7 @@ async def test_remote_lock(connector, mock_graph, ctx):
resp.status_code = 204 resp.status_code = 204
mock_graph.post.return_value = resp mock_graph.post.return_value = resp
result = await connector.invoke("remote_lock", {"device_id": "dev-1"}, ctx) result = await connector.invoke("remote_lock", {"device_id": "00000000-0000-0000-0000-000000000001"}, ctx)
assert result.success assert result.success
assert result.data["locked"] is True assert result.data["locked"] is True

View file

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

View file

@ -4,8 +4,19 @@
"""Tests for Intune MCP tools.""" """Tests for Intune MCP tools."""
import pytest import pytest
from unittest.mock import patch
from httpx import AsyncClient, ASGITransport from httpx import AsyncClient, ASGITransport
from gsap_broker.app import app 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 @pytest.fixture
@ -14,6 +25,15 @@ async def client():
yield c yield c
@pytest.fixture(autouse=True)
def bypass_auth():
"""Bypass bearer auth for MCP tests (auth tested separately)."""
from gsap_broker.auth.middleware import verify_bearer
app.dependency_overrides[verify_bearer] = lambda: _mock_auth()
yield
app.dependency_overrides.pop(verify_bearer, None)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mcp_tools_list_includes_intune(client): async def test_mcp_tools_list_includes_intune(client):
"""MCP tools/list should include Intune tools.""" """MCP tools/list should include Intune tools."""

156
tests/test_security.py Normal file
View file

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

180
tests/test_templates.py Normal file
View file

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

147
uv.lock
View file

@ -121,6 +121,95 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, { 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]] [[package]]
name = "click" name = "click"
version = "8.3.1" version = "8.3.1"
@ -253,6 +342,9 @@ dev = [
{ name = "pytest-mock" }, { name = "pytest-mock" },
{ name = "ruff" }, { name = "ruff" },
] ]
entra = [
{ name = "msal" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
@ -260,6 +352,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.111.0" }, { name = "fastapi", specifier = ">=0.111.0" },
{ name = "httpx", specifier = ">=0.27.0" }, { name = "httpx", specifier = ">=0.27.0" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, { 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", specifier = ">=2.7.0" },
{ name = "pydantic-settings", specifier = ">=2.2.0" }, { name = "pydantic-settings", specifier = ">=2.2.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
@ -271,7 +364,7 @@ requires-dist = [
{ name = "structlog", specifier = ">=24.1.0" }, { name = "structlog", specifier = ">=24.1.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" },
] ]
provides-extras = ["dev"] provides-extras = ["dev", "entra"]
[[package]] [[package]]
name = "greenlet" name = "greenlet"
@ -416,6 +509,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, { 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]] [[package]]
name = "packaging" name = "packaging"
version = "26.0" version = "26.0"
@ -587,6 +694,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, { 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]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.2" version = "9.0.2"
@ -711,6 +832,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, { 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]] [[package]]
name = "rsa" name = "rsa"
version = "4.9.1" version = "4.9.1"
@ -866,6 +1002,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, { 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]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.42.0" version = "0.42.0"