feat(nexus): implement sharded architecture
- Create nexus-mcp/ with 6-shard plugin model (identity, workday, audit, itsm, assets, logistics) - Migrate 31 tools from legacy Identity + Workday servers into unified orchestrator - Add feature flag control (ENABLE_*) for atomic shard deployment per Gemini design - Implement SOC 2 audit logging with automatic PII redaction (CC7.2 / CC6.1) - Create stub shards for ITSM, Assets, Logistics (Red status awaiting credentials) - Add comprehensive mock data library with drift scenarios for credential-free testing - Update README.md: reposition from Workday-MCP to Nexus-MCP as primary server - Document installation, configuration, and shard toggling in Local-Setup.md Architecture: Orchestrator (main.py) + Shards (src/shards/*.py) + Adapters (lib/) enables piece-at-a-time deployment. Mock mode (USE_MOCK=true) supports full 53-tool testing without credentials. Smoke test verified: 33 tools registered successfully. BREAKING CHANGE: Legacy Identity/ and Workday/ servers deprecated. Users must update Claude Desktop config to point to nexus-mcp/src/main.py. Legacy folders preserved for reference pending verification. Refs: WIS-006, WIS-009, WIS-014-018, Gemini conversation 2026-04-06
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
# ─── Mock Mode ────────────────────────────────────────────────────────────────
|
||||
# Set USE_MOCK=true to run all 53 tools on synthetic data — no credentials needed.
|
||||
# Every tool checks this flag first; real API clients are never instantiated.
|
||||
# Drift scenarios pre-seeded in lib/mock_data.py for realistic audit testing.
|
||||
USE_MOCK=false
|
||||
|
||||
# ─── SOC 2 Audit Logging (CC7.2 / CC6.1) ─────────────────────────────────────
|
||||
# Every tool call is written as a JSONL record to AUDIT_LOG_FILE.
|
||||
# Records include: event_id, timestamp, tool, shard, action_category,
|
||||
# sanitised args (passwords/tokens auto-redacted), mock_mode flag,
|
||||
# status (success|error), latency_ms, and error details.
|
||||
#
|
||||
# AUDIT_LOG_FILE — path to the append-only JSONL audit trail
|
||||
# AUDIT_LOG_STDERR — also emit each record to stderr (for SIEM / syslog forwarding)
|
||||
# AUDIT_LOGGING_ENABLED — set false only to disable in dev; must be true in prod
|
||||
AUDIT_LOG_FILE=./logs/nexus_audit.jsonl
|
||||
AUDIT_LOG_STDERR=true
|
||||
AUDIT_LOGGING_ENABLED=true
|
||||
|
||||
# ─── Feature Flags ────────────────────────────────────────────────────────────
|
||||
# Set to "true" to enable each shard at startup.
|
||||
# Set to "false" (or omit) to leave a shard in "holding pattern" mode.
|
||||
ENABLE_IDENTITY=true # AD + Entra ID tools
|
||||
ENABLE_WORKDAY=true # Workday HCM tools
|
||||
ENABLE_ITSM=true # BMC Helix ITSM tools
|
||||
ENABLE_ASSETS=true # Lansweeper + Intune tools
|
||||
ENABLE_LOGISTICS=true # FedEx tools
|
||||
ENABLE_AUDIT=true # Cross-system drift & reporting tools
|
||||
|
||||
# ─── Active Directory / LDAP ─────────────────────────────────────────────────
|
||||
AD_SERVER=ldap://your-dc.company.com
|
||||
AD_PORT=389
|
||||
AD_BASE_DN=DC=company,DC=com
|
||||
AD_USER=CN=svc_account,OU=Service Accounts,DC=company,DC=com
|
||||
AD_PASSWORD=your_service_account_password
|
||||
AD_USE_SSL=false
|
||||
|
||||
# ─── Microsoft Entra ID (Azure AD) ───────────────────────────────────────────
|
||||
ENTRA_TENANT_ID=your_tenant_id
|
||||
ENTRA_CLIENT_ID=your_client_id
|
||||
ENTRA_CLIENT_SECRET=your_client_secret
|
||||
|
||||
# ─── Microsoft Intune (same app registration as Entra) ───────────────────────
|
||||
# Uses ENTRA_* vars above by default. Override below if separate app needed.
|
||||
# INTUNE_TENANT_ID=
|
||||
# INTUNE_CLIENT_ID=
|
||||
# INTUNE_CLIENT_SECRET=
|
||||
|
||||
# ─── Workday ─────────────────────────────────────────────────────────────────
|
||||
WORKDAY_BASE_URL=https://wd2-impl-services1.workday.com/ccx/api/v1
|
||||
WORKDAY_TENANT=your_tenant
|
||||
WORKDAY_CLIENT_ID=your_client_id
|
||||
WORKDAY_CLIENT_SECRET=your_client_secret
|
||||
WORKDAY_REFRESH_TOKEN=your_refresh_token
|
||||
|
||||
# ─── BMC Helix (ITSM) ────────────────────────────────────────────────────────
|
||||
HELIX_BASE_URL=https://your-company.helix.bmc.com
|
||||
HELIX_USERNAME=your_username
|
||||
HELIX_PASSWORD=your_password
|
||||
|
||||
# ─── Lansweeper ───────────────────────────────────────────────────────────────
|
||||
LANSWEEPER_API_URL=https://api.lansweeper.com/api/v2/graphql
|
||||
LANSWEEPER_APPLICATION_ID=your_application_id
|
||||
LANSWEEPER_APPLICATION_SECRET=your_application_secret
|
||||
LANSWEEPER_SITE_ID=your_site_id
|
||||
|
||||
# ─── FedEx ───────────────────────────────────────────────────────────────────
|
||||
FEDEX_API_URL=https://apis.fedex.com
|
||||
FEDEX_API_KEY=your_api_key
|
||||
FEDEX_API_SECRET=your_api_secret
|
||||
FEDEX_ACCOUNT_NUMBER=your_account_number
|
||||
|
||||
# ─── Reporting ────────────────────────────────────────────────────────────────
|
||||
REPORT_OUTPUT_DIR=./reports
|
||||
@@ -0,0 +1,333 @@
|
||||
# Nexus-MCP local setup
|
||||
|
||||
Sharded enterprise integration MCP server for Active Directory, Entra ID, Workday, and cross-system drift auditing.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.11 or newer
|
||||
- Git
|
||||
- PowerShell (Windows) or bash (Linux/macOS)
|
||||
- Access to corporate systems (AD, Entra, Workday) or mock mode enabled
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Clone and navigate
|
||||
|
||||
```bash
|
||||
cd /path/to/mcp_servers
|
||||
cd nexus-mcp
|
||||
```
|
||||
|
||||
### 2. Create virtual environment
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
```
|
||||
|
||||
### 3. Activate virtual environment
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
```
|
||||
|
||||
**Windows (bash/Git Bash):**
|
||||
```bash
|
||||
source .venv/Scripts/activate
|
||||
```
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### 4. Install dependencies
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
This installs nexus-mcp in editable mode with all required packages (mcp, httpx, python-dotenv, ldap3, msal, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. Create .env file
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### 2. Choose your mode
|
||||
|
||||
**Option A: Mock Mode (No credentials needed)**
|
||||
|
||||
Good for development, testing shards, and exploring drift scenarios.
|
||||
|
||||
```env
|
||||
USE_MOCK=true
|
||||
|
||||
# Enable the shards you want to test
|
||||
ENABLE_IDENTITY=true
|
||||
ENABLE_WORKDAY=true
|
||||
ENABLE_AUDIT=true
|
||||
ENABLE_ITSM=false
|
||||
ENABLE_ASSETS=false
|
||||
ENABLE_LOGISTICS=false
|
||||
|
||||
# Audit logging
|
||||
AUDIT_LOGGING_ENABLED=true
|
||||
AUDIT_LOG_FILE=./logs/nexus_audit.jsonl
|
||||
AUDIT_LOG_STDERR=true
|
||||
```
|
||||
|
||||
**Option B: Live Mode (Production credentials)**
|
||||
|
||||
Requires actual service accounts and API credentials.
|
||||
|
||||
```env
|
||||
USE_MOCK=false
|
||||
|
||||
# Enable only the shards where you have credentials
|
||||
ENABLE_IDENTITY=true
|
||||
ENABLE_WORKDAY=false # Set true when credentials available
|
||||
ENABLE_AUDIT=true
|
||||
ENABLE_ITSM=false
|
||||
ENABLE_ASSETS=false
|
||||
ENABLE_LOGISTICS=false
|
||||
|
||||
# Active Directory credentials
|
||||
AD_SERVER=ldap://your-dc.company.com
|
||||
AD_PORT=389
|
||||
AD_BASE_DN=DC=company,DC=com
|
||||
AD_USER=CN=svc_nexus,OU=Service Accounts,DC=company,DC=com
|
||||
AD_PASSWORD=your_password
|
||||
AD_USE_SSL=false
|
||||
|
||||
# Microsoft Entra ID (Azure AD)
|
||||
ENTRA_TENANT_ID=your_tenant_id
|
||||
ENTRA_CLIENT_ID=your_client_id
|
||||
ENTRA_CLIENT_SECRET=your_client_secret
|
||||
|
||||
# (Add other system credentials as needed)
|
||||
```
|
||||
|
||||
### 3. Verify configuration
|
||||
|
||||
Your `.env` file is never committed to git (listed in `.gitignore`).
|
||||
|
||||
---
|
||||
|
||||
## Running the server
|
||||
|
||||
### Start the MCP server
|
||||
|
||||
```bash
|
||||
python src/main.py
|
||||
```
|
||||
|
||||
**Expected output (mock mode):**
|
||||
|
||||
```
|
||||
[nexus] ✅ identity shard loaded
|
||||
[nexus] ✅ workday shard loaded
|
||||
[nexus] ✅ audit shard loaded
|
||||
[nexus] ⏸ itsm shard disabled (ENABLE_ITSM != true)
|
||||
[nexus] ⏸ assets shard disabled (ENABLE_ASSETS != true)
|
||||
[nexus] ⏸ logistics shard disabled (ENABLE_LOGISTICS != true)
|
||||
[nexus] 🔒 SOC 2 audit middleware active — 16 tools wrapped → ./logs/nexus_audit.jsonl
|
||||
```
|
||||
|
||||
The server runs on stdio transport and waits for MCP protocol messages.
|
||||
|
||||
---
|
||||
|
||||
## Claude Desktop integration
|
||||
|
||||
Add this to your Claude Desktop `config.json`:
|
||||
|
||||
**Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
|
||||
|
||||
**macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"nexus": {
|
||||
"command": "python",
|
||||
"args": ["src/main.py"],
|
||||
"cwd": "C:/Users/your-username/repos/mcp_servers/nexus-mcp",
|
||||
"env": {
|
||||
"USE_MOCK": "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart Claude Desktop. You should see the Nexus tools in the tool picker.
|
||||
|
||||
---
|
||||
|
||||
## Feature flag reference
|
||||
|
||||
Control which shards load at startup:
|
||||
|
||||
| Flag | Shard | Systems | Default |
|
||||
|---|---|---|---|
|
||||
| `ENABLE_IDENTITY` | identity.py | AD + Entra ID | `true` |
|
||||
| `ENABLE_WORKDAY` | workday.py | Workday HCM | `true` |
|
||||
| `ENABLE_AUDIT` | audit.py | Cross-system drift | `true` |
|
||||
| `ENABLE_ITSM` | itsm.py | BMC Helix | `false` |
|
||||
| `ENABLE_ASSETS` | assets.py | Lansweeper + Intune | `false` |
|
||||
| `ENABLE_LOGISTICS` | logistics.py | FedEx | `false` |
|
||||
|
||||
Set a flag to `false` (or omit it) to put that shard in "holding pattern" mode. The server will start successfully but skip loading those tools.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite:
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
Run specific test modules:
|
||||
|
||||
```bash
|
||||
pytest tests/identity_tests/ -v
|
||||
pytest tests/workday_tests/ -v
|
||||
```
|
||||
|
||||
Run with coverage:
|
||||
|
||||
```bash
|
||||
pytest tests/ --cov=lib --cov=src --cov-report=term-missing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mock mode drift scenarios
|
||||
|
||||
The lib/mock_data.py file includes deliberate drift:
|
||||
|
||||
- **Bob Martinez:** title differs between AD ("Sr. Software Engineer") and Workday/Entra ("Software Engineer")
|
||||
- **Carol Chen:** department differs — Workday "Product Management" vs AD "Engineering"
|
||||
- **David Kim:** AD account disabled but Entra account still enabled
|
||||
- **Emma Wilson:** AD stale account (no login in 120 days)
|
||||
|
||||
Use the audit tools to detect these:
|
||||
|
||||
```python
|
||||
# In Claude chat with Nexus-MCP active
|
||||
"Run audit_user_drift for Bob Martinez"
|
||||
"Show me all stale AD accounts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server won't start
|
||||
|
||||
- Check Python version: `python --version` (must be 3.11+)
|
||||
- Verify virtual environment is active (you should see `(.venv)` in your prompt)
|
||||
- Verify dependencies installed: `pip list | grep mcp`
|
||||
|
||||
### Shard not loading
|
||||
|
||||
- Check the feature flag in `.env`: `ENABLE_IDENTITY=true`
|
||||
- Look for error messages in the startup output
|
||||
- Verify the shard file exists: `ls src/shards/identity.py`
|
||||
|
||||
### Credentials not working (live mode)
|
||||
|
||||
- Test AD connectivity: `python -c "import ldap3; print(ldap3.__version__)"`
|
||||
- Verify service account can bind to LDAP
|
||||
- Check firewall rules if connecting to on-prem AD
|
||||
|
||||
### Audit log not writing
|
||||
|
||||
- Check permissions on the `logs/` directory
|
||||
- Verify `AUDIT_LOGGING_ENABLED=true` in `.env`
|
||||
- Check disk space
|
||||
|
||||
---
|
||||
|
||||
## Development workflow
|
||||
|
||||
### Adding a new tool to an existing shard
|
||||
|
||||
1. Edit the shard file: `src/shards/identity.py`
|
||||
2. Add your tool function inside the `register(mcp)` function
|
||||
3. Restart the server
|
||||
4. Test with Claude Desktop
|
||||
|
||||
### Creating a new shard
|
||||
|
||||
1. Copy an existing shard as a template: `cp src/shards/identity.py src/shards/my_system.py`
|
||||
2. Implement the `register(mcp)` function with your tools
|
||||
3. Add the shard to `src/main.py`:
|
||||
```python
|
||||
if _enabled("MY_SYSTEM"):
|
||||
my_system.register(mcp)
|
||||
```
|
||||
4. Add the feature flag to `.env.example`: `ENABLE_MY_SYSTEM=false`
|
||||
|
||||
### Switching between mock and live mode
|
||||
|
||||
Just change `USE_MOCK` in `.env` and restart:
|
||||
|
||||
```bash
|
||||
# Switch to live
|
||||
USE_MOCK=false
|
||||
|
||||
# Restart server
|
||||
python src/main.py
|
||||
```
|
||||
|
||||
No code changes needed — every shard checks the `USE_MOCK` flag automatically.
|
||||
|
||||
---
|
||||
|
||||
## SOC 2 audit logging
|
||||
|
||||
Every tool call is logged to `logs/nexus_audit.jsonl` (one JSON object per line).
|
||||
|
||||
Each entry contains:
|
||||
- `event_id` — UUID v4 for correlation
|
||||
- `timestamp` — ISO 8601 UTC
|
||||
- `tool` — MCP tool name
|
||||
- `shard` — identity | workday | audit | etc.
|
||||
- `action_category` — READ | AUDIT | REPORT
|
||||
- `args_summary` — call arguments with passwords/tokens redacted
|
||||
- `mock_mode` — true/false
|
||||
- `status` — success | error
|
||||
- `latency_ms` — execution time
|
||||
|
||||
Query the audit log using the built-in tools:
|
||||
|
||||
```python
|
||||
# In Claude chat
|
||||
"Show me the last 50 audit log entries" # calls nexus_audit_recent
|
||||
"Give me audit statistics" # calls nexus_audit_stats
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next steps
|
||||
|
||||
1. Test mock mode with `USE_MOCK=true` to verify the shards load
|
||||
2. Explore the drift scenarios using audit tools
|
||||
3. Configure credentials for live systems
|
||||
4. Enable additional shards as credentials become available
|
||||
5. Build custom reports using the audit shard tools
|
||||
|
||||
For more details, see [README.md](README.md) for the full tool reference.
|
||||
@@ -0,0 +1,202 @@
|
||||
# Nexus-MCP — Enterprise Integration Server
|
||||
|
||||
Sharded Model Context Protocol server for enterprise systems.
|
||||
Each shard is self-contained and can be toggled independently via feature flags.
|
||||
|
||||
---
|
||||
|
||||
## Shard Status Board
|
||||
|
||||
| Shard | System(s) | Status | WIS Ref | Flag |
|
||||
|---|---|---|---|---|
|
||||
| `identity` | Active Directory + Entra ID | 🟢 Green | WIS-017 | `ENABLE_IDENTITY` |
|
||||
| `workday` | Workday HCM | 🟡 Yellow | WIS-009 | `ENABLE_WORKDAY` |
|
||||
| `itsm` | BMC Helix ITSM | 🔴 Red | Planned | `ENABLE_ITSM` |
|
||||
| `assets` | Lansweeper + Intune | 🔴 Red | Planned | `ENABLE_ASSETS` |
|
||||
| `logistics` | FedEx | 🔴 Red | Planned | `ENABLE_LOGISTICS` |
|
||||
| `audit` | Cross-system drift + reporting | 🟡 Yellow | — | `ENABLE_AUDIT` |
|
||||
|
||||
---
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```
|
||||
nexus-mcp/
|
||||
├── src/
|
||||
│ ├── main.py # Core Orchestrator — reads flags, loads shards
|
||||
│ └── shards/ # The 6 Shards (one file = one system domain)
|
||||
│ ├── __init__.py
|
||||
│ ├── identity.py # 🟢 AD & Entra tools
|
||||
│ ├── workday.py # 🟡 Worker & Org tools
|
||||
│ ├── itsm.py # 🔴 BMC Helix tools
|
||||
│ ├── assets.py # 🔴 Lansweeper + Intune tools
|
||||
│ ├── logistics.py # 🔴 FedEx tools
|
||||
│ └── audit.py # 🟡 Cross-system drift + reporting
|
||||
├── lib/ # Low-level system adapters (no tool logic here)
|
||||
│ ├── config.py
|
||||
│ ├── ad_adapter.py # LDAP/AD connection wrapper
|
||||
│ ├── entra_client.py # Microsoft Graph (Entra)
|
||||
│ ├── workday_client.py # Workday REST + OAuth2
|
||||
│ ├── helix_client.py # BMC Helix AR-JWT auth
|
||||
│ ├── lansweeper_client.py # Lansweeper GraphQL
|
||||
│ ├── intune_client.py # Microsoft Graph (Intune)
|
||||
│ └── fedex_client.py # FedEx REST + OAuth2
|
||||
├── tests/
|
||||
├── .env # Feature flags + credentials
|
||||
├── .env.example # Template — copy this to .env
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture: The Shard Contract
|
||||
|
||||
Every shard file exposes exactly one function:
|
||||
|
||||
```python
|
||||
def register(mcp: FastMCP) -> None:
|
||||
@mcp.tool()
|
||||
async def my_tool(...) -> ...:
|
||||
"""Tool docstring visible to the LLM."""
|
||||
...
|
||||
```
|
||||
|
||||
The orchestrator (`src/main.py`) reads feature flags and calls `register(mcp)` for each enabled shard. **No other file is changed to add or remove a shard.**
|
||||
|
||||
### Adding a new shard
|
||||
|
||||
1. Create `src/shards/my_system.py` following the template above.
|
||||
2. Add the adapter to `lib/` if needed.
|
||||
3. Add one line to `src/main.py`:
|
||||
```python
|
||||
from shards import my_system
|
||||
if _enabled("MY_SYSTEM"):
|
||||
my_system.register(mcp)
|
||||
```
|
||||
4. Add `ENABLE_MY_SYSTEM=true` to `.env`.
|
||||
|
||||
### Holding pattern
|
||||
|
||||
Leave a shard unregistered (or set flag to `false`) to hold it without breaking anything:
|
||||
|
||||
```python
|
||||
# 🔴 Planned — credentials not yet available
|
||||
# if _enabled("MY_SYSTEM"):
|
||||
# my_system.register(mcp)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tools Reference
|
||||
|
||||
### Identity shard (🟢)
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `ad_get_user` | Look up AD user by sAMAccountName |
|
||||
| `ad_get_user_by_email` | Look up AD user by email |
|
||||
| `ad_search_users` | Search AD by display name fragment |
|
||||
| `ad_list_groups` | List all AD groups |
|
||||
| `ad_get_group_members` | Members of a group by DN |
|
||||
| `ad_get_disabled_accounts` | All disabled AD accounts |
|
||||
| `ad_get_stale_accounts` | Accounts inactive beyond N days |
|
||||
| `entra_list_users` | List Entra ID users |
|
||||
| `entra_get_user` | Get user by ID or UPN |
|
||||
| `entra_list_groups` | List Entra groups |
|
||||
| `entra_get_group_members` | Members of an Entra group |
|
||||
| `entra_list_service_principals` | List app registrations |
|
||||
| `entra_get_conditional_access_policies` | List CA policies |
|
||||
| `entra_get_signin_logs` | Recent sign-in logs |
|
||||
| `entra_get_risky_users` | Identity Protection risky users |
|
||||
|
||||
### Workday shard (🟡)
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `workday_list_workers` | Paginated worker list |
|
||||
| `workday_get_worker` | Worker by Workday ID |
|
||||
| `workday_find_worker_by_email` | Worker lookup by email |
|
||||
| `workday_list_positions` | Open and filled positions |
|
||||
| `workday_get_compensation` | Compensation details |
|
||||
| `workday_list_organizations` | Supervisory orgs |
|
||||
| `workday_run_raas_report` | Execute a RaaS custom report |
|
||||
|
||||
### ITSM shard (🔴)
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `helix_list_incidents` | Incidents (filterable by status/assignee) |
|
||||
| `helix_get_incident` | Incident by Entry ID |
|
||||
| `helix_list_changes` | Change requests |
|
||||
| `helix_get_problem` | Problem investigation ticket |
|
||||
| `helix_search_cmdb` | CMDB CI search by name |
|
||||
| `helix_list_cmdb_assets` | Hardware assets from CMDB |
|
||||
|
||||
### Assets shard (🔴)
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `lansweeper_list_assets` | Asset list (filterable by type) |
|
||||
| `lansweeper_get_asset` | Asset by ID |
|
||||
| `lansweeper_get_software` | Installed software on asset |
|
||||
| `lansweeper_search_assets` | Search by name/IP/serial |
|
||||
| `intune_list_managed_devices` | Managed device inventory |
|
||||
| `intune_get_managed_device` | Device by ID |
|
||||
| `intune_get_noncompliant_devices` | Non-compliant devices |
|
||||
| `intune_list_compliance_policies` | Compliance policies |
|
||||
| `intune_list_configuration_profiles` | Config profiles |
|
||||
| `intune_list_apps` | Deployed app list |
|
||||
| `intune_get_autopilot_devices` | Autopilot registrations |
|
||||
|
||||
### Logistics shard (🔴)
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `fedex_track_shipment` | Track by tracking number |
|
||||
| `fedex_track_multiple` | Track up to 30 at once |
|
||||
| `fedex_get_shipment_events` | Scan event history |
|
||||
| `fedex_validate_address` | Address validation |
|
||||
| `fedex_get_rates` | Rate quote between postal codes |
|
||||
|
||||
### Audit shard (🟡)
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `audit_user_drift` | Single user across Workday / AD / Entra |
|
||||
| `audit_bulk_user_drift` | Up to 50 users concurrently |
|
||||
| `audit_device_drift` | Single device across Lansweeper / Intune / Helix |
|
||||
| `audit_entra_ad_sync_drift` | Full Entra→AD sync scan |
|
||||
| `audit_intune_lansweeper_device_drift` | Intune vs Lansweeper reconciliation |
|
||||
| `generate_weekly_report` | Full weekly cross-system report |
|
||||
| `generate_compliance_report` | Device + identity risk snapshot |
|
||||
| `generate_asset_reconciliation_report` | Intune vs Lansweeper diff |
|
||||
| `generate_itsm_weekly_summary` | Helix ticket volume summary |
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd nexus-mcp
|
||||
cp .env.example .env # fill in credentials and set feature flags
|
||||
pip install -e .
|
||||
python src/main.py # or: nexus-mcp
|
||||
```
|
||||
|
||||
## Claude Desktop Config
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"nexus": {
|
||||
"command": "python",
|
||||
"args": ["src/main.py"],
|
||||
"cwd": "/path/to/nexus-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All credentials can live in `nexus-mcp/.env` — no need to put them in the Claude config.
|
||||
|
||||
---
|
||||
|
||||
## Required Permissions
|
||||
|
||||
See `mcp-server/README.md` for the full permission matrix for each system.
|
||||
The same requirements apply here — Nexus-MCP is a refactor of that server,
|
||||
not a new system.
|
||||
@@ -0,0 +1,910 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("identity-mcp.ad-adapter")
|
||||
|
||||
|
||||
class ActiveDirectoryIdentityBackend:
|
||||
"""PowerShell-based Active Directory backend for read-only identity queries.
|
||||
|
||||
Uses subprocess calls to approved Get-AD* cmdlets with deterministic output
|
||||
parsing. All methods maintain the same async contract and return shapes as
|
||||
IdentityBackend interface.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
timeout_seconds: float = 30.0,
|
||||
) -> None:
|
||||
"""Initialize AD adapter with optional explicit credentials for testing.
|
||||
|
||||
Args:
|
||||
username: Optional explicit username for test environments only
|
||||
password: Optional explicit password for test environments only
|
||||
timeout_seconds: Per-query timeout limit
|
||||
"""
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.timeout = timeout_seconds
|
||||
|
||||
@staticmethod
|
||||
def _escape_ps_single_quoted(value: str) -> str:
|
||||
return value.replace("'", "''")
|
||||
|
||||
async def _run_powershell(
|
||||
self, command: str, params: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Execute PowerShell command and return parsed output or error.
|
||||
|
||||
Args:
|
||||
command: PowerShell command string
|
||||
params: Optional parameters for logging context
|
||||
|
||||
Returns:
|
||||
dict with "success" (bool), "data" (Any), "error" (str | None)
|
||||
"""
|
||||
# Build credential block if explicit auth is configured
|
||||
cred_block = ""
|
||||
if self.username and self.password:
|
||||
# Escape single quotes in password for PowerShell
|
||||
escaped_password = self.password.replace("'", "''")
|
||||
# Use single quotes to avoid variable expansion and special char issues
|
||||
cred_block = f"$secpass = ConvertTo-SecureString '{escaped_password}' -AsPlainText -Force; $cred = New-Object System.Management.Automation.PSCredential('{self.username}', $secpass); "
|
||||
|
||||
full_command = cred_block + command
|
||||
|
||||
try:
|
||||
# Use create_subprocess_exec to avoid shell $ interpretation
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
'powershell',
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
full_command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(), timeout=self.timeout
|
||||
)
|
||||
|
||||
stdout_text = stdout.decode("utf-8", errors="replace").strip()
|
||||
stderr_text = stderr.decode("utf-8", errors="replace").strip()
|
||||
|
||||
if process.returncode != 0:
|
||||
logger.warning(
|
||||
"PowerShell command failed: returncode=%d stderr=%s",
|
||||
process.returncode,
|
||||
stderr_text,
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": f"Command failed: {stderr_text[:200]}",
|
||||
}
|
||||
|
||||
if stderr_text:
|
||||
logger.debug("PowerShell stderr: %s", stderr_text)
|
||||
|
||||
return {"success": True, "data": stdout_text, "error": None}
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("PowerShell command timeout after %s seconds", self.timeout)
|
||||
return {
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": f"Query timeout after {self.timeout} seconds",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("PowerShell execution error: %s", str(e))
|
||||
return {
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": f"Execution error: {str(e)[:200]}",
|
||||
}
|
||||
|
||||
async def get_user(self, username: str) -> dict[str, Any] | None:
|
||||
"""Get user state for a username.
|
||||
|
||||
Returns enabled/disabled, OU, description, and last logon timestamp.
|
||||
"""
|
||||
# Build PowerShell command using JSON output for reliable parsing
|
||||
escaped_username = self._escape_ps_single_quoted(username)
|
||||
command = f"""
|
||||
$user = Get-ADUser -Filter "samAccountName -eq '{escaped_username}'" -Properties GivenName,Surname,DisplayName,Enabled,DistinguishedName,Description,lastLogonTimestamp -ErrorAction Stop
|
||||
if ($user) {{
|
||||
$lastLogon = if ($user.lastLogonTimestamp) {{ [DateTime]::FromFileTime($user.lastLogonTimestamp).ToUniversalTime().ToString('o') }} else {{ $null }}
|
||||
@{{
|
||||
username = $user.SamAccountName
|
||||
first_name = if ($user.GivenName) {{ $user.GivenName }} else {{ '' }}
|
||||
last_name = if ($user.Surname) {{ $user.Surname }} else {{ '' }}
|
||||
display_name = if ($user.DisplayName) {{ $user.DisplayName }} else {{ '' }}
|
||||
enabled = $user.Enabled
|
||||
ou = $user.DistinguishedName
|
||||
description = if ($user.Description) {{ $user.Description }} else {{ '' }}
|
||||
last_logon_utc = $lastLogon
|
||||
}} | ConvertTo-Json -Compress
|
||||
}}
|
||||
"""
|
||||
|
||||
result = await self._run_powershell(command, {"username": username})
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning(
|
||||
"get_user failed for username=%s: %s", username, result["error"]
|
||||
)
|
||||
return None
|
||||
|
||||
if not result["data"]:
|
||||
return None
|
||||
|
||||
try:
|
||||
user_data = json.loads(result["data"])
|
||||
return {
|
||||
"username": user_data["username"],
|
||||
"first_name": user_data.get("first_name", "") or "",
|
||||
"last_name": user_data.get("last_name", "") or "",
|
||||
"display_name": user_data.get("display_name", "") or "",
|
||||
"enabled": user_data["enabled"],
|
||||
"ou": user_data["ou"],
|
||||
"description": user_data["description"] or "",
|
||||
"last_logon_utc": user_data["last_logon_utc"] or "",
|
||||
}
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
logger.error("Failed to parse user data: %s", str(e))
|
||||
return None
|
||||
|
||||
async def search_users_by_name(
|
||||
self, name_query: str, limit: int = 20
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search users by first name, last name, or full display name."""
|
||||
query = name_query.strip()
|
||||
if not query:
|
||||
return []
|
||||
|
||||
max_results = max(1, min(limit, 100))
|
||||
escaped_query = self._escape_ps_single_quoted(query)
|
||||
|
||||
command = f"""
|
||||
$query = '{escaped_query}'
|
||||
$tokens = @($query.Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries))
|
||||
|
||||
if ($tokens.Count -ge 2) {{
|
||||
$first = $tokens[0]
|
||||
$last = $tokens[1]
|
||||
$adFilter = "(givenName -like '$($first)*' -and surname -like '$($last)*') -or (displayName -like '$query*')"
|
||||
}} else {{
|
||||
$adFilter = "givenName -like '$query*' -or surname -like '$query*' -or displayName -like '$query*'"
|
||||
}}
|
||||
|
||||
$users = @(Get-ADUser -Filter $adFilter -Properties GivenName,Surname,DisplayName,Enabled,DistinguishedName -ErrorAction Stop |
|
||||
Sort-Object DisplayName |
|
||||
Select-Object -First {max_results} |
|
||||
ForEach-Object {{
|
||||
@{{
|
||||
username = $_.SamAccountName
|
||||
first_name = if ($_.GivenName) {{ $_.GivenName }} else {{ '' }}
|
||||
last_name = if ($_.Surname) {{ $_.Surname }} else {{ '' }}
|
||||
display_name = if ($_.DisplayName) {{ $_.DisplayName }} else {{ '' }}
|
||||
enabled = $_.Enabled
|
||||
ou = $_.DistinguishedName
|
||||
}}
|
||||
}})
|
||||
|
||||
$users | ConvertTo-Json -Compress
|
||||
"""
|
||||
|
||||
result = await self._run_powershell(
|
||||
command,
|
||||
{"name_query": name_query, "limit": max_results},
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning(
|
||||
"search_users_by_name failed for query=%s: %s",
|
||||
name_query,
|
||||
result["error"],
|
||||
)
|
||||
return []
|
||||
|
||||
if not result["data"]:
|
||||
return []
|
||||
|
||||
try:
|
||||
users = json.loads(result["data"])
|
||||
if isinstance(users, dict):
|
||||
users = [users]
|
||||
if not isinstance(users, list):
|
||||
return []
|
||||
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
"username": user.get("username", "") or "",
|
||||
"first_name": user.get("first_name", "") or "",
|
||||
"last_name": user.get("last_name", "") or "",
|
||||
"display_name": user.get("display_name", "") or "",
|
||||
"enabled": bool(user.get("enabled", False)),
|
||||
"ou": user.get("ou", "") or "",
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to parse search user data: %s", str(e))
|
||||
return []
|
||||
|
||||
async def get_user_groups(self, username: str) -> list[str]:
|
||||
"""Get all group memberships for a user."""
|
||||
escaped_username = self._escape_ps_single_quoted(username)
|
||||
command = f"""
|
||||
$user = Get-ADUser -Filter "samAccountName -eq '{escaped_username}'" -Properties MemberOf -ErrorAction Stop
|
||||
if ($user -and $user.MemberOf) {{
|
||||
$groups = @($user.MemberOf | ForEach-Object {{
|
||||
$group = Get-ADGroup $_ -ErrorAction SilentlyContinue
|
||||
if ($group) {{ $group.Name }}
|
||||
}})
|
||||
$groups | ConvertTo-Json -Compress
|
||||
}} else {{
|
||||
@() | ConvertTo-Json -Compress
|
||||
}}
|
||||
"""
|
||||
|
||||
result = await self._run_powershell(command, {"username": username})
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning(
|
||||
"get_user_groups failed for username=%s: %s",
|
||||
username,
|
||||
result["error"],
|
||||
)
|
||||
return []
|
||||
|
||||
if not result["data"]:
|
||||
return []
|
||||
|
||||
try:
|
||||
groups = json.loads(result["data"])
|
||||
return sorted(groups) if isinstance(groups, list) else []
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to parse group data: %s", str(e))
|
||||
return []
|
||||
|
||||
async def get_group_members(self, group_name: str) -> list[str]:
|
||||
"""Get all members of a named group."""
|
||||
escaped_group_name = self._escape_ps_single_quoted(group_name)
|
||||
command = f"""
|
||||
$group = Get-ADGroup -Filter "Name -eq '{escaped_group_name}'" -ErrorAction Stop
|
||||
if ($group) {{
|
||||
$members = @(Get-ADGroupMember $group -ErrorAction Stop | Where-Object {{ $_.objectClass -eq 'user' }} | ForEach-Object {{ $_.SamAccountName }})
|
||||
$members | ConvertTo-Json -Compress
|
||||
}} else {{
|
||||
@() | ConvertTo-Json -Compress
|
||||
}}
|
||||
"""
|
||||
|
||||
result = await self._run_powershell(command, {"group_name": group_name})
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning(
|
||||
"get_group_members failed for group=%s: %s",
|
||||
group_name,
|
||||
result["error"],
|
||||
)
|
||||
return []
|
||||
|
||||
if not result["data"]:
|
||||
return []
|
||||
|
||||
try:
|
||||
members = json.loads(result["data"])
|
||||
return sorted(members) if isinstance(members, list) else []
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to parse member data: %s", str(e))
|
||||
return []
|
||||
|
||||
async def find_stale_users(self, days: int) -> list[dict[str, Any]]:
|
||||
"""Get users with no logon activity in N days using lastLogonTimestamp."""
|
||||
if days < 0:
|
||||
return []
|
||||
|
||||
command = f"""
|
||||
$cutoff = (Get-Date).AddDays(-{days})
|
||||
$cutoffFileTime = $cutoff.ToFileTime()
|
||||
$users = @(Get-ADUser -Filter * -Properties Enabled,lastLogonTimestamp -ErrorAction Stop | Where-Object {{
|
||||
(-not $_.lastLogonTimestamp) -or ($_.lastLogonTimestamp -lt $cutoffFileTime)
|
||||
}} | Select-Object -First 100 | ForEach-Object {{
|
||||
$lastLogon = if ($_.lastLogonTimestamp) {{ [DateTime]::FromFileTime($_.lastLogonTimestamp).ToUniversalTime().ToString('o') }} else {{ '' }}
|
||||
@{{
|
||||
username = $_.SamAccountName
|
||||
enabled = $_.Enabled
|
||||
last_logon_utc = $lastLogon
|
||||
}}
|
||||
}})
|
||||
$users | ConvertTo-Json -Compress
|
||||
"""
|
||||
|
||||
result = await self._run_powershell(command, {"days": days})
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning("find_stale_users failed: %s", result["error"])
|
||||
return []
|
||||
|
||||
if not result["data"]:
|
||||
return []
|
||||
|
||||
try:
|
||||
users = json.loads(result["data"])
|
||||
if not isinstance(users, list):
|
||||
users = [users] if users else []
|
||||
return sorted(users, key=lambda u: u.get("username", ""))
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to parse stale user data: %s", str(e))
|
||||
return []
|
||||
|
||||
async def get_computer(self, computer_name: str) -> dict[str, Any] | None:
|
||||
"""Get computer account details including OU placement.
|
||||
|
||||
Note: assigned_username returns null in this phase per requirements.
|
||||
"""
|
||||
escaped_computer_name = self._escape_ps_single_quoted(computer_name)
|
||||
command = f"""
|
||||
$computer = Get-ADComputer -Filter "Name -eq '{escaped_computer_name}'" -Properties DistinguishedName -ErrorAction Stop
|
||||
if ($computer) {{
|
||||
@{{
|
||||
computer_name = $computer.Name
|
||||
ou = $computer.DistinguishedName
|
||||
assigned_username = $null
|
||||
}} | ConvertTo-Json -Compress
|
||||
}}
|
||||
"""
|
||||
|
||||
result = await self._run_powershell(command, {"computer_name": computer_name})
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning(
|
||||
"get_computer failed for computer=%s: %s",
|
||||
computer_name,
|
||||
result["error"],
|
||||
)
|
||||
return None
|
||||
|
||||
if not result["data"]:
|
||||
return None
|
||||
|
||||
try:
|
||||
computer_data = json.loads(result["data"])
|
||||
return {
|
||||
"computer_name": computer_data["computer_name"],
|
||||
"ou": computer_data["ou"],
|
||||
"assigned_username": None,
|
||||
}
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
logger.error("Failed to parse computer data: %s", str(e))
|
||||
return None
|
||||
|
||||
async def query_users(
|
||||
self,
|
||||
filter_params: dict[str, Any] | None = None,
|
||||
fields: list[str] | None = None,
|
||||
sort_by: str = "display_name",
|
||||
sort_direction: str = "asc",
|
||||
page_size: int = 50,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Query users with flexible validated filters and pagination."""
|
||||
from identity_backend import ALLOWED_USER_FIELDS
|
||||
|
||||
filter_params = filter_params or {}
|
||||
fields = fields or list(ALLOWED_USER_FIELDS)
|
||||
|
||||
# Validate fields
|
||||
invalid_fields = set(fields) - ALLOWED_USER_FIELDS
|
||||
if invalid_fields:
|
||||
return {
|
||||
"items": [],
|
||||
"next_cursor": None,
|
||||
"page_size": page_size,
|
||||
"total_estimate": 0,
|
||||
"applied_filter": filter_params,
|
||||
"warnings": [f"Invalid fields requested: {', '.join(invalid_fields)}"],
|
||||
}
|
||||
|
||||
# Build AD filter from filter_params
|
||||
ad_filter_parts = []
|
||||
|
||||
if "enabled" in filter_params:
|
||||
enabled_val = "True" if filter_params["enabled"] else "False"
|
||||
ad_filter_parts.append(f"Enabled -eq ${enabled_val}")
|
||||
|
||||
if "name_contains" in filter_params:
|
||||
name_query = self._escape_ps_single_quoted(filter_params["name_contains"])
|
||||
ad_filter_parts.append(f"DisplayName -like '*{name_query}*'")
|
||||
|
||||
if "username_prefix" in filter_params:
|
||||
prefix = self._escape_ps_single_quoted(filter_params["username_prefix"])
|
||||
ad_filter_parts.append(f"samAccountName -like '{prefix}*'")
|
||||
|
||||
if "ou_contains" in filter_params:
|
||||
ou_part = self._escape_ps_single_quoted(filter_params["ou_contains"])
|
||||
ad_filter_parts.append(f"DistinguishedName -like '*{ou_part}*'")
|
||||
|
||||
if "description_contains" in filter_params:
|
||||
desc_part = self._escape_ps_single_quoted(filter_params["description_contains"])
|
||||
ad_filter_parts.append(f"Description -like '*{desc_part}*'")
|
||||
|
||||
# Build final filter - default to all users if no filters specified
|
||||
if ad_filter_parts:
|
||||
ad_filter = " -and ".join(ad_filter_parts)
|
||||
else:
|
||||
ad_filter = "*"
|
||||
|
||||
# Determine which AD properties to fetch
|
||||
ad_properties = [
|
||||
"SamAccountName",
|
||||
"GivenName",
|
||||
"Surname",
|
||||
"DisplayName",
|
||||
"Enabled",
|
||||
"DistinguishedName",
|
||||
"Description",
|
||||
"lastLogonTimestamp",
|
||||
]
|
||||
if "when_created_utc" in fields:
|
||||
ad_properties.append("whenCreated")
|
||||
if "department" in fields:
|
||||
ad_properties.append("Department")
|
||||
if "title" in fields:
|
||||
ad_properties.append("Title")
|
||||
if "email" in fields:
|
||||
ad_properties.append("EmailAddress")
|
||||
|
||||
# Build sort expression
|
||||
sort_field_map = {
|
||||
"display_name": "DisplayName",
|
||||
"username": "SamAccountName",
|
||||
"last_logon_utc": "lastLogonTimestamp",
|
||||
"when_created_utc": "whenCreated",
|
||||
"department": "Department",
|
||||
}
|
||||
sort_field = sort_field_map.get(sort_by, "DisplayName")
|
||||
|
||||
# Clamp page size
|
||||
clamped_size = min(max(1, page_size), 200)
|
||||
|
||||
# Note: Simple offset-based pagination for Phase 2
|
||||
# For production, consider lastLogonTimestamp-based keyset pagination
|
||||
start_index = 0
|
||||
if cursor:
|
||||
try:
|
||||
start_index = int(cursor)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Build PowerShell command
|
||||
props_csv = ", ".join(ad_properties)
|
||||
command = f"""
|
||||
$filter = "{ad_filter}"
|
||||
$users = Get-ADUser -Filter $filter -Properties {props_csv} -ErrorAction Stop | Sort-Object {sort_field}"""
|
||||
|
||||
if sort_direction == "desc":
|
||||
command += " -Descending"
|
||||
|
||||
command += "\n"
|
||||
|
||||
# Handle last_logon_before_days post-filter (requires FileTime comparison)
|
||||
if "last_logon_before_days" in filter_params:
|
||||
days = filter_params["last_logon_before_days"]
|
||||
command += f"$users = $users | Where-Object {{ if ($_.lastLogonTimestamp) {{ $cutoff = (Get-Date).AddDays(-{days}).ToFileTime(); $_.lastLogonTimestamp -lt $cutoff }} else {{ $true }} }}\n"
|
||||
|
||||
# Handle group_any post-filter
|
||||
if "group_any" in filter_params:
|
||||
groups_json = json.dumps(filter_params["group_any"])
|
||||
escaped_groups_json = groups_json.replace("'", "''")
|
||||
command += f"$users = $users | Where-Object {{ $targetGroups = '{escaped_groups_json}' | ConvertFrom-Json; $userGroups = @($_.MemberOf | ForEach-Object {{ $g = Get-ADGroup $_ -ErrorAction SilentlyContinue; if ($g) {{ $g.Name }} }}); $found = $false; foreach ($tg in $targetGroups) {{ if ($userGroups -contains $tg) {{ $found = $true; break }} }}; $found }}\n"
|
||||
|
||||
# Add pagination and field projection
|
||||
command += f"""
|
||||
$users = @($users)
|
||||
$total = $users.Count
|
||||
$pageUsers = $users | Select-Object -Skip {start_index} -First {clamped_size}
|
||||
$hasMore = ($total -gt {start_index + clamped_size})
|
||||
|
||||
$items = @($pageUsers | ForEach-Object {{
|
||||
$lastLogon = if ($_.lastLogonTimestamp) {{
|
||||
[DateTime]::FromFileTime($_.lastLogonTimestamp).ToUniversalTime().ToString('o')
|
||||
}} else {{ '' }}
|
||||
$whenCreated = if ($_.whenCreated) {{
|
||||
$_.whenCreated.ToUniversalTime().ToString('o')
|
||||
}} else {{ '' }}
|
||||
|
||||
@{{
|
||||
username = $_.SamAccountName
|
||||
display_name = if ($_.DisplayName) {{ $_.DisplayName }} else {{ '' }}
|
||||
first_name = if ($_.GivenName) {{ $_.GivenName }} else {{ '' }}
|
||||
last_name = if ($_.Surname) {{ $_.Surname }} else {{ '' }}
|
||||
enabled = $_.Enabled
|
||||
ou = $_.DistinguishedName
|
||||
description = if ($_.Description) {{ $_.Description }} else {{ '' }}
|
||||
last_logon_utc = $lastLogon
|
||||
when_created_utc = $whenCreated
|
||||
department = if ($_.Department) {{ $_.Department }} else {{ '' }}
|
||||
title = if ($_.Title) {{ $_.Title }} else {{ '' }}
|
||||
email = if ($_.EmailAddress) {{ $_.EmailAddress }} else {{ '' }}
|
||||
}}
|
||||
}})
|
||||
|
||||
$nextCursor = if ($hasMore) {{ {start_index + clamped_size} }} else {{ $null }}
|
||||
|
||||
@{{
|
||||
items = $items
|
||||
next_cursor = $nextCursor
|
||||
page_size = {clamped_size}
|
||||
total_estimate = $total
|
||||
}} | ConvertTo-Json -Depth 3 -Compress
|
||||
"""
|
||||
|
||||
result = await self._run_powershell(
|
||||
command,
|
||||
{"filter_params": filter_params, "page_size": clamped_size},
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning("query_users failed: %s", result["error"])
|
||||
return {
|
||||
"items": [],
|
||||
"next_cursor": None,
|
||||
"page_size": clamped_size,
|
||||
"total_estimate": 0,
|
||||
"applied_filter": filter_params,
|
||||
"warnings": [f"Query failed: {result['error'][:100]}"],
|
||||
}
|
||||
|
||||
if not result["data"]:
|
||||
return {
|
||||
"items": [],
|
||||
"next_cursor": None,
|
||||
"page_size": clamped_size,
|
||||
"total_estimate": 0,
|
||||
"applied_filter": filter_params,
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
try:
|
||||
response = json.loads(result["data"])
|
||||
|
||||
# Filter fields based on requested fields
|
||||
filtered_items = []
|
||||
for item in response.get("items", []):
|
||||
filtered_item = {k: v for k, v in item.items() if k in fields}
|
||||
filtered_items.append(filtered_item)
|
||||
|
||||
return {
|
||||
"items": filtered_items,
|
||||
"next_cursor": response.get("next_cursor"),
|
||||
"page_size": clamped_size,
|
||||
"total_estimate": response.get("total_estimate", 0),
|
||||
"applied_filter": filter_params,
|
||||
"warnings": [],
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to parse query_users data: %s", str(e))
|
||||
return {
|
||||
"items": [],
|
||||
"next_cursor": None,
|
||||
"page_size": clamped_size,
|
||||
"total_estimate": 0,
|
||||
"applied_filter": filter_params,
|
||||
"warnings": ["Failed to parse query results"],
|
||||
}
|
||||
|
||||
async def count_users(
|
||||
self,
|
||||
filter_params: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Count users matching filter without returning full records."""
|
||||
filter_params = filter_params or {}
|
||||
|
||||
# Build AD filter from filter_params (same logic as query_users)
|
||||
ad_filter_parts = []
|
||||
|
||||
if "enabled" in filter_params:
|
||||
enabled_val = "True" if filter_params["enabled"] else "False"
|
||||
ad_filter_parts.append(f"Enabled -eq ${enabled_val}")
|
||||
|
||||
if "name_contains" in filter_params:
|
||||
name_query = self._escape_ps_single_quoted(filter_params["name_contains"])
|
||||
ad_filter_parts.append(f"DisplayName -like '*{name_query}*'")
|
||||
|
||||
if "username_prefix" in filter_params:
|
||||
prefix = self._escape_ps_single_quoted(filter_params["username_prefix"])
|
||||
ad_filter_parts.append(f"samAccountName -like '{prefix}*'")
|
||||
|
||||
if "ou_contains" in filter_params:
|
||||
ou_part = self._escape_ps_single_quoted(filter_params["ou_contains"])
|
||||
ad_filter_parts.append(f"DistinguishedName -like '*{ou_part}*'")
|
||||
|
||||
if "description_contains" in filter_params:
|
||||
desc_part = self._escape_ps_single_quoted(filter_params["description_contains"])
|
||||
ad_filter_parts.append(f"Description -like '*{desc_part}*'")
|
||||
|
||||
if ad_filter_parts:
|
||||
ad_filter = " -and ".join(ad_filter_parts)
|
||||
else:
|
||||
ad_filter = "*"
|
||||
|
||||
command = f"""
|
||||
$filter = "{ad_filter}"
|
||||
$users = Get-ADUser -Filter $filter -Properties lastLogonTimestamp,MemberOf -ErrorAction Stop
|
||||
"""
|
||||
|
||||
# Handle last_logon_before_days post-filter
|
||||
if "last_logon_before_days" in filter_params:
|
||||
days = filter_params["last_logon_before_days"]
|
||||
command += f"$users = $users | Where-Object {{ if ($_.lastLogonTimestamp) {{ $cutoff = (Get-Date).AddDays(-{days}).ToFileTime(); $_.lastLogonTimestamp -lt $cutoff }} else {{ $true }} }}\n"
|
||||
|
||||
# Handle group_any post-filter
|
||||
if "group_any" in filter_params:
|
||||
groups_json = json.dumps(filter_params["group_any"])
|
||||
escaped_groups_json = groups_json.replace("'", "''")
|
||||
command += f"$users = $users | Where-Object {{ $targetGroups = '{escaped_groups_json}' | ConvertFrom-Json; $userGroups = @($_.MemberOf | ForEach-Object {{ $g = Get-ADGroup $_ -ErrorAction SilentlyContinue; if ($g) {{ $g.Name }} }}); $found = $false; foreach ($tg in $targetGroups) {{ if ($userGroups -contains $tg) {{ $found = $true; break }} }}; $found }}\n"
|
||||
|
||||
command += """
|
||||
$users = @($users)
|
||||
@{
|
||||
count = $users.Count
|
||||
} | ConvertTo-Json -Compress
|
||||
"""
|
||||
|
||||
result = await self._run_powershell(command, {"filter_params": filter_params})
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning("count_users failed: %s", result["error"])
|
||||
return {
|
||||
"count": 0,
|
||||
"applied_filter": filter_params,
|
||||
}
|
||||
|
||||
if not result["data"]:
|
||||
return {
|
||||
"count": 0,
|
||||
"applied_filter": filter_params,
|
||||
}
|
||||
|
||||
try:
|
||||
response = json.loads(result["data"])
|
||||
return {
|
||||
"count": response.get("count", 0),
|
||||
"applied_filter": filter_params,
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to parse count_users data: %s", str(e))
|
||||
return {
|
||||
"count": 0,
|
||||
"applied_filter": filter_params,
|
||||
}
|
||||
|
||||
async def summarize_users(
|
||||
self,
|
||||
filter_params: dict[str, Any] | None = None,
|
||||
group_by: str = "enabled",
|
||||
top: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
"""Return grouped aggregates for users matching filter."""
|
||||
filter_params = filter_params or {}
|
||||
|
||||
# Build AD filter (same as count_users)
|
||||
ad_filter_parts = []
|
||||
|
||||
if "enabled" in filter_params:
|
||||
enabled_val = "True" if filter_params["enabled"] else "False"
|
||||
ad_filter_parts.append(f"Enabled -eq ${enabled_val}")
|
||||
|
||||
if "name_contains" in filter_params:
|
||||
name_query = self._escape_ps_single_quoted(filter_params["name_contains"])
|
||||
ad_filter_parts.append(f"DisplayName -like '*{name_query}*'")
|
||||
|
||||
if "username_prefix" in filter_params:
|
||||
prefix = self._escape_ps_single_quoted(filter_params["username_prefix"])
|
||||
ad_filter_parts.append(f"samAccountName -like '{prefix}*'")
|
||||
|
||||
if "ou_contains" in filter_params:
|
||||
ou_part = self._escape_ps_single_quoted(filter_params["ou_contains"])
|
||||
ad_filter_parts.append(f"DistinguishedName -like '*{ou_part}*'")
|
||||
|
||||
if "description_contains" in filter_params:
|
||||
desc_part = self._escape_ps_single_quoted(filter_params["description_contains"])
|
||||
ad_filter_parts.append(f"Description -like '*{desc_part}*'")
|
||||
|
||||
if ad_filter_parts:
|
||||
ad_filter = " -and ".join(ad_filter_parts)
|
||||
else:
|
||||
ad_filter = "*"
|
||||
|
||||
# Determine grouping field
|
||||
group_field_map = {
|
||||
"enabled": "Enabled",
|
||||
"ou": "DistinguishedName",
|
||||
"department": "Department",
|
||||
"title": "Title",
|
||||
}
|
||||
group_field = group_field_map.get(group_by, "Enabled")
|
||||
|
||||
# Determine required properties
|
||||
required_props = ["Enabled", "lastLogonTimestamp", "MemberOf", "whenCreated"]
|
||||
if group_by in ["department", "title", "ou"]:
|
||||
if group_by == "department":
|
||||
required_props.append("Department")
|
||||
elif group_by == "title":
|
||||
required_props.append("Title")
|
||||
|
||||
props_csv = ", ".join(set(required_props))
|
||||
clamped_top = min(max(1, top), 50)
|
||||
|
||||
command = f"""
|
||||
$filter = "{ad_filter}"
|
||||
$users = Get-ADUser -Filter $filter -Properties {props_csv} -ErrorAction Stop
|
||||
"""
|
||||
|
||||
# Handle last_logon_before_days post-filter
|
||||
if "last_logon_before_days" in filter_params:
|
||||
days = filter_params["last_logon_before_days"]
|
||||
command += f"$users = $users | Where-Object {{ if ($_.lastLogonTimestamp) {{ $cutoff = (Get-Date).AddDays(-{days}).ToFileTime(); $_.lastLogonTimestamp -lt $cutoff }} else {{ $true }} }}\n"
|
||||
|
||||
# Handle group_any post-filter
|
||||
if "group_any" in filter_params:
|
||||
groups_json = json.dumps(filter_params["group_any"])
|
||||
escaped_groups_json = groups_json.replace("'", "''")
|
||||
command += f"$users = $users | Where-Object {{ $targetGroups = '{escaped_groups_json}' | ConvertFrom-Json; $userGroups = @($_.MemberOf | ForEach-Object {{ $g = Get-ADGroup $_ -ErrorAction SilentlyContinue; if ($g) {{ $g.Name }} }}); $found = $false; foreach ($tg in $targetGroups) {{ if ($userGroups -contains $tg) {{ $found = $true; break }} }}; $found }}\n"
|
||||
|
||||
# Group by logic
|
||||
command += """
|
||||
$users = @($users)
|
||||
"""
|
||||
if group_by == "enabled":
|
||||
command += """
|
||||
$buckets = $users | Group-Object Enabled | ForEach-Object {
|
||||
@{
|
||||
key = if ($_.Name -eq "True") { "Enabled" } else { "Disabled" }
|
||||
count = $_.Count
|
||||
}
|
||||
} | Sort-Object count -Descending
|
||||
"""
|
||||
elif group_by == "ou":
|
||||
command += """
|
||||
$buckets = $users | ForEach-Object {
|
||||
# Extract OU from DN
|
||||
$dn = $_.DistinguishedName
|
||||
if ($dn -match 'OU=([^,]+)') {
|
||||
$matches[1]
|
||||
} else {
|
||||
"Root"
|
||||
}
|
||||
} | Group-Object | ForEach-Object {
|
||||
@{
|
||||
key = $_.Name
|
||||
count = $_.Count
|
||||
}
|
||||
} | Sort-Object count -Descending
|
||||
"""
|
||||
elif group_by == "department":
|
||||
command += """
|
||||
$buckets = $users | ForEach-Object {
|
||||
if ($_.Department) { $_.Department } else { "No Department" }
|
||||
} | Group-Object | ForEach-Object {
|
||||
@{
|
||||
key = $_.Name
|
||||
count = $_.Count
|
||||
}
|
||||
} | Sort-Object count -Descending
|
||||
"""
|
||||
elif group_by == "title":
|
||||
command += """
|
||||
$buckets = $users | ForEach-Object {
|
||||
if ($_.Title) { $_.Title } else { "No Title" }
|
||||
} | Group-Object | ForEach-Object {
|
||||
@{
|
||||
key = $_.Name
|
||||
count = $_.Count
|
||||
}
|
||||
} | Sort-Object count -Descending
|
||||
"""
|
||||
elif group_by == "last_logon_bucket":
|
||||
command += """
|
||||
$now = Get-Date
|
||||
$buckets = $users | ForEach-Object {
|
||||
if ($_.lastLogonTimestamp) {
|
||||
$lastLogon = [DateTime]::FromFileTime($_.lastLogonTimestamp)
|
||||
$daysAgo = ($now - $lastLogon).Days
|
||||
if ($daysAgo -lt 7) { "Last 7 days" }
|
||||
elseif ($daysAgo -lt 30) { "Last 30 days" }
|
||||
elseif ($daysAgo -lt 90) { "Last 90 days" }
|
||||
else { "90+ days" }
|
||||
} else {
|
||||
"Never logged in"
|
||||
}
|
||||
} | Group-Object | ForEach-Object {
|
||||
@{
|
||||
key = $_.Name
|
||||
count = $_.Count
|
||||
}
|
||||
} | Sort-Object count -Descending
|
||||
"""
|
||||
elif group_by == "created_month":
|
||||
command += """
|
||||
$buckets = $users | ForEach-Object {
|
||||
if ($_.whenCreated) {
|
||||
$_.whenCreated.ToString("yyyy-MM")
|
||||
} else {
|
||||
"Unknown"
|
||||
}
|
||||
} | Group-Object | ForEach-Object {
|
||||
@{
|
||||
key = $_.Name
|
||||
count = $_.Count
|
||||
}
|
||||
} | Sort-Object key -Descending
|
||||
"""
|
||||
else:
|
||||
command += """
|
||||
$buckets = @(@{
|
||||
key = "Unknown"
|
||||
count = $users.Count
|
||||
})
|
||||
"""
|
||||
|
||||
command += f"""
|
||||
@{{
|
||||
buckets = @($buckets | Select-Object -First {clamped_top})
|
||||
total = $users.Count
|
||||
}} | ConvertTo-Json -Depth 3 -Compress
|
||||
"""
|
||||
|
||||
result = await self._run_powershell(
|
||||
command,
|
||||
{"filter_params": filter_params, "group_by": group_by},
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning("summarize_users failed: %s", result["error"])
|
||||
return {
|
||||
"buckets": [],
|
||||
"total": 0,
|
||||
"applied_filter": filter_params,
|
||||
}
|
||||
|
||||
if not result["data"]:
|
||||
return {
|
||||
"buckets": [],
|
||||
"total": 0,
|
||||
"applied_filter": filter_params,
|
||||
}
|
||||
|
||||
try:
|
||||
response = json.loads(result["data"])
|
||||
buckets = response.get("buckets", [])
|
||||
if not isinstance(buckets, list):
|
||||
buckets = [buckets] if buckets else []
|
||||
|
||||
return {
|
||||
"buckets": buckets,
|
||||
"total": response.get("total", 0),
|
||||
"applied_filter": filter_params,
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to parse summarize_users data: %s", str(e))
|
||||
return {
|
||||
"buckets": [],
|
||||
"total": 0,
|
||||
"applied_filter": filter_params,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Nexus-MCP SOC 2 Audit Logger.
|
||||
|
||||
Implements:
|
||||
CC7.2 — System Monitoring (every tool call recorded)
|
||||
CC6.1 — Logical Access Controls (action_category per call)
|
||||
PI1.4 — Processing Integrity: completeness (pre/post logging)
|
||||
PI1.5 — Processing Integrity: accuracy (mock_mode flag)
|
||||
A1.1 — Availability monitoring (latency_ms per call)
|
||||
|
||||
Log format: newline-delimited JSON (JSONL) — one event object per line.
|
||||
The file is opened in append mode; existing lines are never modified.
|
||||
That property, combined with file-system ACL controls, provides the
|
||||
tamper-evident quality required by SOC 2 auditors.
|
||||
|
||||
Log entry schema v1:
|
||||
event_id str — UUID v4; globally unique per invocation
|
||||
timestamp str — ISO 8601 UTC with microsecond precision
|
||||
schema_version str — "1" (increment on breaking schema changes)
|
||||
source str — always "nexus-mcp"
|
||||
tool str — MCP tool name, e.g. "ad_get_user"
|
||||
shard str — domain: identity | workday | itsm | assets | logistics | audit
|
||||
action_category str — READ | AUDIT | REPORT (CC6.1 logical access label)
|
||||
args_summary dict — call arguments with sensitive values replaced by **REDACTED**
|
||||
mock_mode bool — True when USE_MOCK=true (no real system data accessed)
|
||||
status str — "success" | "error"
|
||||
latency_ms int — wall-clock duration in milliseconds
|
||||
error_type str? — exception class name; null on success
|
||||
error_message str? — sanitised error text; null on success
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ── Sensitive field redaction ─────────────────────────────────────────────────
|
||||
# Any argument key whose name contains one of these substrings has its value
|
||||
# replaced with **REDACTED** before the entry is written to disk.
|
||||
|
||||
_REDACT_SUBSTRINGS: frozenset[str] = frozenset({
|
||||
"password", "passwd", "secret", "token",
|
||||
"api_key", "apikey", "access_key", "private_key",
|
||||
"credential", "credentials", "auth", "authorization",
|
||||
"ssn", "dob", "date_of_birth", "birth",
|
||||
"card", "cvv", "pin", "account_number",
|
||||
})
|
||||
|
||||
|
||||
def _should_redact(key: str) -> bool:
|
||||
k = key.lower()
|
||||
return any(s in k for s in _REDACT_SUBSTRINGS)
|
||||
|
||||
|
||||
def _redact(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a copy of *args* with sensitive values replaced by **REDACTED**."""
|
||||
out: dict[str, Any] = {}
|
||||
for k, v in args.items():
|
||||
if _should_redact(k):
|
||||
out[k] = "**REDACTED**"
|
||||
elif isinstance(v, dict):
|
||||
out[k] = _redact(v)
|
||||
elif isinstance(v, list) and v and isinstance(v[0], dict):
|
||||
out[k] = [_redact(i) if isinstance(i, dict) else i for i in v]
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
# ── Shard inference ───────────────────────────────────────────────────────────
|
||||
|
||||
_SHARD_PREFIXES: list[tuple[tuple[str, ...], str]] = [
|
||||
(("ad_", "entra_"), "identity"),
|
||||
(("workday_",), "workday"),
|
||||
(("helix_",), "itsm"),
|
||||
(("lansweeper_", "intune_"), "assets"),
|
||||
(("fedex_",), "logistics"),
|
||||
(("audit_", "generate_"), "audit"),
|
||||
]
|
||||
|
||||
|
||||
def _infer_shard(tool_name: str) -> str:
|
||||
for prefixes, shard in _SHARD_PREFIXES:
|
||||
if any(tool_name.startswith(p) for p in prefixes):
|
||||
return shard
|
||||
return "unknown"
|
||||
|
||||
|
||||
# ── Action category inference (CC6.1) ─────────────────────────────────────────
|
||||
# Logical access categories tell auditors what class of operation was performed.
|
||||
|
||||
_AUDIT_PREFIXES: tuple[str, ...] = ("audit_",)
|
||||
_REPORT_PREFIXES: tuple[str, ...] = ("generate_",)
|
||||
|
||||
|
||||
def _infer_action_category(tool_name: str) -> str:
|
||||
if any(tool_name.startswith(p) for p in _AUDIT_PREFIXES):
|
||||
return "AUDIT"
|
||||
if any(tool_name.startswith(p) for p in _REPORT_PREFIXES):
|
||||
return "REPORT"
|
||||
return "READ"
|
||||
|
||||
|
||||
# ── AuditLogger ───────────────────────────────────────────────────────────────
|
||||
|
||||
class AuditLogger:
|
||||
"""Thread-safe, append-only SOC 2 audit logger.
|
||||
|
||||
Writes one JSONL record per tool invocation to *log_file*.
|
||||
When *log_to_stderr* is True it also emits the same JSON to stderr
|
||||
so the record flows into systemd / CloudWatch / SIEM without a sidecar.
|
||||
"""
|
||||
|
||||
_instance: AuditLogger | None = None
|
||||
|
||||
def __init__(self, log_file: Path | None = None, log_to_stderr: bool | None = None) -> None:
|
||||
if log_file is None:
|
||||
from config import AuditConfig # lazy — avoids circular import at module level
|
||||
cfg = AuditConfig()
|
||||
log_file = cfg.log_file
|
||||
if log_to_stderr is None:
|
||||
log_to_stderr = cfg.log_to_stderr
|
||||
|
||||
self._log_file = Path(log_file)
|
||||
self._log_to_stderr = bool(log_to_stderr)
|
||||
self._mock_mode: bool = os.getenv("USE_MOCK", "false").lower() == "true"
|
||||
|
||||
self._log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._stderr_logger = logging.getLogger("nexus.audit")
|
||||
if not self._stderr_logger.handlers:
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
self._stderr_logger.addHandler(handler)
|
||||
self._stderr_logger.propagate = False
|
||||
self._stderr_logger.setLevel(logging.INFO)
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
def record(
|
||||
self,
|
||||
*,
|
||||
event_id: str,
|
||||
tool: str,
|
||||
args: dict[str, Any],
|
||||
status: str,
|
||||
latency_ms: int,
|
||||
error_type: str | None = None,
|
||||
error_message: str | None = None,
|
||||
) -> None:
|
||||
"""Write one audit record. Called by the middleware after every tool call."""
|
||||
entry = {
|
||||
"event_id": event_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(timespec="microseconds"),
|
||||
"schema_version": "1",
|
||||
"source": "nexus-mcp",
|
||||
"tool": tool,
|
||||
"shard": _infer_shard(tool),
|
||||
"action_category": _infer_action_category(tool),
|
||||
"args_summary": _redact(args),
|
||||
"mock_mode": self._mock_mode,
|
||||
"status": status,
|
||||
"latency_ms": latency_ms,
|
||||
"error_type": error_type,
|
||||
"error_message": error_message,
|
||||
}
|
||||
line = json.dumps(entry, default=str)
|
||||
with open(self._log_file, "a", encoding="utf-8") as fh:
|
||||
fh.write(line + "\n")
|
||||
if self._log_to_stderr:
|
||||
self._stderr_logger.info(line)
|
||||
|
||||
# ── Convenience helpers ────────────────────────────────────────────────────
|
||||
|
||||
def record_success(self, event_id: str, tool: str, args: dict, latency_ms: int) -> None:
|
||||
self.record(event_id=event_id, tool=tool, args=args,
|
||||
status="success", latency_ms=latency_ms)
|
||||
|
||||
def record_error(self, event_id: str, tool: str, args: dict, latency_ms: int,
|
||||
exc: Exception) -> None:
|
||||
self.record(
|
||||
event_id=event_id, tool=tool, args=args,
|
||||
status="error", latency_ms=latency_ms,
|
||||
error_type=type(exc).__name__,
|
||||
error_message=_sanitise_error(str(exc)),
|
||||
)
|
||||
|
||||
# ── Singleton accessor ────────────────────────────────────────────────────
|
||||
|
||||
@classmethod
|
||||
def get(cls) -> AuditLogger:
|
||||
"""Return the process-wide singleton (created on first call)."""
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
|
||||
def _sanitise_error(msg: str) -> str:
|
||||
"""Strip anything that looks like a credential or token from an error string."""
|
||||
import re
|
||||
msg = re.sub(r"(password|secret|token|key)\s*[=:]\s*\S+",
|
||||
r"\1=**REDACTED**", msg, flags=re.IGNORECASE)
|
||||
return msg[:500]
|
||||
|
||||
|
||||
# ── Audit log reader (for compliance queries) ─────────────────────────────────
|
||||
|
||||
def tail_audit_log(n: int = 100, log_file: Path | None = None) -> list[dict]:
|
||||
"""Return the last *n* audit log entries as parsed dicts.
|
||||
|
||||
Useful for building internal compliance dashboards or spot-checks.
|
||||
"""
|
||||
if log_file is None:
|
||||
from config import AuditConfig
|
||||
log_file = AuditConfig().log_file
|
||||
if not Path(log_file).exists():
|
||||
return []
|
||||
with open(log_file, encoding="utf-8") as fh:
|
||||
lines = fh.readlines()
|
||||
return [json.loads(l) for l in lines[-n:] if l.strip()]
|
||||
|
||||
|
||||
def audit_log_stats(log_file: Path | None = None) -> dict:
|
||||
"""Summarise the audit log — total calls, errors, tool frequency.
|
||||
|
||||
Returns a dict suitable for feeding into a compliance dashboard.
|
||||
"""
|
||||
entries = tail_audit_log(n=100_000, log_file=log_file)
|
||||
if not entries:
|
||||
return {"total_entries": 0}
|
||||
|
||||
from collections import Counter
|
||||
tools = Counter(e["tool"] for e in entries)
|
||||
statuses = Counter(e["status"] for e in entries)
|
||||
shards = Counter(e["shard"] for e in entries)
|
||||
categories = Counter(e["action_category"] for e in entries)
|
||||
errors = [e for e in entries if e["status"] == "error"]
|
||||
|
||||
return {
|
||||
"total_entries": len(entries),
|
||||
"status_breakdown": dict(statuses),
|
||||
"shard_breakdown": dict(shards),
|
||||
"action_category_breakdown": dict(categories),
|
||||
"top_10_tools": tools.most_common(10),
|
||||
"error_count": len(errors),
|
||||
"recent_errors": [
|
||||
{"timestamp": e["timestamp"], "tool": e["tool"],
|
||||
"error_type": e["error_type"], "error_message": e["error_message"]}
|
||||
for e in errors[-10:]
|
||||
],
|
||||
"mock_mode_calls": sum(1 for e in entries if e.get("mock_mode")),
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Centralised config — loaded from environment / .env file."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env from the project root (nexus-mcp/)
|
||||
load_dotenv(Path(__file__).parent.parent / ".env")
|
||||
|
||||
|
||||
class ADConfig:
|
||||
server: str = os.getenv("AD_SERVER", "")
|
||||
port: int = int(os.getenv("AD_PORT", "389"))
|
||||
base_dn: str = os.getenv("AD_BASE_DN", "")
|
||||
user: str = os.getenv("AD_USER", "")
|
||||
password: str = os.getenv("AD_PASSWORD", "")
|
||||
use_ssl: bool = os.getenv("AD_USE_SSL", "false").lower() == "true"
|
||||
|
||||
|
||||
class EntraConfig:
|
||||
tenant_id: str = os.getenv("ENTRA_TENANT_ID", "")
|
||||
client_id: str = os.getenv("ENTRA_CLIENT_ID", "")
|
||||
client_secret: str = os.getenv("ENTRA_CLIENT_SECRET", "")
|
||||
|
||||
|
||||
class IntuneConfig:
|
||||
tenant_id: str = os.getenv("INTUNE_TENANT_ID") or os.getenv("ENTRA_TENANT_ID", "")
|
||||
client_id: str = os.getenv("INTUNE_CLIENT_ID") or os.getenv("ENTRA_CLIENT_ID", "")
|
||||
client_secret: str = os.getenv("INTUNE_CLIENT_SECRET") or os.getenv("ENTRA_CLIENT_SECRET", "")
|
||||
|
||||
|
||||
class WorkdayConfig:
|
||||
base_url: str = os.getenv("WORKDAY_BASE_URL", "")
|
||||
tenant: str = os.getenv("WORKDAY_TENANT", "")
|
||||
client_id: str = os.getenv("WORKDAY_CLIENT_ID", "")
|
||||
client_secret: str = os.getenv("WORKDAY_CLIENT_SECRET", "")
|
||||
refresh_token: str = os.getenv("WORKDAY_REFRESH_TOKEN", "")
|
||||
|
||||
|
||||
class HelixConfig:
|
||||
base_url: str = os.getenv("HELIX_BASE_URL", "")
|
||||
username: str = os.getenv("HELIX_USERNAME", "")
|
||||
password: str = os.getenv("HELIX_PASSWORD", "")
|
||||
|
||||
|
||||
class LansweeperConfig:
|
||||
api_url: str = os.getenv("LANSWEEPER_API_URL", "https://api.lansweeper.com/api/v2/graphql")
|
||||
application_id: str = os.getenv("LANSWEEPER_APPLICATION_ID", "")
|
||||
application_secret: str = os.getenv("LANSWEEPER_APPLICATION_SECRET", "")
|
||||
site_id: str = os.getenv("LANSWEEPER_SITE_ID", "")
|
||||
|
||||
|
||||
class FedExConfig:
|
||||
api_url: str = os.getenv("FEDEX_API_URL", "https://apis.fedex.com")
|
||||
api_key: str = os.getenv("FEDEX_API_KEY", "")
|
||||
api_secret: str = os.getenv("FEDEX_API_SECRET", "")
|
||||
account_number: str = os.getenv("FEDEX_ACCOUNT_NUMBER", "")
|
||||
|
||||
|
||||
class ReportConfig:
|
||||
output_dir: Path = Path(os.getenv("REPORT_OUTPUT_DIR", "./reports"))
|
||||
|
||||
|
||||
class AuditConfig:
|
||||
"""SOC 2 audit log configuration.
|
||||
|
||||
Controls:
|
||||
CC7.2 — System Monitoring: log_file is the append-only audit trail.
|
||||
CC6.1 — Logical Access: log_to_stderr enables SIEM/syslog forwarding.
|
||||
"""
|
||||
log_file: Path = Path(os.getenv("AUDIT_LOG_FILE", "./logs/nexus_audit.jsonl"))
|
||||
log_to_stderr: bool = os.getenv("AUDIT_LOG_STDERR", "true").lower() == "true"
|
||||
enabled: bool = os.getenv("AUDIT_LOGGING_ENABLED", "true").lower() == "true"
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Microsoft Entra ID (Graph API) adapter (lib layer)."""
|
||||
|
||||
from typing import Any
|
||||
import httpx
|
||||
import msal
|
||||
from config import EntraConfig
|
||||
|
||||
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
|
||||
GRAPH_BETA = "https://graph.microsoft.com/beta"
|
||||
|
||||
|
||||
class EntraClient:
|
||||
"""Low-level Microsoft Graph client for Entra ID.
|
||||
|
||||
Handles token acquisition and pagination transparently.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.cfg = EntraConfig()
|
||||
self._app: msal.ConfidentialClientApplication | None = None
|
||||
self._http = httpx.AsyncClient(timeout=30)
|
||||
|
||||
def _get_app(self) -> msal.ConfidentialClientApplication:
|
||||
if self._app is None:
|
||||
self._app = msal.ConfidentialClientApplication(
|
||||
self.cfg.client_id,
|
||||
authority=f"https://login.microsoftonline.com/{self.cfg.tenant_id}",
|
||||
client_credential=self.cfg.client_secret,
|
||||
)
|
||||
return self._app
|
||||
|
||||
async def get_token(self) -> str:
|
||||
app = self._get_app()
|
||||
result = app.acquire_token_silent(
|
||||
["https://graph.microsoft.com/.default"], account=None
|
||||
)
|
||||
if not result:
|
||||
result = app.acquire_token_for_client(
|
||||
scopes=["https://graph.microsoft.com/.default"]
|
||||
)
|
||||
if "access_token" not in result:
|
||||
raise RuntimeError(f"MSAL token error: {result.get('error_description')}")
|
||||
return result["access_token"]
|
||||
|
||||
async def get(self, path: str, params: dict | None = None, beta: bool = False) -> Any:
|
||||
token = await self.get_token()
|
||||
base = GRAPH_BETA if beta else GRAPH_BASE
|
||||
resp = await self._http.get(
|
||||
f"{base}{path}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
params=params,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_all_pages(self, path: str, params: dict | None = None) -> list[dict]:
|
||||
results: list[dict] = []
|
||||
data = await self.get(path, params)
|
||||
results.extend(data.get("value", []))
|
||||
while "@odata.nextLink" in data:
|
||||
token = await self.get_token()
|
||||
resp = await self._http.get(
|
||||
data["@odata.nextLink"],
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
results.extend(data.get("value", []))
|
||||
return results
|
||||
|
||||
async def close(self):
|
||||
await self._http.aclose()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""FedEx REST API adapter (lib layer)."""
|
||||
|
||||
from typing import Any
|
||||
import httpx
|
||||
from config import FedExConfig
|
||||
|
||||
|
||||
class FedExClient:
|
||||
"""Low-level FedEx Track + Ship REST client."""
|
||||
|
||||
def __init__(self):
|
||||
self.cfg = FedExConfig()
|
||||
self._token: str | None = None
|
||||
self._http = httpx.AsyncClient(timeout=30)
|
||||
|
||||
async def get_token(self) -> str:
|
||||
if self._token:
|
||||
return self._token
|
||||
resp = await self._http.post(
|
||||
f"{self.cfg.api_url}/oauth/token",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.cfg.api_key,
|
||||
"client_secret": self.cfg.api_secret,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
self._token = resp.json()["access_token"]
|
||||
return self._token
|
||||
|
||||
async def post(self, path: str, body: dict) -> Any:
|
||||
token = await self.get_token()
|
||||
resp = await self._http.post(
|
||||
f"{self.cfg.api_url}{path}",
|
||||
json=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"X-locale": "en_US",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def close(self):
|
||||
await self._http.aclose()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""BMC Helix ITSM REST API adapter (lib layer)."""
|
||||
|
||||
from typing import Any
|
||||
import httpx
|
||||
from config import HelixConfig
|
||||
|
||||
|
||||
class HelixClient:
|
||||
"""Low-level BMC Helix REST client.
|
||||
|
||||
Handles AR-JWT token acquisition.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.cfg = HelixConfig()
|
||||
self._token: str | None = None
|
||||
self._http = httpx.AsyncClient(timeout=30)
|
||||
|
||||
async def get_token(self) -> str:
|
||||
if self._token:
|
||||
return self._token
|
||||
resp = await self._http.post(
|
||||
f"{self.cfg.base_url}/api/jwt/login",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
data={"username": self.cfg.username, "password": self.cfg.password},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
self._token = resp.text.strip()
|
||||
return self._token
|
||||
|
||||
async def get(self, path: str, params: dict | None = None) -> Any:
|
||||
token = await self.get_token()
|
||||
resp = await self._http.get(
|
||||
f"{self.cfg.base_url}{path}",
|
||||
headers={"Authorization": f"AR-JWT {token}"},
|
||||
params=params,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def post(self, path: str, body: dict) -> Any:
|
||||
token = await self.get_token()
|
||||
resp = await self._http.post(
|
||||
f"{self.cfg.base_url}{path}",
|
||||
headers={"Authorization": f"AR-JWT {token}", "Content-Type": "application/json"},
|
||||
json=body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def close(self):
|
||||
await self._http.aclose()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Microsoft Intune (Graph API) adapter (lib layer)."""
|
||||
|
||||
from typing import Any
|
||||
import httpx
|
||||
import msal
|
||||
from config import IntuneConfig
|
||||
|
||||
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
|
||||
GRAPH_BETA = "https://graph.microsoft.com/beta"
|
||||
|
||||
|
||||
class IntuneClient:
|
||||
"""Low-level Graph API client scoped to deviceManagement endpoints."""
|
||||
|
||||
def __init__(self):
|
||||
self.cfg = IntuneConfig()
|
||||
self._app: msal.ConfidentialClientApplication | None = None
|
||||
self._http = httpx.AsyncClient(timeout=30)
|
||||
|
||||
def _get_app(self) -> msal.ConfidentialClientApplication:
|
||||
if self._app is None:
|
||||
self._app = msal.ConfidentialClientApplication(
|
||||
self.cfg.client_id,
|
||||
authority=f"https://login.microsoftonline.com/{self.cfg.tenant_id}",
|
||||
client_credential=self.cfg.client_secret,
|
||||
)
|
||||
return self._app
|
||||
|
||||
async def get_token(self) -> str:
|
||||
app = self._get_app()
|
||||
result = app.acquire_token_silent(
|
||||
["https://graph.microsoft.com/.default"], account=None
|
||||
)
|
||||
if not result:
|
||||
result = app.acquire_token_for_client(
|
||||
scopes=["https://graph.microsoft.com/.default"]
|
||||
)
|
||||
if "access_token" not in result:
|
||||
raise RuntimeError(f"MSAL token error: {result.get('error_description')}")
|
||||
return result["access_token"]
|
||||
|
||||
async def get(self, path: str, params: dict | None = None, beta: bool = False) -> Any:
|
||||
token = await self.get_token()
|
||||
base = GRAPH_BETA if beta else GRAPH_BASE
|
||||
resp = await self._http.get(
|
||||
f"{base}{path}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
params=params,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def close(self):
|
||||
await self._http.aclose()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Lansweeper GraphQL API adapter (lib layer)."""
|
||||
|
||||
from typing import Any
|
||||
import httpx
|
||||
from config import LansweeperConfig
|
||||
|
||||
|
||||
class LansweeperClient:
|
||||
"""Low-level Lansweeper Cloud GraphQL client."""
|
||||
|
||||
def __init__(self):
|
||||
self.cfg = LansweeperConfig()
|
||||
self._token: str | None = None
|
||||
self._http = httpx.AsyncClient(timeout=30)
|
||||
|
||||
async def get_token(self) -> str:
|
||||
if self._token:
|
||||
return self._token
|
||||
resp = await self._http.post(
|
||||
"https://app.lansweeper.com/api/oauth/token",
|
||||
json={
|
||||
"client_id": self.cfg.application_id,
|
||||
"client_secret": self.cfg.application_secret,
|
||||
"grant_type": "client_credentials",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
self._token = resp.json()["access_token"]
|
||||
return self._token
|
||||
|
||||
async def gql(self, query: str, variables: dict | None = None) -> Any:
|
||||
token = await self.get_token()
|
||||
resp = await self._http.post(
|
||||
self.cfg.api_url,
|
||||
json={"query": query, "variables": variables or {}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
if "errors" in payload:
|
||||
raise RuntimeError(f"Lansweeper GQL error: {payload['errors']}")
|
||||
return payload["data"]
|
||||
|
||||
async def close(self):
|
||||
await self._http.aclose()
|
||||
@@ -0,0 +1,905 @@
|
||||
"""Nexus-MCP Mock Data Library.
|
||||
|
||||
Realistic enterprise mock data for all 6 shards.
|
||||
Deliberate drift is seeded across systems to make audit tools return meaningful results.
|
||||
|
||||
Drift scenarios built in:
|
||||
- Bob Martinez: jobTitle differs between AD ("Sr. Software Engineer") and Workday/Entra ("Software Engineer")
|
||||
- Carol Chen: department differs — Workday "Product Management" vs AD/Entra "Engineering"
|
||||
- LAPTOP-CAROL-03: serialNumber differs — Lansweeper "SN-CAROL-03A" vs Intune "SN-CAROL-03B"
|
||||
- David Kim: AD account disabled but Entra account still enabled
|
||||
- Emma Wilson: AD stale account (no login in 120 days)
|
||||
- SERVER-PROD-01: exists in Lansweeper + Helix CMDB but not in Intune (unmanaged server)
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
_NOW = datetime.datetime.utcnow()
|
||||
_FMT = "%Y-%m-%dT%H:%M:%SZ"
|
||||
_D = lambda days: (_NOW - datetime.timedelta(days=days)).strftime(_FMT)
|
||||
_DATE = lambda days: (_NOW - datetime.timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
# ─── Active Directory ─────────────────────────────────────────────────────────
|
||||
|
||||
AD_USERS: list[dict] = [
|
||||
{
|
||||
"dn": "CN=Alice Johnson,OU=Users,DC=corp,DC=nexus,DC=local",
|
||||
"sAMAccountName": "alicej",
|
||||
"displayName": "Alice Johnson",
|
||||
"mail": "alice.johnson@nexus.corp",
|
||||
"department": "Engineering",
|
||||
"title": "Engineering Manager",
|
||||
"manager": "CN=Frank Davis,OU=Users,DC=corp,DC=nexus,DC=local",
|
||||
"memberOf": "CN=Engineering,OU=Groups,DC=corp,DC=nexus,DC=local",
|
||||
"employeeID": "EMP-1001",
|
||||
"userAccountControl": "512",
|
||||
"lastLogonTimestamp": _D(3),
|
||||
"whenCreated": "2019-04-01T08:00:00Z",
|
||||
"telephoneNumber": "+1-555-0101",
|
||||
"physicalDeliveryOfficeName": "HQ-Floor3",
|
||||
"cn": "Alice Johnson",
|
||||
},
|
||||
{
|
||||
"dn": "CN=Bob Martinez,OU=Users,DC=corp,DC=nexus,DC=local",
|
||||
"sAMAccountName": "bobm",
|
||||
"displayName": "Bob Martinez",
|
||||
"mail": "bob.martinez@nexus.corp",
|
||||
"department": "Engineering",
|
||||
# ⚡ DRIFT: AD title doesn't match Workday/Entra ("Software Engineer")
|
||||
"title": "Sr. Software Engineer",
|
||||
"manager": "CN=Alice Johnson,OU=Users,DC=corp,DC=nexus,DC=local",
|
||||
"memberOf": "CN=Engineering,OU=Groups,DC=corp,DC=nexus,DC=local",
|
||||
"employeeID": "EMP-1002",
|
||||
"userAccountControl": "512",
|
||||
"lastLogonTimestamp": _D(1),
|
||||
"whenCreated": "2021-06-15T08:00:00Z",
|
||||
"telephoneNumber": "+1-555-0102",
|
||||
"physicalDeliveryOfficeName": "HQ-Floor3",
|
||||
"cn": "Bob Martinez",
|
||||
},
|
||||
{
|
||||
"dn": "CN=Carol Chen,OU=Users,DC=corp,DC=nexus,DC=local",
|
||||
"sAMAccountName": "carolc",
|
||||
"displayName": "Carol Chen",
|
||||
"mail": "carol.chen@nexus.corp",
|
||||
# ⚡ DRIFT: AD department is "Engineering" but Workday/Entra is "Product Management"
|
||||
"department": "Engineering",
|
||||
"title": "Product Manager",
|
||||
"manager": "CN=Alice Johnson,OU=Users,DC=corp,DC=nexus,DC=local",
|
||||
"memberOf": "CN=Product,OU=Groups,DC=corp,DC=nexus,DC=local",
|
||||
"employeeID": "EMP-1003",
|
||||
"userAccountControl": "512",
|
||||
"lastLogonTimestamp": _D(2),
|
||||
"whenCreated": "2020-09-10T08:00:00Z",
|
||||
"telephoneNumber": "+1-555-0103",
|
||||
"physicalDeliveryOfficeName": "HQ-Floor2",
|
||||
"cn": "Carol Chen",
|
||||
},
|
||||
{
|
||||
"dn": "CN=David Kim,OU=Users,DC=corp,DC=nexus,DC=local",
|
||||
"sAMAccountName": "davidk",
|
||||
"displayName": "David Kim",
|
||||
"mail": "david.kim@nexus.corp",
|
||||
"department": "IT Operations",
|
||||
"title": "Systems Administrator",
|
||||
"manager": "CN=Frank Davis,OU=Users,DC=corp,DC=nexus,DC=local",
|
||||
"memberOf": "CN=IT Ops,OU=Groups,DC=corp,DC=nexus,DC=local",
|
||||
"employeeID": "EMP-1004",
|
||||
# ⚡ DRIFT: disabled in AD but active in Entra
|
||||
"userAccountControl": "514",
|
||||
"lastLogonTimestamp": _D(45),
|
||||
"whenCreated": "2018-02-20T08:00:00Z",
|
||||
"telephoneNumber": "+1-555-0104",
|
||||
"physicalDeliveryOfficeName": "HQ-Floor1",
|
||||
"cn": "David Kim",
|
||||
},
|
||||
{
|
||||
"dn": "CN=Emma Wilson,OU=Users,DC=corp,DC=nexus,DC=local",
|
||||
"sAMAccountName": "emmaw",
|
||||
"displayName": "Emma Wilson",
|
||||
"mail": "emma.wilson@nexus.corp",
|
||||
"department": "HR",
|
||||
"title": "HR Specialist",
|
||||
"manager": "CN=Grace Lee,OU=Users,DC=corp,DC=nexus,DC=local",
|
||||
"memberOf": "CN=HR,OU=Groups,DC=corp,DC=nexus,DC=local",
|
||||
"employeeID": "EMP-1005",
|
||||
"userAccountControl": "512",
|
||||
# ⚡ Stale: no login in 120 days
|
||||
"lastLogonTimestamp": _D(120),
|
||||
"whenCreated": "2017-11-05T08:00:00Z",
|
||||
"telephoneNumber": "+1-555-0105",
|
||||
"physicalDeliveryOfficeName": "HQ-Floor2",
|
||||
"cn": "Emma Wilson",
|
||||
},
|
||||
{
|
||||
"dn": "CN=Frank Davis,OU=Users,DC=corp,DC=nexus,DC=local",
|
||||
"sAMAccountName": "frankd",
|
||||
"displayName": "Frank Davis",
|
||||
"mail": "frank.davis@nexus.corp",
|
||||
"department": "IT Operations",
|
||||
"title": "IT Director",
|
||||
"manager": None,
|
||||
"memberOf": "CN=IT Ops,OU=Groups,DC=corp,DC=nexus,DC=local",
|
||||
"employeeID": "EMP-1006",
|
||||
"userAccountControl": "512",
|
||||
"lastLogonTimestamp": _D(0),
|
||||
"whenCreated": "2016-03-01T08:00:00Z",
|
||||
"telephoneNumber": "+1-555-0106",
|
||||
"physicalDeliveryOfficeName": "HQ-Floor1",
|
||||
"cn": "Frank Davis",
|
||||
},
|
||||
{
|
||||
"dn": "CN=Grace Lee,OU=Users,DC=corp,DC=nexus,DC=local",
|
||||
"sAMAccountName": "gracel",
|
||||
"displayName": "Grace Lee",
|
||||
"mail": "grace.lee@nexus.corp",
|
||||
"department": "HR",
|
||||
"title": "HR Director",
|
||||
"manager": None,
|
||||
"memberOf": "CN=HR,OU=Groups,DC=corp,DC=nexus,DC=local",
|
||||
"employeeID": "EMP-1007",
|
||||
"userAccountControl": "512",
|
||||
"lastLogonTimestamp": _D(1),
|
||||
"whenCreated": "2016-07-15T08:00:00Z",
|
||||
"telephoneNumber": "+1-555-0107",
|
||||
"physicalDeliveryOfficeName": "HQ-Floor2",
|
||||
"cn": "Grace Lee",
|
||||
},
|
||||
]
|
||||
|
||||
AD_USERS_BY_SAM = {u["sAMAccountName"]: u for u in AD_USERS}
|
||||
AD_USERS_BY_EMAIL = {u["mail"]: u for u in AD_USERS}
|
||||
|
||||
AD_GROUPS: list[dict] = [
|
||||
{"dn": "CN=Engineering,OU=Groups,DC=corp,DC=nexus,DC=local", "cn": "Engineering",
|
||||
"description": "All engineering staff", "memberCount": 3, "groupType": "-2147483646", "whenCreated": "2016-01-01T00:00:00Z"},
|
||||
{"dn": "CN=IT Ops,OU=Groups,DC=corp,DC=nexus,DC=local", "cn": "IT Ops",
|
||||
"description": "IT Operations team", "memberCount": 2, "groupType": "-2147483646", "whenCreated": "2016-01-01T00:00:00Z"},
|
||||
{"dn": "CN=HR,OU=Groups,DC=corp,DC=nexus,DC=local", "cn": "HR",
|
||||
"description": "Human Resources", "memberCount": 2, "groupType": "-2147483648", "whenCreated": "2016-01-01T00:00:00Z"},
|
||||
{"dn": "CN=Product,OU=Groups,DC=corp,DC=nexus,DC=local", "cn": "Product",
|
||||
"description": "Product Management team", "memberCount": 1, "groupType": "-2147483646", "whenCreated": "2018-06-01T00:00:00Z"},
|
||||
{"dn": "CN=VPN Users,OU=Groups,DC=corp,DC=nexus,DC=local", "cn": "VPN Users",
|
||||
"description": "Remote access group", "memberCount": 6, "groupType": "-2147483648", "whenCreated": "2017-01-01T00:00:00Z"},
|
||||
]
|
||||
|
||||
|
||||
# ─── Microsoft Entra ID ───────────────────────────────────────────────────────
|
||||
|
||||
ENTRA_USERS: list[dict] = [
|
||||
{
|
||||
"id": "aaa11111-0000-0000-0000-000000000001",
|
||||
"displayName": "Alice Johnson",
|
||||
"userPrincipalName": "alice.johnson@nexus.corp",
|
||||
"mail": "alice.johnson@nexus.corp",
|
||||
"jobTitle": "Engineering Manager",
|
||||
"department": "Engineering",
|
||||
"accountEnabled": True,
|
||||
"createdDateTime": "2019-04-01T08:00:00Z",
|
||||
"onPremisesSyncEnabled": True,
|
||||
"assignedLicenses": [{"skuId": "6fd2c87f-b296-42f0-b197-1e91e994b900"}],
|
||||
},
|
||||
{
|
||||
"id": "aaa11111-0000-0000-0000-000000000002",
|
||||
"displayName": "Bob Martinez",
|
||||
"userPrincipalName": "bob.martinez@nexus.corp",
|
||||
"mail": "bob.martinez@nexus.corp",
|
||||
# ⚡ DRIFT: Entra has "Software Engineer" but AD has "Sr. Software Engineer"
|
||||
"jobTitle": "Software Engineer",
|
||||
"department": "Engineering",
|
||||
"accountEnabled": True,
|
||||
"createdDateTime": "2021-06-15T08:00:00Z",
|
||||
"onPremisesSyncEnabled": True,
|
||||
"assignedLicenses": [{"skuId": "6fd2c87f-b296-42f0-b197-1e91e994b900"}],
|
||||
},
|
||||
{
|
||||
"id": "aaa11111-0000-0000-0000-000000000003",
|
||||
"displayName": "Carol Chen",
|
||||
"userPrincipalName": "carol.chen@nexus.corp",
|
||||
"mail": "carol.chen@nexus.corp",
|
||||
"jobTitle": "Product Manager",
|
||||
# ⚡ DRIFT: Entra has "Product Management" but AD has "Engineering"
|
||||
"department": "Product Management",
|
||||
"accountEnabled": True,
|
||||
"createdDateTime": "2020-09-10T08:00:00Z",
|
||||
"onPremisesSyncEnabled": True,
|
||||
"assignedLicenses": [{"skuId": "6fd2c87f-b296-42f0-b197-1e91e994b900"}],
|
||||
},
|
||||
{
|
||||
"id": "aaa11111-0000-0000-0000-000000000004",
|
||||
"displayName": "David Kim",
|
||||
"userPrincipalName": "david.kim@nexus.corp",
|
||||
"mail": "david.kim@nexus.corp",
|
||||
"jobTitle": "Systems Administrator",
|
||||
"department": "IT Operations",
|
||||
# ⚡ DRIFT: Entra still enabled but AD is disabled
|
||||
"accountEnabled": True,
|
||||
"createdDateTime": "2018-02-20T08:00:00Z",
|
||||
"onPremisesSyncEnabled": True,
|
||||
"assignedLicenses": [{"skuId": "6fd2c87f-b296-42f0-b197-1e91e994b900"}],
|
||||
},
|
||||
{
|
||||
"id": "aaa11111-0000-0000-0000-000000000005",
|
||||
"displayName": "Emma Wilson",
|
||||
"userPrincipalName": "emma.wilson@nexus.corp",
|
||||
"mail": "emma.wilson@nexus.corp",
|
||||
"jobTitle": "HR Specialist",
|
||||
"department": "HR",
|
||||
"accountEnabled": True,
|
||||
"createdDateTime": "2017-11-05T08:00:00Z",
|
||||
"onPremisesSyncEnabled": True,
|
||||
"assignedLicenses": [{"skuId": "6fd2c87f-b296-42f0-b197-1e91e994b900"}],
|
||||
},
|
||||
{
|
||||
"id": "aaa11111-0000-0000-0000-000000000006",
|
||||
"displayName": "Frank Davis",
|
||||
"userPrincipalName": "frank.davis@nexus.corp",
|
||||
"mail": "frank.davis@nexus.corp",
|
||||
"jobTitle": "IT Director",
|
||||
"department": "IT Operations",
|
||||
"accountEnabled": True,
|
||||
"createdDateTime": "2016-03-01T08:00:00Z",
|
||||
"onPremisesSyncEnabled": True,
|
||||
"assignedLicenses": [{"skuId": "6fd2c87f-b296-42f0-b197-1e91e994b900"}],
|
||||
},
|
||||
{
|
||||
"id": "aaa11111-0000-0000-0000-000000000007",
|
||||
"displayName": "Grace Lee",
|
||||
"userPrincipalName": "grace.lee@nexus.corp",
|
||||
"mail": "grace.lee@nexus.corp",
|
||||
"jobTitle": "HR Director",
|
||||
"department": "HR",
|
||||
"accountEnabled": True,
|
||||
"createdDateTime": "2016-07-15T08:00:00Z",
|
||||
"onPremisesSyncEnabled": True,
|
||||
"assignedLicenses": [{"skuId": "6fd2c87f-b296-42f0-b197-1e91e994b900"}],
|
||||
},
|
||||
{
|
||||
"id": "aaa11111-0000-0000-0000-000000000008",
|
||||
"displayName": "Svc-Monitoring",
|
||||
"userPrincipalName": "svc-monitoring@nexus.corp",
|
||||
"mail": None,
|
||||
"jobTitle": None,
|
||||
"department": None,
|
||||
"accountEnabled": True,
|
||||
"createdDateTime": "2020-01-01T00:00:00Z",
|
||||
"onPremisesSyncEnabled": False,
|
||||
"assignedLicenses": [],
|
||||
},
|
||||
]
|
||||
|
||||
ENTRA_USERS_BY_MAIL = {u["mail"]: u for u in ENTRA_USERS if u.get("mail")}
|
||||
ENTRA_USERS_BY_UPN = {u["userPrincipalName"]: u for u in ENTRA_USERS}
|
||||
|
||||
ENTRA_GROUPS: list[dict] = [
|
||||
{"id": "ggg00001", "displayName": "Engineering", "description": "Engineering staff", "groupTypes": ["Unified"], "mailEnabled": True},
|
||||
{"id": "ggg00002", "displayName": "IT Operations", "description": "IT Ops team", "groupTypes": [], "mailEnabled": False},
|
||||
{"id": "ggg00003", "displayName": "HR", "description": "Human Resources", "groupTypes": [], "mailEnabled": False},
|
||||
{"id": "ggg00004", "displayName": "All Employees", "description": "Company-wide distribution list", "groupTypes": [], "mailEnabled": True},
|
||||
{"id": "ggg00005", "displayName": "Intune Admins", "description": "Intune device management admins", "groupTypes": [], "mailEnabled": False},
|
||||
]
|
||||
|
||||
ENTRA_CONDITIONAL_ACCESS_POLICIES: list[dict] = [
|
||||
{
|
||||
"id": "cap-001", "displayName": "Require MFA for All Users",
|
||||
"state": "enabled",
|
||||
"conditions": {"users": {"includeUsers": ["All"]}, "applications": {"includeApplications": ["All"]}},
|
||||
"grantControls": {"operator": "OR", "builtInControls": ["mfa"]},
|
||||
},
|
||||
{
|
||||
"id": "cap-002", "displayName": "Block Legacy Authentication",
|
||||
"state": "enabled",
|
||||
"conditions": {"clientAppTypes": ["exchangeActiveSync", "other"]},
|
||||
"grantControls": {"operator": "OR", "builtInControls": ["block"]},
|
||||
},
|
||||
{
|
||||
"id": "cap-003", "displayName": "Require Compliant Device for Corp Apps",
|
||||
"state": "enabledForReportingButNotEnforced",
|
||||
"conditions": {"users": {"includeUsers": ["All"]}, "applications": {"includeApplications": ["Office365"]}},
|
||||
"grantControls": {"operator": "AND", "builtInControls": ["compliantDevice"]},
|
||||
},
|
||||
]
|
||||
|
||||
ENTRA_RISKY_USERS: list[dict] = [
|
||||
{
|
||||
"id": "aaa11111-0000-0000-0000-000000000002",
|
||||
"isDeleted": False,
|
||||
"isProcessing": False,
|
||||
"riskLevel": "medium",
|
||||
"riskState": "atRisk",
|
||||
"riskDetail": "userPassedMFADrivenByRiskBasedPolicy",
|
||||
"riskLastUpdatedDateTime": _D(2),
|
||||
"userDisplayName": "Bob Martinez",
|
||||
"userPrincipalName": "bob.martinez@nexus.corp",
|
||||
},
|
||||
]
|
||||
|
||||
ENTRA_SIGNIN_LOGS: list[dict] = [
|
||||
{
|
||||
"id": "sign-001", "createdDateTime": _D(0),
|
||||
"userDisplayName": "Alice Johnson", "userPrincipalName": "alice.johnson@nexus.corp",
|
||||
"appDisplayName": "Microsoft Teams", "ipAddress": "203.0.113.42",
|
||||
"status": {"errorCode": 0, "failureReason": None}, "location": {"city": "New York", "countryOrRegion": "US"},
|
||||
},
|
||||
{
|
||||
"id": "sign-002", "createdDateTime": _D(0),
|
||||
"userDisplayName": "Bob Martinez", "userPrincipalName": "bob.martinez@nexus.corp",
|
||||
"appDisplayName": "GitHub Enterprise", "ipAddress": "198.51.100.17",
|
||||
"status": {"errorCode": 50126, "failureReason": "Invalid username or password"}, "location": {"city": "Unknown", "countryOrRegion": "RU"},
|
||||
},
|
||||
{
|
||||
"id": "sign-003", "createdDateTime": _D(1),
|
||||
"userDisplayName": "Frank Davis", "userPrincipalName": "frank.davis@nexus.corp",
|
||||
"appDisplayName": "Azure Portal", "ipAddress": "203.0.113.10",
|
||||
"status": {"errorCode": 0, "failureReason": None}, "location": {"city": "New York", "countryOrRegion": "US"},
|
||||
},
|
||||
]
|
||||
|
||||
ENTRA_SERVICE_PRINCIPALS: list[dict] = [
|
||||
{"id": "sp-001", "displayName": "Nexus-MCP Integration App", "appId": "app-nexus-001", "servicePrincipalType": "Application", "accountEnabled": True},
|
||||
{"id": "sp-002", "displayName": "Lansweeper Connector", "appId": "app-ls-002", "servicePrincipalType": "Application", "accountEnabled": True},
|
||||
{"id": "sp-003", "displayName": "SIEM Integration", "appId": "app-siem-003", "servicePrincipalType": "Application", "accountEnabled": False},
|
||||
]
|
||||
|
||||
|
||||
# ─── Workday ──────────────────────────────────────────────────────────────────
|
||||
|
||||
WORKDAY_WORKERS: list[dict] = [
|
||||
{
|
||||
"id": "WD-EMP-1001", "descriptor": "Alice Johnson",
|
||||
"primaryWorkEmail": "alice.johnson@nexus.corp",
|
||||
"hireDate": "2019-04-01",
|
||||
"primaryJob": {
|
||||
"jobProfile": {"descriptor": "Engineering Manager"},
|
||||
"businessUnit": {"descriptor": "Engineering"},
|
||||
"location": {"descriptor": "New York HQ"},
|
||||
},
|
||||
"workerType": {"descriptor": "Employee"},
|
||||
"workerStatus": {"descriptor": "Active"},
|
||||
},
|
||||
{
|
||||
"id": "WD-EMP-1002", "descriptor": "Bob Martinez",
|
||||
"primaryWorkEmail": "bob.martinez@nexus.corp",
|
||||
"hireDate": "2021-06-15",
|
||||
"primaryJob": {
|
||||
# ⚡ DRIFT: Workday says "Software Engineer" (AD says "Sr. Software Engineer")
|
||||
"jobProfile": {"descriptor": "Software Engineer"},
|
||||
"businessUnit": {"descriptor": "Engineering"},
|
||||
"location": {"descriptor": "New York HQ"},
|
||||
},
|
||||
"workerType": {"descriptor": "Employee"},
|
||||
"workerStatus": {"descriptor": "Active"},
|
||||
},
|
||||
{
|
||||
"id": "WD-EMP-1003", "descriptor": "Carol Chen",
|
||||
"primaryWorkEmail": "carol.chen@nexus.corp",
|
||||
"hireDate": "2020-09-10",
|
||||
"primaryJob": {
|
||||
"jobProfile": {"descriptor": "Product Manager"},
|
||||
# ⚡ DRIFT: Workday department is "Product Management" (AD has "Engineering")
|
||||
"businessUnit": {"descriptor": "Product Management"},
|
||||
"location": {"descriptor": "New York HQ"},
|
||||
},
|
||||
"workerType": {"descriptor": "Employee"},
|
||||
"workerStatus": {"descriptor": "Active"},
|
||||
},
|
||||
{
|
||||
"id": "WD-EMP-1004", "descriptor": "David Kim",
|
||||
"primaryWorkEmail": "david.kim@nexus.corp",
|
||||
"hireDate": "2018-02-20",
|
||||
"primaryJob": {
|
||||
"jobProfile": {"descriptor": "Systems Administrator"},
|
||||
"businessUnit": {"descriptor": "IT Operations"},
|
||||
"location": {"descriptor": "New York HQ"},
|
||||
},
|
||||
"workerType": {"descriptor": "Employee"},
|
||||
"workerStatus": {"descriptor": "Leave of Absence"},
|
||||
},
|
||||
{
|
||||
"id": "WD-EMP-1005", "descriptor": "Emma Wilson",
|
||||
"primaryWorkEmail": "emma.wilson@nexus.corp",
|
||||
"hireDate": "2017-11-05",
|
||||
"primaryJob": {
|
||||
"jobProfile": {"descriptor": "HR Specialist"},
|
||||
"businessUnit": {"descriptor": "Human Resources"},
|
||||
"location": {"descriptor": "New York HQ"},
|
||||
},
|
||||
"workerType": {"descriptor": "Employee"},
|
||||
"workerStatus": {"descriptor": "Active"},
|
||||
},
|
||||
{
|
||||
"id": "WD-EMP-1006", "descriptor": "Frank Davis",
|
||||
"primaryWorkEmail": "frank.davis@nexus.corp",
|
||||
"hireDate": "2016-03-01",
|
||||
"primaryJob": {
|
||||
"jobProfile": {"descriptor": "IT Director"},
|
||||
"businessUnit": {"descriptor": "IT Operations"},
|
||||
"location": {"descriptor": "New York HQ"},
|
||||
},
|
||||
"workerType": {"descriptor": "Employee"},
|
||||
"workerStatus": {"descriptor": "Active"},
|
||||
},
|
||||
{
|
||||
"id": "WD-EMP-1007", "descriptor": "Grace Lee",
|
||||
"primaryWorkEmail": "grace.lee@nexus.corp",
|
||||
"hireDate": "2016-07-15",
|
||||
"primaryJob": {
|
||||
"jobProfile": {"descriptor": "HR Director"},
|
||||
"businessUnit": {"descriptor": "Human Resources"},
|
||||
"location": {"descriptor": "New York HQ"},
|
||||
},
|
||||
"workerType": {"descriptor": "Employee"},
|
||||
"workerStatus": {"descriptor": "Active"},
|
||||
},
|
||||
{
|
||||
"id": "WD-EMP-1008", "descriptor": "Henry Park",
|
||||
"primaryWorkEmail": "henry.park@nexus.corp",
|
||||
"hireDate": _DATE(5), # New hire this week
|
||||
"primaryJob": {
|
||||
"jobProfile": {"descriptor": "Junior Software Engineer"},
|
||||
"businessUnit": {"descriptor": "Engineering"},
|
||||
"location": {"descriptor": "New York HQ"},
|
||||
},
|
||||
"workerType": {"descriptor": "Employee"},
|
||||
"workerStatus": {"descriptor": "Active"},
|
||||
},
|
||||
]
|
||||
|
||||
WORKDAY_WORKERS_BY_EMAIL = {w["primaryWorkEmail"]: w for w in WORKDAY_WORKERS}
|
||||
|
||||
WORKDAY_POSITIONS: list[dict] = [
|
||||
{"id": "POS-2001", "descriptor": "Senior Product Manager", "status": "Open", "businessUnit": "Product Management", "location": "New York HQ"},
|
||||
{"id": "POS-2002", "descriptor": "DevOps Engineer", "status": "Open", "businessUnit": "Engineering", "location": "New York HQ"},
|
||||
{"id": "POS-2003", "descriptor": "HR Business Partner", "status": "Open", "businessUnit": "Human Resources", "location": "Remote"},
|
||||
{"id": "POS-2004", "descriptor": "Engineering Manager", "status": "Filled", "businessUnit": "Engineering", "location": "New York HQ"},
|
||||
]
|
||||
|
||||
WORKDAY_ORGANIZATIONS: list[dict] = [
|
||||
{"id": "ORG-100", "descriptor": "Nexus Corp", "type": "Company", "headcount": 8},
|
||||
{"id": "ORG-101", "descriptor": "Engineering", "type": "Supervisory", "headcount": 3, "parent": "ORG-100"},
|
||||
{"id": "ORG-102", "descriptor": "Product Management", "type": "Supervisory", "headcount": 1, "parent": "ORG-100"},
|
||||
{"id": "ORG-103", "descriptor": "IT Operations", "type": "Supervisory", "headcount": 2, "parent": "ORG-100"},
|
||||
{"id": "ORG-104", "descriptor": "Human Resources", "type": "Supervisory", "headcount": 2, "parent": "ORG-100"},
|
||||
]
|
||||
|
||||
WORKDAY_COMPENSATION: dict[str, dict] = {
|
||||
"WD-EMP-1001": {"grade": "G7", "salaryRange": {"min": 130000, "max": 170000, "currency": "USD"}, "payFrequency": "Annual"},
|
||||
"WD-EMP-1002": {"grade": "G5", "salaryRange": {"min": 95000, "max": 125000, "currency": "USD"}, "payFrequency": "Annual"},
|
||||
"WD-EMP-1003": {"grade": "G6", "salaryRange": {"min": 110000, "max": 145000, "currency": "USD"}, "payFrequency": "Annual"},
|
||||
"WD-EMP-1004": {"grade": "G4", "salaryRange": {"min": 80000, "max": 105000, "currency": "USD"}, "payFrequency": "Annual"},
|
||||
"WD-EMP-1005": {"grade": "G3", "salaryRange": {"min": 65000, "max": 85000, "currency": "USD"}, "payFrequency": "Annual"},
|
||||
"WD-EMP-1006": {"grade": "G8", "salaryRange": {"min": 155000, "max": 200000, "currency": "USD"}, "payFrequency": "Annual"},
|
||||
"WD-EMP-1007": {"grade": "G7", "salaryRange": {"min": 130000, "max": 170000, "currency": "USD"}, "payFrequency": "Annual"},
|
||||
"WD-EMP-1008": {"grade": "G2", "salaryRange": {"min": 75000, "max": 95000, "currency": "USD"}, "payFrequency": "Annual"},
|
||||
}
|
||||
|
||||
|
||||
# ─── BMC Helix ITSM ───────────────────────────────────────────────────────────
|
||||
|
||||
def _helix_entry(entry_id: str, values: dict) -> dict:
|
||||
return {"_links": {"self": [{"href": f"/api/arsys/v1/entry/{entry_id}"}]}, "values": values}
|
||||
|
||||
|
||||
HELIX_INCIDENTS: list[dict] = [
|
||||
_helix_entry("INC0001234", {
|
||||
"Incident Number": "INC0001234",
|
||||
"Status": "In Progress",
|
||||
"Priority": "1-Critical",
|
||||
"Summary": "LAPTOP-BOB-02 BitLocker encryption disabled — compliance violation",
|
||||
"Description": "Intune compliance check detected BitLocker is disabled on LAPTOP-BOB-02. Requires immediate remediation.",
|
||||
"Assignee": "Frank Davis",
|
||||
"Assigned Group": "IT Operations",
|
||||
"Customer First Name": "Bob",
|
||||
"Customer Last Name": "Martinez",
|
||||
"Submit Date": _D(1),
|
||||
"Last Modified Date": _D(0),
|
||||
"CI Name": "LAPTOP-BOB-02",
|
||||
"Resolution": None,
|
||||
}),
|
||||
_helix_entry("INC0001235", {
|
||||
"Incident Number": "INC0001235",
|
||||
"Status": "Assigned",
|
||||
"Priority": "2-High",
|
||||
"Summary": "VPN connection dropping intermittently for remote users",
|
||||
"Description": "Multiple users reporting VPN drops every 2-3 hours. Affects 4 remote employees.",
|
||||
"Assignee": "David Kim",
|
||||
"Assigned Group": "IT Operations",
|
||||
"Customer First Name": "Alice",
|
||||
"Customer Last Name": "Johnson",
|
||||
"Submit Date": _D(3),
|
||||
"Last Modified Date": _D(2),
|
||||
"CI Name": "VPN-GW-01",
|
||||
"Resolution": None,
|
||||
}),
|
||||
_helix_entry("INC0001236", {
|
||||
"Incident Number": "INC0001236",
|
||||
"Status": "Pending",
|
||||
"Priority": "4-Low",
|
||||
"Summary": "Password reset request — Emma Wilson",
|
||||
"Description": "User locked out after multiple failed attempts.",
|
||||
"Assignee": "Frank Davis",
|
||||
"Assigned Group": "IT Operations",
|
||||
"Customer First Name": "Emma",
|
||||
"Customer Last Name": "Wilson",
|
||||
"Submit Date": _D(2),
|
||||
"Last Modified Date": _D(1),
|
||||
"CI Name": None,
|
||||
"Resolution": None,
|
||||
}),
|
||||
_helix_entry("INC0001237", {
|
||||
"Incident Number": "INC0001237",
|
||||
"Status": "Resolved",
|
||||
"Priority": "3-Medium",
|
||||
"Summary": "Outlook calendar not syncing on LAPTOP-CAROL-03",
|
||||
"Description": "Calendar sync issue resolved by re-enrolling device profile.",
|
||||
"Assignee": "Frank Davis",
|
||||
"Assigned Group": "IT Operations",
|
||||
"Customer First Name": "Carol",
|
||||
"Customer Last Name": "Chen",
|
||||
"Submit Date": _D(7),
|
||||
"Last Modified Date": _D(5),
|
||||
"CI Name": "LAPTOP-CAROL-03",
|
||||
"Resolution": "Re-enrolled Intune device profile. Calendar syncing correctly.",
|
||||
}),
|
||||
_helix_entry("INC0001238", {
|
||||
"Incident Number": "INC0001238",
|
||||
"Status": "Assigned",
|
||||
"Priority": "2-High",
|
||||
"Summary": "Suspicious sign-in detected for bob.martinez@nexus.corp",
|
||||
"Description": "Entra Identity Protection flagged a medium-risk sign-in from Russia. Account under review.",
|
||||
"Assignee": "Frank Davis",
|
||||
"Assigned Group": "IT Operations",
|
||||
"Customer First Name": "Bob",
|
||||
"Customer Last Name": "Martinez",
|
||||
"Submit Date": _D(2),
|
||||
"Last Modified Date": _D(1),
|
||||
"CI Name": None,
|
||||
"Resolution": None,
|
||||
}),
|
||||
]
|
||||
|
||||
HELIX_INCIDENTS_BY_ID = {e["values"]["Incident Number"]: e for e in HELIX_INCIDENTS}
|
||||
|
||||
HELIX_CHANGES: list[dict] = [
|
||||
_helix_entry("CHG0002001", {
|
||||
"Change Number": "CHG0002001",
|
||||
"Status": "Scheduled",
|
||||
"Change Type": "Standard",
|
||||
"Summary": "Monthly Windows Update cycle — all managed devices",
|
||||
"Description": "Intune-pushed Windows updates for all compliant devices. Scheduled maintenance window.",
|
||||
"Change Manager": "Frank Davis",
|
||||
"Scheduled Start Date": _D(-3),
|
||||
"Scheduled End Date": _D(-1),
|
||||
}),
|
||||
_helix_entry("CHG0002002", {
|
||||
"Change Number": "CHG0002002",
|
||||
"Status": "In Progress",
|
||||
"Change Type": "Emergency",
|
||||
"Summary": "Emergency patch: CVE-2025-9902 remote code execution",
|
||||
"Description": "Critical RCE vulnerability. Emergency patching all Windows 11 endpoints via Intune.",
|
||||
"Change Manager": "Frank Davis",
|
||||
"Scheduled Start Date": _D(0),
|
||||
"Scheduled End Date": _D(-1),
|
||||
}),
|
||||
_helix_entry("CHG0002003", {
|
||||
"Change Number": "CHG0002003",
|
||||
"Status": "Draft",
|
||||
"Change Type": "Normal",
|
||||
"Summary": "Upgrade SERVER-PROD-01 from Ubuntu 22.04 to 24.04",
|
||||
"Description": "Planned OS upgrade with 2-hour downtime window. Backup required before start.",
|
||||
"Change Manager": "Alice Johnson",
|
||||
"Scheduled Start Date": _D(-14),
|
||||
"Scheduled End Date": _D(-13),
|
||||
}),
|
||||
]
|
||||
|
||||
HELIX_CHANGES_BY_ID = {e["values"]["Change Number"]: e for e in HELIX_CHANGES}
|
||||
|
||||
HELIX_PROBLEMS: list[dict] = [
|
||||
_helix_entry("PRB0000456", {
|
||||
"Problem Number": "PRB0000456",
|
||||
"Status": "Under Investigation",
|
||||
"Priority": "2-High",
|
||||
"Summary": "Recurring VPN instability — root cause unknown",
|
||||
"Description": "Three incidents in two weeks. Suspected gateway capacity issue. Network team engaged.",
|
||||
"Assignee": "Frank Davis",
|
||||
"Submit Date": _D(10),
|
||||
"Last Modified Date": _D(1),
|
||||
"CI Name": "VPN-GW-01",
|
||||
"Known Error": False,
|
||||
"Workaround": "Reconnect VPN client. Issue resolves itself within 60 seconds.",
|
||||
}),
|
||||
]
|
||||
|
||||
HELIX_CMDB: list[dict] = [
|
||||
_helix_entry("BMC.ASSET-01", {
|
||||
"Name": "LAPTOP-ALICE-01", "Asset Type": "Workstation",
|
||||
"Manufacturer": "Dell", "Model": "Latitude 5540", "Serial Number": "SN-ALICE-01",
|
||||
"Operating System": "Windows 11 Pro", "Status": "Deployed",
|
||||
"Assigned To": "Alice Johnson", "Location": "HQ-Floor3",
|
||||
"Last Modified Date": _D(30),
|
||||
}),
|
||||
_helix_entry("BMC.ASSET-02", {
|
||||
"Name": "LAPTOP-BOB-02", "Asset Type": "Workstation",
|
||||
"Manufacturer": "HP", "Model": "EliteBook 840 G10", "Serial Number": "SN-BOB-02",
|
||||
"Operating System": "Windows 11 Pro", "Status": "Deployed",
|
||||
"Assigned To": "Bob Martinez", "Location": "HQ-Floor3",
|
||||
"Last Modified Date": _D(14),
|
||||
}),
|
||||
_helix_entry("BMC.ASSET-03", {
|
||||
"Name": "LAPTOP-CAROL-03", "Asset Type": "Workstation",
|
||||
"Manufacturer": "Lenovo", "Model": "ThinkPad X1 Carbon", "Serial Number": "SN-CAROL-03A",
|
||||
"Operating System": "Windows 10 Pro", "Status": "Deployed",
|
||||
"Assigned To": "Carol Chen", "Location": "HQ-Floor2",
|
||||
"Last Modified Date": _D(60),
|
||||
}),
|
||||
_helix_entry("BMC.ASSET-05", {
|
||||
"Name": "SERVER-PROD-01", "Asset Type": "Server",
|
||||
"Manufacturer": "Dell", "Model": "PowerEdge R750", "Serial Number": "SN-PROD-01",
|
||||
"Operating System": "Ubuntu 22.04 LTS", "Status": "Deployed",
|
||||
"Assigned To": "Frank Davis", "Location": "DataCenter-A",
|
||||
"Last Modified Date": _D(90),
|
||||
}),
|
||||
]
|
||||
|
||||
HELIX_CMDB_BY_NAME = {e["values"]["Name"]: e for e in HELIX_CMDB}
|
||||
|
||||
|
||||
# ─── Lansweeper ───────────────────────────────────────────────────────────────
|
||||
|
||||
LANSWEEPER_ASSETS: list[dict] = [
|
||||
{
|
||||
"assetId": "ls-001", "assetName": "LAPTOP-ALICE-01", "assetType": "Windows",
|
||||
"ipAddress": "10.0.1.101", "mac": "AA:BB:CC:DD:EE:01",
|
||||
"lastSeen": _D(0), "lastLoggedOnUser": "CORP\\alicej",
|
||||
"operatingSystem": "Windows 11 Pro 23H2",
|
||||
"domain": "corp.nexus.local", "manufacturer": "Dell",
|
||||
"model": "Latitude 5540", "serialNumber": "SN-ALICE-01",
|
||||
"custom1": "Asset Tag: NEXUS-0101", "custom2": None, "custom3": None, "custom4": None,
|
||||
},
|
||||
{
|
||||
"assetId": "ls-002", "assetName": "LAPTOP-BOB-02", "assetType": "Windows",
|
||||
"ipAddress": "10.0.1.102", "mac": "AA:BB:CC:DD:EE:02",
|
||||
"lastSeen": _D(1), "lastLoggedOnUser": "CORP\\bobm",
|
||||
"operatingSystem": "Windows 11 Pro 23H2",
|
||||
"domain": "corp.nexus.local", "manufacturer": "HP",
|
||||
"model": "EliteBook 840 G10", "serialNumber": "SN-BOB-02",
|
||||
"custom1": "Asset Tag: NEXUS-0102", "custom2": None, "custom3": None, "custom4": None,
|
||||
},
|
||||
{
|
||||
"assetId": "ls-003", "assetName": "LAPTOP-CAROL-03", "assetType": "Windows",
|
||||
"ipAddress": "10.0.1.103", "mac": "AA:BB:CC:DD:EE:03",
|
||||
"lastSeen": _D(2), "lastLoggedOnUser": "CORP\\carolc",
|
||||
"operatingSystem": "Windows 10 Pro 22H2",
|
||||
"domain": "corp.nexus.local", "manufacturer": "Lenovo",
|
||||
"model": "ThinkPad X1 Carbon", "serialNumber": "SN-CAROL-03A",
|
||||
# ⚡ DRIFT: Lansweeper has SN-CAROL-03A but Intune has SN-CAROL-03B
|
||||
"custom1": "Asset Tag: NEXUS-0103", "custom2": None, "custom3": None, "custom4": None,
|
||||
},
|
||||
{
|
||||
"assetId": "ls-004", "assetName": "TABLET-DAVID-04", "assetType": "Apple iOS",
|
||||
"ipAddress": "10.0.1.104", "mac": "AA:BB:CC:DD:EE:04",
|
||||
"lastSeen": _D(45), "lastLoggedOnUser": "davidk",
|
||||
"operatingSystem": "iPadOS 17.4",
|
||||
"domain": None, "manufacturer": "Apple",
|
||||
"model": "iPad Pro 12.9 M2", "serialNumber": "SN-DAVID-04",
|
||||
"custom1": "Asset Tag: NEXUS-0104", "custom2": None, "custom3": None, "custom4": None,
|
||||
},
|
||||
{
|
||||
"assetId": "ls-005", "assetName": "SERVER-PROD-01", "assetType": "Linux",
|
||||
"ipAddress": "10.0.2.10", "mac": "AA:BB:CC:DD:EE:05",
|
||||
"lastSeen": _D(0), "lastLoggedOnUser": "root",
|
||||
"operatingSystem": "Ubuntu 22.04.4 LTS",
|
||||
"domain": None, "manufacturer": "Dell",
|
||||
"model": "PowerEdge R750", "serialNumber": "SN-PROD-01",
|
||||
"custom1": "Asset Tag: NEXUS-SRV-001", "custom2": "Rack: DC-A Row-3", "custom3": None, "custom4": None,
|
||||
},
|
||||
{
|
||||
"assetId": "ls-006", "assetName": "SW-CORE-01", "assetType": "Network Device",
|
||||
"ipAddress": "10.0.0.1", "mac": "AA:BB:CC:DD:FF:01",
|
||||
"lastSeen": _D(0), "lastLoggedOnUser": None,
|
||||
"operatingSystem": "Cisco IOS 17.9.4",
|
||||
"domain": None, "manufacturer": "Cisco",
|
||||
"model": "Catalyst 9300", "serialNumber": "SN-CISCO-01",
|
||||
"custom1": "Asset Tag: NEXUS-NET-001", "custom2": "Location: DataCenter-A", "custom3": None, "custom4": None,
|
||||
},
|
||||
]
|
||||
|
||||
LANSWEEPER_ASSETS_BY_NAME = {a["assetName"].lower(): a for a in LANSWEEPER_ASSETS}
|
||||
LANSWEEPER_ASSETS_BY_ID = {a["assetId"]: a for a in LANSWEEPER_ASSETS}
|
||||
|
||||
LANSWEEPER_SOFTWARE: dict[str, list[dict]] = {
|
||||
"ls-001": [
|
||||
{"name": "Microsoft 365 Apps", "version": "16.0.17628", "publisher": "Microsoft", "installDate": _DATE(30)},
|
||||
{"name": "Google Chrome", "version": "124.0.6367", "publisher": "Google LLC", "installDate": _DATE(7)},
|
||||
{"name": "CrowdStrike Falcon Sensor", "version": "7.14.17002", "publisher": "CrowdStrike", "installDate": _DATE(60)},
|
||||
{"name": "Zoom", "version": "6.0.11", "publisher": "Zoom Video Communications", "installDate": _DATE(14)},
|
||||
{"name": "Bitdefender GravityZone", "version": "7.9.9.324", "publisher": "Bitdefender", "installDate": _DATE(90)},
|
||||
],
|
||||
"ls-002": [
|
||||
{"name": "Microsoft 365 Apps", "version": "16.0.17628", "publisher": "Microsoft", "installDate": _DATE(30)},
|
||||
{"name": "Google Chrome", "version": "123.0.6312", "publisher": "Google LLC", "installDate": _DATE(45)},
|
||||
{"name": "CrowdStrike Falcon Sensor", "version": "7.14.17002", "publisher": "CrowdStrike", "installDate": _DATE(60)},
|
||||
{"name": "Wireshark", "version": "4.2.4", "publisher": "Wireshark Foundation", "installDate": _DATE(20)},
|
||||
# ⚠️ Note: No BitLocker visible (encryption not enforced — triggers INC0001234)
|
||||
],
|
||||
"ls-003": [
|
||||
{"name": "Microsoft 365 Apps", "version": "16.0.17531", "publisher": "Microsoft", "installDate": _DATE(90)},
|
||||
{"name": "Microsoft Edge", "version": "124.0.2478", "publisher": "Microsoft", "installDate": _DATE(7)},
|
||||
{"name": "Miro", "version": "0.8.35", "publisher": "Miro", "installDate": _DATE(30)},
|
||||
{"name": "CrowdStrike Falcon Sensor", "version": "7.13.16901", "publisher": "CrowdStrike", "installDate": _DATE(180)},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─── Microsoft Intune ─────────────────────────────────────────────────────────
|
||||
|
||||
INTUNE_DEVICES: list[dict] = [
|
||||
{
|
||||
"id": "intune-001", "deviceName": "LAPTOP-ALICE-01",
|
||||
"operatingSystem": "Windows", "osVersion": "10.0.22631.3593",
|
||||
"complianceState": "compliant", "managementState": "managed",
|
||||
"enrolledDateTime": "2023-01-10T08:00:00Z", "lastSyncDateTime": _D(0),
|
||||
"deviceType": "laptop", "userPrincipalName": "alice.johnson@nexus.corp",
|
||||
"manufacturer": "Dell", "model": "Latitude 5540", "serialNumber": "SN-ALICE-01",
|
||||
"isEncrypted": True, "azureADRegistered": True, "azureADDeviceId": "aad-001",
|
||||
},
|
||||
{
|
||||
"id": "intune-002", "deviceName": "LAPTOP-BOB-02",
|
||||
"operatingSystem": "Windows", "osVersion": "10.0.22631.3593",
|
||||
# ⚡ Non-compliant: BitLocker not enabled
|
||||
"complianceState": "noncompliant", "managementState": "managed",
|
||||
"enrolledDateTime": "2022-08-20T08:00:00Z", "lastSyncDateTime": _D(1),
|
||||
"deviceType": "laptop", "userPrincipalName": "bob.martinez@nexus.corp",
|
||||
"manufacturer": "HP", "model": "EliteBook 840 G10", "serialNumber": "SN-BOB-02",
|
||||
"isEncrypted": False, "azureADRegistered": True, "azureADDeviceId": "aad-002",
|
||||
},
|
||||
{
|
||||
"id": "intune-003", "deviceName": "LAPTOP-CAROL-03",
|
||||
"operatingSystem": "Windows", "osVersion": "10.0.19045.4412",
|
||||
"complianceState": "compliant", "managementState": "managed",
|
||||
"enrolledDateTime": "2021-03-15T08:00:00Z", "lastSyncDateTime": _D(2),
|
||||
"deviceType": "laptop", "userPrincipalName": "carol.chen@nexus.corp",
|
||||
"manufacturer": "Lenovo", "model": "ThinkPad X1 Carbon",
|
||||
# ⚡ DRIFT: Intune serial is SN-CAROL-03B, Lansweeper/Helix has SN-CAROL-03A
|
||||
"serialNumber": "SN-CAROL-03B",
|
||||
"isEncrypted": True, "azureADRegistered": True, "azureADDeviceId": "aad-003",
|
||||
},
|
||||
{
|
||||
"id": "intune-004", "deviceName": "TABLET-DAVID-04",
|
||||
"operatingSystem": "iOS", "osVersion": "17.4.1",
|
||||
# Non-compliant: device not checked in > 30 days
|
||||
"complianceState": "noncompliant", "managementState": "managed",
|
||||
"enrolledDateTime": "2022-01-05T08:00:00Z", "lastSyncDateTime": _D(45),
|
||||
"deviceType": "tablet", "userPrincipalName": "david.kim@nexus.corp",
|
||||
"manufacturer": "Apple", "model": "iPad Pro 12.9 M2", "serialNumber": "SN-DAVID-04",
|
||||
"isEncrypted": True, "azureADRegistered": True, "azureADDeviceId": "aad-004",
|
||||
},
|
||||
{
|
||||
"id": "intune-005", "deviceName": "LAPTOP-HENRY-05",
|
||||
"operatingSystem": "Windows", "osVersion": "10.0.22631.3593",
|
||||
"complianceState": "compliant", "managementState": "managed",
|
||||
"enrolledDateTime": _D(5), "lastSyncDateTime": _D(0),
|
||||
"deviceType": "laptop", "userPrincipalName": "henry.park@nexus.corp",
|
||||
"manufacturer": "Dell", "model": "Latitude 5540", "serialNumber": "SN-HENRY-05",
|
||||
"isEncrypted": True, "azureADRegistered": True, "azureADDeviceId": "aad-005",
|
||||
},
|
||||
]
|
||||
|
||||
INTUNE_DEVICES_BY_NAME = {d["deviceName"].lower(): d for d in INTUNE_DEVICES}
|
||||
INTUNE_DEVICES_BY_ID = {d["id"]: d for d in INTUNE_DEVICES}
|
||||
|
||||
INTUNE_COMPLIANCE_POLICIES: list[dict] = [
|
||||
{
|
||||
"id": "cp-001", "displayName": "Windows 11 Baseline",
|
||||
"description": "Requires BitLocker, Defender, and up-to-date OS",
|
||||
"platformType": "windows10AndLater", "createdDateTime": "2023-01-01T00:00:00Z",
|
||||
"settingCount": 12,
|
||||
},
|
||||
{
|
||||
"id": "cp-002", "displayName": "iOS Device Policy",
|
||||
"description": "Passcode and check-in requirements for iOS",
|
||||
"platformType": "ios", "createdDateTime": "2022-06-01T00:00:00Z",
|
||||
"settingCount": 6,
|
||||
},
|
||||
]
|
||||
|
||||
INTUNE_CONFIGURATION_PROFILES: list[dict] = [
|
||||
{"id": "prof-001", "displayName": "Windows Security Baseline", "description": "CIS L1 controls", "platformType": "windows10AndLater", "createdDateTime": "2023-01-01T00:00:00Z"},
|
||||
{"id": "prof-002", "displayName": "BitLocker Enforcement", "description": "Enforce full-disk encryption", "platformType": "windows10AndLater", "createdDateTime": "2023-03-01T00:00:00Z"},
|
||||
{"id": "prof-003", "displayName": "Wi-Fi — Corp SSID", "description": "Automatic corporate Wi-Fi configuration", "platformType": "windows10AndLater", "createdDateTime": "2022-06-01T00:00:00Z"},
|
||||
]
|
||||
|
||||
INTUNE_APPS: list[dict] = [
|
||||
{"id": "app-001", "displayName": "Microsoft 365 Apps for Business", "publisher": "Microsoft", "appType": "windowsMicrosoftEdgeApp", "isFeatured": True, "createdDateTime": "2022-01-01T00:00:00Z"},
|
||||
{"id": "app-002", "displayName": "CrowdStrike Falcon Sensor", "publisher": "CrowdStrike", "appType": "win32LobApp", "isFeatured": False, "createdDateTime": "2022-06-01T00:00:00Z"},
|
||||
{"id": "app-003", "displayName": "Zoom Workplace", "publisher": "Zoom Video Communications", "appType": "win32LobApp", "isFeatured": False, "createdDateTime": "2023-02-01T00:00:00Z"},
|
||||
{"id": "app-004", "displayName": "Google Chrome", "publisher": "Google LLC", "appType": "win32LobApp", "isFeatured": False, "createdDateTime": "2022-06-01T00:00:00Z"},
|
||||
]
|
||||
|
||||
INTUNE_AUTOPILOT_DEVICES: list[dict] = [
|
||||
{"id": "ap-001", "serialNumber": "SN-ALICE-01", "model": "Latitude 5540", "manufacturer": "Dell", "groupTag": "Engineering", "enrollmentState": "enrolled"},
|
||||
{"id": "ap-002", "serialNumber": "SN-BOB-02", "model": "EliteBook 840 G10", "manufacturer": "HP", "groupTag": "Engineering", "enrollmentState": "enrolled"},
|
||||
{"id": "ap-003", "serialNumber": "SN-CAROL-03B", "model": "ThinkPad X1 Carbon", "manufacturer": "Lenovo", "groupTag": "Product", "enrollmentState": "enrolled"},
|
||||
{"id": "ap-005", "serialNumber": "SN-HENRY-05", "model": "Latitude 5540", "manufacturer": "Dell", "groupTag": "Engineering", "enrollmentState": "enrolled"},
|
||||
]
|
||||
|
||||
|
||||
# ─── FedEx ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _scan_event(status: str, desc: str, city: str, state: str, days_ago: float) -> dict:
|
||||
dt = (_NOW - datetime.timedelta(days=days_ago)).strftime(_FMT)
|
||||
return {
|
||||
"date": dt, "eventType": status, "eventDescription": desc,
|
||||
"scanLocation": {"city": city, "stateOrProvinceCode": state, "countryCode": "US"},
|
||||
"derivedStatus": desc,
|
||||
}
|
||||
|
||||
|
||||
FEDEX_TRACKING: dict[str, dict] = {
|
||||
"776126785997": {
|
||||
"trackingNumber": "776126785997",
|
||||
"trackResults": [{
|
||||
"trackingNumberInfo": {"trackingNumber": "776126785997", "trackingNumberUniqueId": "12013~776126785997~FX"},
|
||||
"additionalTrackingInfo": {"packageIdentifiers": [{"type": "PURCHASE_ORDER", "value": "PO-LAPTOP-001"}]},
|
||||
"shipperInformation": {"address": {"city": "Austin", "stateOrProvinceCode": "TX", "countryCode": "US"}},
|
||||
"recipientInformation": {"address": {"city": "New York", "stateOrProvinceCode": "NY", "countryCode": "US"}},
|
||||
"latestStatusDetail": {"code": "DL", "derivedCode": "DL", "statusByLocale": "Delivered", "description": "Delivered", "scanLocation": {"city": "New York", "stateOrProvinceCode": "NY", "countryCode": "US"}},
|
||||
"dateAndTimes": [{"type": "SHIP", "dateTime": _D(5)}, {"type": "ACTUAL_DELIVERY", "dateTime": _D(2)}],
|
||||
"packageDetails": {"physicalPackagingType": "PACKAGE", "sequenceNumber": "1/1", "weightAndDimensions": {"weight": [{"unit": "LB", "value": "4.5"}]}},
|
||||
"shipmentDetails": {"possessionStatus": "OD", "weight": [{"unit": "LB", "value": "4.5"}]},
|
||||
"scanEvents": [
|
||||
_scan_event("DL", "Delivered — Front Door/Porch", "New York", "NY", 2),
|
||||
_scan_event("OD", "On FedEx vehicle for delivery", "New York", "NY", 2.2),
|
||||
_scan_event("AR", "Arrived at FedEx facility", "New York", "NY", 2.5),
|
||||
_scan_event("IT", "In transit", "Philadelphia", "PA", 3),
|
||||
_scan_event("PU", "Picked up", "Austin", "TX", 5),
|
||||
],
|
||||
}],
|
||||
},
|
||||
"776126785998": {
|
||||
"trackingNumber": "776126785998",
|
||||
"trackResults": [{
|
||||
"trackingNumberInfo": {"trackingNumber": "776126785998", "trackingNumberUniqueId": "12013~776126785998~FX"},
|
||||
"additionalTrackingInfo": {"packageIdentifiers": [{"type": "PURCHASE_ORDER", "value": "PO-MONITOR-002"}]},
|
||||
"shipperInformation": {"address": {"city": "Seattle", "stateOrProvinceCode": "WA", "countryCode": "US"}},
|
||||
"recipientInformation": {"address": {"city": "New York", "stateOrProvinceCode": "NY", "countryCode": "US"}},
|
||||
"latestStatusDetail": {"code": "IT", "derivedCode": "IT", "statusByLocale": "In Transit", "description": "In transit", "scanLocation": {"city": "Chicago", "stateOrProvinceCode": "IL", "countryCode": "US"}},
|
||||
"dateAndTimes": [{"type": "SHIP", "dateTime": _D(2)}, {"type": "ESTIMATED_DELIVERY", "dateTime": _D(-1)}],
|
||||
"packageDetails": {"physicalPackagingType": "PACKAGE", "sequenceNumber": "1/1", "weightAndDimensions": {"weight": [{"unit": "LB", "value": "12.0"}]}},
|
||||
"scanEvents": [
|
||||
_scan_event("IT", "In transit", "Chicago", "IL", 0.5),
|
||||
_scan_event("AR", "Arrived at FedEx facility", "Chicago", "IL", 0.8),
|
||||
_scan_event("IT", "In transit", "Denver", "CO", 1),
|
||||
_scan_event("PU", "Picked up", "Seattle", "WA", 2),
|
||||
],
|
||||
}],
|
||||
},
|
||||
"776126785999": {
|
||||
"trackingNumber": "776126785999",
|
||||
"trackResults": [{
|
||||
"trackingNumberInfo": {"trackingNumber": "776126785999", "trackingNumberUniqueId": "12013~776126785999~FX"},
|
||||
"additionalTrackingInfo": {"packageIdentifiers": [{"type": "PURCHASE_ORDER", "value": "PO-KEYBOARD-003"}]},
|
||||
"shipperInformation": {"address": {"city": "Memphis", "stateOrProvinceCode": "TN", "countryCode": "US"}},
|
||||
"recipientInformation": {"address": {"city": "New York", "stateOrProvinceCode": "NY", "countryCode": "US"}},
|
||||
"latestStatusDetail": {"code": "OD", "derivedCode": "OD", "statusByLocale": "Out for Delivery", "description": "On FedEx vehicle for delivery", "scanLocation": {"city": "New York", "stateOrProvinceCode": "NY", "countryCode": "US"}},
|
||||
"dateAndTimes": [{"type": "SHIP", "dateTime": _D(1)}, {"type": "ESTIMATED_DELIVERY", "dateTime": _D(0)}],
|
||||
"packageDetails": {"physicalPackagingType": "ENVELOPE", "sequenceNumber": "1/1", "weightAndDimensions": {"weight": [{"unit": "LB", "value": "0.8"}]}},
|
||||
"scanEvents": [
|
||||
_scan_event("OD", "On FedEx vehicle for delivery", "New York", "NY", 0.1),
|
||||
_scan_event("AR", "At local FedEx facility", "New York", "NY", 0.3),
|
||||
_scan_event("IT", "In transit", "Newark", "NJ", 0.6),
|
||||
_scan_event("PU", "Picked up", "Memphis", "TN", 1),
|
||||
],
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
FEDEX_RATES_SAMPLE: list[dict] = [
|
||||
{"serviceType": "FEDEX_OVERNIGHT", "serviceName": "FedEx First Overnight®", "currency": "USD", "totalNetCharge": 89.50, "estimatedDeliveryTime": "Next business day by 8:00 AM"},
|
||||
{"serviceType": "PRIORITY_OVERNIGHT", "serviceName": "FedEx Priority Overnight®", "currency": "USD", "totalNetCharge": 62.75, "estimatedDeliveryTime": "Next business day by 10:30 AM"},
|
||||
{"serviceType": "STANDARD_OVERNIGHT", "serviceName": "FedEx Standard Overnight®", "currency": "USD", "totalNetCharge": 48.20, "estimatedDeliveryTime": "Next business day by 3:00 PM"},
|
||||
{"serviceType": "FEDEX_2_DAY", "serviceName": "FedEx 2Day®", "currency": "USD", "totalNetCharge": 32.10, "estimatedDeliveryTime": "2 business days"},
|
||||
{"serviceType": "FEDEX_GROUND", "serviceName": "FedEx Ground®", "currency": "USD", "totalNetCharge": 12.85, "estimatedDeliveryTime": "3-5 business days"},
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Workday REST API adapter (lib layer)."""
|
||||
|
||||
from typing import Any
|
||||
import httpx
|
||||
from config import WorkdayConfig
|
||||
|
||||
|
||||
class WorkdayClient:
|
||||
"""Low-level Workday REST client.
|
||||
|
||||
Handles OAuth2 refresh-token flow and base URL composition.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.cfg = WorkdayConfig()
|
||||
self._token: str | None = None
|
||||
self._http = httpx.AsyncClient(timeout=30)
|
||||
|
||||
async def get_token(self) -> str:
|
||||
if self._token:
|
||||
return self._token
|
||||
token_url = f"{self.cfg.base_url}/oauth2/{self.cfg.tenant}/token"
|
||||
resp = await self._http.post(
|
||||
token_url,
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": self.cfg.refresh_token,
|
||||
"client_id": self.cfg.client_id,
|
||||
"client_secret": self.cfg.client_secret,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
self._token = resp.json()["access_token"]
|
||||
return self._token
|
||||
|
||||
async def get(self, path: str, params: dict | None = None) -> Any:
|
||||
token = await self.get_token()
|
||||
url = f"{self.cfg.base_url}/{self.cfg.tenant}{path}"
|
||||
resp = await self._http.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
params=params,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def raas(self, report_path: str, params: dict | None = None) -> list[dict]:
|
||||
token = await self.get_token()
|
||||
url = (
|
||||
f"https://services1.myworkday.com/ccx/service/customreport2/"
|
||||
f"{self.cfg.tenant}/{report_path}"
|
||||
)
|
||||
resp = await self._http.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
params={"format": "json", **(params or {})},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("Report_Entry", [])
|
||||
|
||||
async def close(self):
|
||||
await self._http.aclose()
|
||||
@@ -0,0 +1,31 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "nexus-mcp"
|
||||
version = "0.1.0"
|
||||
description = "Nexus MCP — sharded enterprise integration server"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"mcp>=1.2.0",
|
||||
"httpx>=0.27.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"pydantic>=2.0.0",
|
||||
"ldap3>=2.9.1",
|
||||
"msal>=1.28.0",
|
||||
"schedule>=1.2.0",
|
||||
"jinja2>=3.1.0",
|
||||
"tabulate>=0.9.0",
|
||||
"python-dateutil>=2.9.0",
|
||||
"aiofiles>=24.1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
nexus-mcp = "main:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-dir]
|
||||
"" = "src"
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Nexus-MCP — Core Orchestrator (src/main.py).
|
||||
|
||||
This is the only file that decides WHAT loads. Each shard is self-contained.
|
||||
To enable a shard: set its ENABLE_* flag to "true" in .env or the environment.
|
||||
To put a shard in holding pattern: set it to "false" or omit the variable.
|
||||
|
||||
Shard status legend used in WIS tracking:
|
||||
🟢 Green — Production-ready, enabled by default
|
||||
🟡 Yellow — In progress / partially ready
|
||||
🔴 Red — Planned, not yet registered (keep import commented out)
|
||||
|
||||
SOC 2 audit middleware (CC7.2 / CC6.1) is applied automatically after all
|
||||
shards load — every registered tool is wrapped with a structured JSONL logger.
|
||||
No shard code needs to know about it.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
# Make lib/ importable from shards and main alike
|
||||
_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.join(_root, "lib"))
|
||||
sys.path.insert(0, os.path.join(_root, "src"))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(os.path.join(_root, ".env"))
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from shards import identity, workday, itsm, assets, logistics, audit
|
||||
|
||||
# ── Build the server ──────────────────────────────────────────────────────────
|
||||
|
||||
mcp = FastMCP(
|
||||
name="Nexus",
|
||||
instructions=(
|
||||
"Nexus is the enterprise integration MCP. You have access to identity "
|
||||
"(AD + Entra), workforce (Workday), ITSM (BMC Helix), asset inventory "
|
||||
"(Lansweeper + Intune), logistics (FedEx), and cross-system audit tools. "
|
||||
"Use audit_* tools to detect field drift. Use generate_* tools for weekly reports."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _enabled(flag: str, default: str = "true") -> bool:
|
||||
"""Return True if the ENABLE_<flag> environment variable is truthy."""
|
||||
return os.getenv(f"ENABLE_{flag}", default).strip().lower() == "true"
|
||||
|
||||
|
||||
# ── Shard loading ─────────────────────────────────────────────────────────────
|
||||
# Each block is independent. Comment out a block to put that shard in "holding
|
||||
# pattern" without touching any other file.
|
||||
|
||||
# 🟢 Identity — Active Directory + Entra ID (WIS-017)
|
||||
if _enabled("IDENTITY"):
|
||||
identity.register(mcp)
|
||||
print("[nexus] ✅ identity shard loaded")
|
||||
else:
|
||||
print("[nexus] ⏸ identity shard disabled (ENABLE_IDENTITY != true)")
|
||||
|
||||
# 🟡 Workday — Worker & Org tools (WIS-009)
|
||||
if _enabled("WORKDAY"):
|
||||
workday.register(mcp)
|
||||
print("[nexus] ✅ workday shard loaded")
|
||||
else:
|
||||
print("[nexus] ⏸ workday shard disabled (ENABLE_WORKDAY != true)")
|
||||
|
||||
# 🔴 ITSM — BMC Helix (Planned)
|
||||
if _enabled("ITSM"):
|
||||
itsm.register(mcp)
|
||||
print("[nexus] ✅ itsm shard loaded")
|
||||
else:
|
||||
print("[nexus] ⏸ itsm shard disabled (ENABLE_ITSM != true)")
|
||||
|
||||
# 🔴 Assets — Lansweeper + Intune (Planned)
|
||||
if _enabled("ASSETS"):
|
||||
assets.register(mcp)
|
||||
print("[nexus] ✅ assets shard loaded")
|
||||
else:
|
||||
print("[nexus] ⏸ assets shard disabled (ENABLE_ASSETS != true)")
|
||||
|
||||
# 🔴 Logistics — FedEx (Planned — credentials pending)
|
||||
if _enabled("LOGISTICS"):
|
||||
logistics.register(mcp)
|
||||
print("[nexus] ✅ logistics shard loaded")
|
||||
else:
|
||||
print("[nexus] ⏸ logistics shard disabled (ENABLE_LOGISTICS != true)")
|
||||
|
||||
# 🟡 Audit — Cross-system drift + reporting
|
||||
if _enabled("AUDIT"):
|
||||
audit.register(mcp)
|
||||
print("[nexus] ✅ audit shard loaded")
|
||||
else:
|
||||
print("[nexus] ⏸ audit shard disabled (ENABLE_AUDIT != true)")
|
||||
|
||||
|
||||
# ── SOC 2 Audit Middleware (CC7.2 / CC6.1) ───────────────────────────────────
|
||||
# Applied AFTER all shards register so every tool — regardless of which shard
|
||||
# it came from — is wrapped in one place. Shards are completely unaware of it.
|
||||
#
|
||||
# Each wrapper:
|
||||
# 1. Generates a UUID v4 event_id for correlation
|
||||
# 2. Calls the original tool function (async or sync)
|
||||
# 3. Records status, latency_ms, and (on failure) sanitised error details
|
||||
# 4. Appends a JSONL entry to AUDIT_LOG_FILE (default: logs/nexus_audit.jsonl)
|
||||
# 5. Optionally mirrors the entry to stderr for SIEM / syslog forwarding
|
||||
#
|
||||
# To disable audit logging: set AUDIT_LOGGING_ENABLED=false in .env
|
||||
# To silence stderr output: set AUDIT_LOG_STDERR=false in .env
|
||||
|
||||
from config import AuditConfig
|
||||
from audit_log import AuditLogger
|
||||
|
||||
_audit_cfg = AuditConfig()
|
||||
|
||||
if _audit_cfg.enabled:
|
||||
_auditor = AuditLogger.get()
|
||||
|
||||
def _make_audited_wrapper(orig_fn, tool_name: str):
|
||||
"""Return an async wrapper that logs every invocation of *orig_fn*."""
|
||||
@functools.wraps(orig_fn)
|
||||
async def _audited(**kwargs):
|
||||
event_id = str(uuid.uuid4())
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(orig_fn):
|
||||
result = await orig_fn(**kwargs)
|
||||
else:
|
||||
result = orig_fn(**kwargs)
|
||||
latency_ms = int((time.monotonic() - t0) * 1000)
|
||||
_auditor.record_success(event_id, tool_name, kwargs, latency_ms)
|
||||
return result
|
||||
except Exception as exc:
|
||||
latency_ms = int((time.monotonic() - t0) * 1000)
|
||||
_auditor.record_error(event_id, tool_name, kwargs, latency_ms, exc)
|
||||
raise
|
||||
return _audited
|
||||
|
||||
_wrapped = 0
|
||||
for _tool_name, _tool_obj in mcp._tool_manager._tools.items():
|
||||
_tool_obj.fn = _make_audited_wrapper(_tool_obj.fn, _tool_name)
|
||||
_wrapped += 1
|
||||
|
||||
print(f"[nexus] 🔒 SOC 2 audit middleware active — {_wrapped} tools wrapped"
|
||||
f" → {_audit_cfg.log_file}")
|
||||
else:
|
||||
print("[nexus] ⚠️ SOC 2 audit middleware DISABLED (AUDIT_LOGGING_ENABLED=false)")
|
||||
|
||||
|
||||
# ── Built-in audit query tools ────────────────────────────────────────────────
|
||||
# Two additional MCP tools that let an authorised Claude session query the
|
||||
# audit log directly — useful for compliance reviews and spot-checks.
|
||||
# These are always registered (they do not require a shard flag).
|
||||
|
||||
from audit_log import tail_audit_log, audit_log_stats as _log_stats_fn
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nexus_audit_recent(n: int = 50) -> list[dict]:
|
||||
"""Return the last *n* entries from the Nexus-MCP SOC 2 audit log.
|
||||
|
||||
Each entry contains: event_id, timestamp, tool, shard, action_category,
|
||||
args_summary (redacted), mock_mode, status, latency_ms, error details.
|
||||
Default n=50; max recommended 500.
|
||||
"""
|
||||
from config import AuditConfig
|
||||
return tail_audit_log(n=min(n, 500), log_file=AuditConfig().log_file)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nexus_audit_stats() -> dict:
|
||||
"""Return summary statistics over the entire Nexus-MCP audit log.
|
||||
|
||||
Includes: total call count, status breakdown (success/error), shard
|
||||
breakdown, action_category breakdown, top-10 tools by call volume,
|
||||
error count, recent errors, and mock-mode call count.
|
||||
"""
|
||||
from config import AuditConfig
|
||||
return _log_stats_fn(log_file=AuditConfig().log_file)
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
mcp.run(transport="stdio")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
"""Shard registration helpers."""
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Assets Shard — Lansweeper IT inventory + Microsoft Intune device management.
|
||||
|
||||
Status: 🔴 Red (Planned)
|
||||
Mock: Set USE_MOCK=true to use built-in sample data (no credentials needed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "lib"))
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
import mock_data as M
|
||||
|
||||
_USE_MOCK = os.getenv("USE_MOCK", "false").lower() == "true"
|
||||
|
||||
_ls = None
|
||||
_intune = None
|
||||
|
||||
|
||||
def _get_ls():
|
||||
global _ls
|
||||
if _ls is None:
|
||||
from lansweeper_client import LansweeperClient
|
||||
_ls = LansweeperClient()
|
||||
return _ls
|
||||
|
||||
|
||||
def _get_intune():
|
||||
global _intune
|
||||
if _intune is None:
|
||||
from intune_client import IntuneClient
|
||||
_intune = IntuneClient()
|
||||
return _intune
|
||||
|
||||
|
||||
def _site_id() -> str:
|
||||
from config import LansweeperConfig
|
||||
return LansweeperConfig().site_id
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
"""Register all Assets shard tools onto the MCP server."""
|
||||
|
||||
# ── Lansweeper ─────────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
async def lansweeper_list_assets(limit: int = 100, asset_type: str | None = None) -> list[dict]:
|
||||
"""List assets from Lansweeper.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of assets to return.
|
||||
asset_type: Optional type filter e.g. 'Windows', 'Linux', 'Network Device'.
|
||||
"""
|
||||
if _USE_MOCK:
|
||||
results = M.LANSWEEPER_ASSETS
|
||||
if asset_type:
|
||||
results = [a for a in results if a["assetType"].lower() == asset_type.lower()]
|
||||
return results[:limit]
|
||||
filter_part = f'assetType: "{asset_type}"' if asset_type else ""
|
||||
query = f"""
|
||||
query GetAssets($siteId: String!, $limit: Int!) {{
|
||||
site(id: $siteId) {{
|
||||
assetResources(
|
||||
pagination: {{ limit: $limit, page: 1 }}
|
||||
{filter_part}
|
||||
) {{
|
||||
items {{
|
||||
assetId assetName assetType ipAddress mac lastSeen
|
||||
lastLoggedOnUser operatingSystem domain manufacturer model serialNumber
|
||||
custom1 custom2 custom3 custom4
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
data = await _get_ls().gql(query, {"siteId": _site_id(), "limit": limit})
|
||||
return data["site"]["assetResources"]["items"]
|
||||
|
||||
@mcp.tool()
|
||||
async def lansweeper_get_asset(asset_id: str) -> dict | None:
|
||||
"""Retrieve full details for a single Lansweeper asset by its ID."""
|
||||
if _USE_MOCK:
|
||||
return M.LANSWEEPER_ASSETS_BY_ID.get(asset_id)
|
||||
query = """
|
||||
query GetAsset($siteId: String!, $assetId: String!) {
|
||||
site(id: $siteId) {
|
||||
asset(assetId: $assetId) {
|
||||
assetId assetName assetType ipAddress mac lastSeen
|
||||
lastLoggedOnUser operatingSystem domain manufacturer model serialNumber
|
||||
custom1 custom2 custom3 custom4 warrantyDate purchaseDate
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
data = await _get_ls().gql(query, {"siteId": _site_id(), "assetId": asset_id})
|
||||
return data["site"]["asset"]
|
||||
|
||||
@mcp.tool()
|
||||
async def lansweeper_get_software(asset_id: str) -> list[dict]:
|
||||
"""List all installed software on a given Lansweeper asset."""
|
||||
if _USE_MOCK:
|
||||
return M.LANSWEEPER_SOFTWARE.get(asset_id, [])
|
||||
query = """
|
||||
query GetSoftware($siteId: String!, $assetId: String!) {
|
||||
site(id: $siteId) {
|
||||
asset(assetId: $assetId) {
|
||||
software { name version publisher installDate }
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
data = await _get_ls().gql(query, {"siteId": _site_id(), "assetId": asset_id})
|
||||
return data["site"]["asset"]["software"]
|
||||
|
||||
@mcp.tool()
|
||||
async def lansweeper_search_assets(query: str, limit: int = 50) -> list[dict]:
|
||||
"""Search Lansweeper assets by name, IP address, or serial number fragment."""
|
||||
if _USE_MOCK:
|
||||
q = query.lower()
|
||||
return [a for a in M.LANSWEEPER_ASSETS if q in a["assetName"].lower()][:limit]
|
||||
gql_query = """
|
||||
query SearchAssets($siteId: String!, $query: String!, $limit: Int!) {
|
||||
site(id: $siteId) {
|
||||
assetResources(
|
||||
pagination: { limit: $limit, page: 1 }
|
||||
assetBasicFilters: { assetName: $query }
|
||||
) {
|
||||
items {
|
||||
assetId assetName assetType ipAddress mac lastSeen
|
||||
lastLoggedOnUser operatingSystem domain
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
data = await _get_ls().gql(gql_query, {
|
||||
"siteId": _site_id(),
|
||||
"query": query,
|
||||
"limit": limit,
|
||||
})
|
||||
return data["site"]["assetResources"]["items"]
|
||||
|
||||
# ── Intune ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
async def intune_list_managed_devices(limit: int = 100) -> list[dict]:
|
||||
"""List all managed devices enrolled in Microsoft Intune."""
|
||||
if _USE_MOCK:
|
||||
return M.INTUNE_DEVICES[:limit]
|
||||
fields = (
|
||||
"id,deviceName,operatingSystem,osVersion,complianceState,"
|
||||
"managementState,enrolledDateTime,lastSyncDateTime,deviceType,"
|
||||
"userPrincipalName,manufacturer,model,serialNumber,"
|
||||
"isEncrypted,azureADRegistered,azureADDeviceId"
|
||||
)
|
||||
data = await _get_intune().get(
|
||||
"/deviceManagement/managedDevices",
|
||||
params={"$select": fields, "$top": min(limit, 1000)},
|
||||
)
|
||||
return data.get("value", [])
|
||||
|
||||
@mcp.tool()
|
||||
async def intune_get_managed_device(device_id: str) -> dict | None:
|
||||
"""Retrieve full details for a single Intune managed device by its device ID or name."""
|
||||
if _USE_MOCK:
|
||||
return (
|
||||
M.INTUNE_DEVICES_BY_ID.get(device_id)
|
||||
or M.INTUNE_DEVICES_BY_NAME.get(device_id.lower())
|
||||
)
|
||||
return await _get_intune().get(f"/deviceManagement/managedDevices/{device_id}")
|
||||
|
||||
@mcp.tool()
|
||||
async def intune_get_noncompliant_devices() -> list[dict]:
|
||||
"""Return all Intune devices currently in a non-compliant state."""
|
||||
if _USE_MOCK:
|
||||
return [d for d in M.INTUNE_DEVICES if d["complianceState"] == "noncompliant"]
|
||||
data = await _get_intune().get(
|
||||
"/deviceManagement/managedDevices",
|
||||
params={"$filter": "complianceState eq 'noncompliant'"},
|
||||
)
|
||||
return data.get("value", [])
|
||||
|
||||
@mcp.tool()
|
||||
async def intune_list_compliance_policies() -> list[dict]:
|
||||
"""List device compliance policies configured in Intune."""
|
||||
if _USE_MOCK:
|
||||
return M.INTUNE_COMPLIANCE_POLICIES
|
||||
data = await _get_intune().get("/deviceManagement/deviceCompliancePolicies")
|
||||
return data.get("value", [])
|
||||
|
||||
@mcp.tool()
|
||||
async def intune_list_configuration_profiles() -> list[dict]:
|
||||
"""List device configuration profiles in Intune."""
|
||||
if _USE_MOCK:
|
||||
return M.INTUNE_CONFIGURATION_PROFILES
|
||||
data = await _get_intune().get("/deviceManagement/deviceConfigurations")
|
||||
return data.get("value", [])
|
||||
|
||||
@mcp.tool()
|
||||
async def intune_list_apps(limit: int = 100) -> list[dict]:
|
||||
"""List managed applications deployed via Intune."""
|
||||
if _USE_MOCK:
|
||||
return M.INTUNE_APPS[:limit]
|
||||
data = await _get_intune().get(
|
||||
"/deviceManagement/deviceAppManagement/mobileApps",
|
||||
params={"$top": min(limit, 1000)},
|
||||
)
|
||||
return data.get("value", [])
|
||||
|
||||
@mcp.tool()
|
||||
async def intune_get_autopilot_devices() -> list[dict]:
|
||||
"""List Windows Autopilot device registrations in Intune."""
|
||||
if _USE_MOCK:
|
||||
return M.INTUNE_AUTOPILOT_DEVICES
|
||||
data = await _get_intune().get(
|
||||
"/deviceManagement/windowsAutopilotDeviceIdentities"
|
||||
)
|
||||
return data.get("value", [])
|
||||
@@ -0,0 +1,902 @@
|
||||
"""Audit Shard — cross-system content drift detection and weekly reporting.
|
||||
|
||||
Status: 🟡 Yellow
|
||||
Mock: Set USE_MOCK=true to use built-in sample data (no credentials needed).
|
||||
All cross-system comparisons run entirely on mock_data.py — no live calls made.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "lib"))
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
import mock_data as M
|
||||
|
||||
_USE_MOCK = os.getenv("USE_MOCK", "false").lower() == "true"
|
||||
|
||||
# Lazy singletons shared within this shard only
|
||||
_ad = None
|
||||
_entra = None
|
||||
_wd = None
|
||||
_ls = None
|
||||
_helix = None
|
||||
_intune = None
|
||||
|
||||
|
||||
def _get_ad():
|
||||
global _ad
|
||||
if _ad is None:
|
||||
from ad_adapter import ActiveDirectoryAdapter
|
||||
_ad = ActiveDirectoryAdapter()
|
||||
return _ad
|
||||
|
||||
|
||||
def _get_entra():
|
||||
global _entra
|
||||
if _entra is None:
|
||||
from entra_client import EntraClient
|
||||
_entra = EntraClient()
|
||||
return _entra
|
||||
|
||||
|
||||
def _get_wd():
|
||||
global _wd
|
||||
if _wd is None:
|
||||
from workday_client import WorkdayClient
|
||||
_wd = WorkdayClient()
|
||||
return _wd
|
||||
|
||||
|
||||
def _get_ls():
|
||||
global _ls
|
||||
if _ls is None:
|
||||
from lansweeper_client import LansweeperClient
|
||||
_ls = LansweeperClient()
|
||||
return _ls
|
||||
|
||||
|
||||
def _get_helix():
|
||||
global _helix
|
||||
if _helix is None:
|
||||
from helix_client import HelixClient
|
||||
_helix = HelixClient()
|
||||
return _helix
|
||||
|
||||
|
||||
def _get_intune():
|
||||
global _intune
|
||||
if _intune is None:
|
||||
from intune_client import IntuneClient
|
||||
_intune = IntuneClient()
|
||||
return _intune
|
||||
|
||||
|
||||
# ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def _norm(val: Any) -> str | None:
|
||||
return str(val).strip().lower() if val is not None else None
|
||||
|
||||
|
||||
def _drift(sys_a: str, sys_b: str, field: str, val_a: Any, val_b: Any) -> dict | None:
|
||||
if _norm(val_a) != _norm(val_b):
|
||||
return {"field": field, "system_a": sys_a, "value_a": val_a,
|
||||
"system_b": sys_b, "value_b": val_b}
|
||||
return None
|
||||
|
||||
|
||||
def _pick(obj: dict | None, *keys: str) -> Any:
|
||||
for k in keys:
|
||||
if obj is None:
|
||||
return None
|
||||
obj = obj.get(k)
|
||||
return obj
|
||||
|
||||
|
||||
def _ts() -> str:
|
||||
return datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _week() -> str:
|
||||
t = datetime.date.today()
|
||||
return f"{t.year}-W{t.isocalendar()[1]:02d}"
|
||||
|
||||
|
||||
def _count_by(items: list[dict], key: str) -> dict[str, int]:
|
||||
out: dict[str, int] = {}
|
||||
for item in items:
|
||||
v = str(item.get(key) or "unknown")
|
||||
out[v] = out.get(v, 0) + 1
|
||||
return dict(sorted(out.items(), key=lambda x: -x[1]))
|
||||
|
||||
|
||||
def _save(report: dict, filename: str) -> str:
|
||||
from config import ReportConfig
|
||||
cfg = ReportConfig()
|
||||
cfg.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
p = cfg.output_dir / filename
|
||||
p.write_text(json.dumps(report, indent=2))
|
||||
return str(p)
|
||||
|
||||
|
||||
# ── Shard registration ────────────────────────────────────────────────────────
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
"""Register all Audit shard tools onto the MCP server."""
|
||||
|
||||
# ── Drift tools ───────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
async def audit_user_drift(email: str) -> dict:
|
||||
"""Audit a single user across Workday, Active Directory, and Entra ID for field drift.
|
||||
|
||||
Compares displayName, jobTitle, and department across all three systems.
|
||||
|
||||
Args:
|
||||
email: Primary work email of the user to audit.
|
||||
"""
|
||||
if _USE_MOCK:
|
||||
wd_worker = M.WORKDAY_WORKERS_BY_EMAIL.get(email.lower())
|
||||
ad_user = M.AD_USERS_BY_EMAIL.get(email.lower())
|
||||
entra_user = M.ENTRA_USERS_BY_MAIL.get(email.lower())
|
||||
diffs: list[dict] = []
|
||||
comparisons = [
|
||||
("displayName", "Workday", _pick(wd_worker, "descriptor"),
|
||||
"ActiveDirectory", _pick(ad_user, "displayName")),
|
||||
("displayName", "Workday", _pick(wd_worker, "descriptor"),
|
||||
"Entra", _pick(entra_user, "displayName")),
|
||||
("displayName", "ActiveDirectory", _pick(ad_user, "displayName"),
|
||||
"Entra", _pick(entra_user, "displayName")),
|
||||
("jobTitle", "Workday", _pick(wd_worker, "primaryJob", "jobProfile", "descriptor"),
|
||||
"ActiveDirectory", _pick(ad_user, "title")),
|
||||
("jobTitle", "Workday", _pick(wd_worker, "primaryJob", "jobProfile", "descriptor"),
|
||||
"Entra", _pick(entra_user, "jobTitle")),
|
||||
("department", "Workday", _pick(wd_worker, "primaryJob", "businessUnit", "descriptor"),
|
||||
"ActiveDirectory", _pick(ad_user, "department")),
|
||||
("department", "Workday", _pick(wd_worker, "primaryJob", "businessUnit", "descriptor"),
|
||||
"Entra", _pick(entra_user, "department")),
|
||||
]
|
||||
for field, sa, va, sb, vb in comparisons:
|
||||
d = _drift(sa, sb, field, va, vb)
|
||||
if d:
|
||||
diffs.append(d)
|
||||
return {
|
||||
"email": email,
|
||||
"systems_checked": ["Workday", "ActiveDirectory", "Entra"],
|
||||
"workday_found": wd_worker is not None,
|
||||
"ad_found": ad_user is not None,
|
||||
"entra_found": entra_user is not None,
|
||||
"discrepancy_count": len(diffs),
|
||||
"discrepancies": diffs,
|
||||
}
|
||||
wd_task = asyncio.create_task(_get_wd().get(
|
||||
"/staffing/v6/workers", params={"limit": 500}
|
||||
))
|
||||
entra_task = asyncio.create_task(_get_entra().get(
|
||||
"/users",
|
||||
params={
|
||||
"$select": "id,displayName,userPrincipalName,mail,jobTitle,department,accountEnabled",
|
||||
"$top": 999,
|
||||
},
|
||||
))
|
||||
wd_data, entra_data = await asyncio.gather(wd_task, entra_task)
|
||||
ad_user = await asyncio.to_thread(_get_ad().get_user_by_email, email)
|
||||
|
||||
wd_worker = next(
|
||||
(w for w in wd_data.get("data", [])
|
||||
if (w.get("primaryWorkEmail") or "").lower() == email.lower()),
|
||||
None,
|
||||
)
|
||||
entra_user = next(
|
||||
(u for u in entra_data.get("value", [])
|
||||
if _norm(u.get("mail")) == _norm(email)
|
||||
or _norm(u.get("userPrincipalName")) == _norm(email)),
|
||||
None,
|
||||
)
|
||||
|
||||
diffs: list[dict] = []
|
||||
comparisons = [
|
||||
("displayName",
|
||||
"Workday", _pick(wd_worker, "descriptor"),
|
||||
"ActiveDirectory", _pick(ad_user, "displayName")),
|
||||
("displayName",
|
||||
"Workday", _pick(wd_worker, "descriptor"),
|
||||
"Entra", _pick(entra_user, "displayName")),
|
||||
("displayName",
|
||||
"ActiveDirectory", _pick(ad_user, "displayName"),
|
||||
"Entra", _pick(entra_user, "displayName")),
|
||||
("jobTitle",
|
||||
"Workday", _pick(wd_worker, "primaryJob", "jobProfile", "descriptor"),
|
||||
"ActiveDirectory", _pick(ad_user, "title")),
|
||||
("jobTitle",
|
||||
"Workday", _pick(wd_worker, "primaryJob", "jobProfile", "descriptor"),
|
||||
"Entra", _pick(entra_user, "jobTitle")),
|
||||
("department",
|
||||
"Workday", _pick(wd_worker, "primaryJob", "businessUnit", "descriptor"),
|
||||
"ActiveDirectory", _pick(ad_user, "department")),
|
||||
("department",
|
||||
"Workday", _pick(wd_worker, "primaryJob", "businessUnit", "descriptor"),
|
||||
"Entra", _pick(entra_user, "department")),
|
||||
]
|
||||
for field, sa, va, sb, vb in comparisons:
|
||||
d = _drift(sa, sb, field, va, vb)
|
||||
if d:
|
||||
diffs.append(d)
|
||||
|
||||
return {
|
||||
"email": email,
|
||||
"systems_checked": ["Workday", "ActiveDirectory", "Entra"],
|
||||
"workday_found": wd_worker is not None,
|
||||
"ad_found": ad_user is not None,
|
||||
"entra_found": entra_user is not None,
|
||||
"discrepancy_count": len(diffs),
|
||||
"discrepancies": diffs,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def audit_bulk_user_drift(emails: list[str]) -> list[dict]:
|
||||
"""Run user drift audit for a list of email addresses concurrently (max 50).
|
||||
|
||||
Args:
|
||||
emails: List of work email addresses to audit.
|
||||
"""
|
||||
async def _one(email: str) -> dict:
|
||||
try:
|
||||
return await audit_user_drift(email)
|
||||
except Exception as e:
|
||||
return {"email": email, "error": str(e)}
|
||||
|
||||
return await asyncio.gather(*[_one(e) for e in emails[:50]])
|
||||
|
||||
@mcp.tool()
|
||||
async def audit_device_drift(device_name: str) -> dict:
|
||||
"""Audit a device across Lansweeper, Intune, and BMC Helix CMDB for field drift.
|
||||
|
||||
Compares manufacturer and serial number across all three asset systems.
|
||||
|
||||
Args:
|
||||
device_name: The computer/device name to look up.
|
||||
"""
|
||||
if _USE_MOCK:
|
||||
dn = device_name.lower()
|
||||
ls_asset = M.LANSWEEPER_ASSETS_BY_NAME.get(dn)
|
||||
intune_device = M.INTUNE_DEVICES_BY_NAME.get(dn)
|
||||
helix_ci = M.HELIX_CMDB_BY_NAME.get(device_name.upper()) or M.HELIX_CMDB_BY_NAME.get(device_name)
|
||||
diffs: list[dict] = []
|
||||
for field, ik, hk in [("manufacturer", "manufacturer", "Manufacturer"),
|
||||
("serialNumber", "serialNumber", "Serial Number")]:
|
||||
ls_val = _pick(ls_asset, field)
|
||||
intune_val = _pick(intune_device, ik)
|
||||
helix_val = _pick(helix_ci, "values", hk)
|
||||
for sa, sb, va, vb in [
|
||||
("Lansweeper", "Intune", ls_val, intune_val),
|
||||
("Lansweeper", "Helix", ls_val, helix_val),
|
||||
]:
|
||||
d = _drift(sa, sb, field, va, vb)
|
||||
if d:
|
||||
diffs.append(d)
|
||||
return {
|
||||
"device_name": device_name,
|
||||
"systems_checked": ["Lansweeper", "Intune", "HelixCMDB"],
|
||||
"lansweeper_found": ls_asset is not None,
|
||||
"intune_found": intune_device is not None,
|
||||
"helix_found": helix_ci is not None,
|
||||
"discrepancy_count": len(diffs),
|
||||
"discrepancies": diffs,
|
||||
}
|
||||
from config import LansweeperConfig
|
||||
site_id = LansweeperConfig().site_id
|
||||
ls_query = """
|
||||
query S($siteId: String!, $q: String!) {
|
||||
site(id: $siteId) {
|
||||
assetResources(
|
||||
pagination: { limit: 5, page: 1 }
|
||||
assetBasicFilters: { assetName: $q }
|
||||
) {
|
||||
items { assetId assetName operatingSystem manufacturer serialNumber }
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
ls_task = asyncio.create_task(
|
||||
_get_ls().gql(ls_query, {"siteId": site_id, "q": device_name})
|
||||
)
|
||||
intune_task = asyncio.create_task(
|
||||
_get_intune().get("/deviceManagement/managedDevices", params={"$top": 500})
|
||||
)
|
||||
helix_task = asyncio.create_task(
|
||||
_get_helix().get(
|
||||
"/api/arsys/v1/entry/BMC.CORE:BMC_ComputerSystem",
|
||||
params={"q": f"'Name' LIKE \"%{device_name}%\"", "limit": 5},
|
||||
)
|
||||
)
|
||||
ls_data, intune_data, helix_data = await asyncio.gather(ls_task, intune_task, helix_task)
|
||||
|
||||
ls_results = ls_data["site"]["assetResources"]["items"]
|
||||
ls_asset = ls_results[0] if ls_results else None
|
||||
intune_device = next(
|
||||
(d for d in intune_data.get("value", [])
|
||||
if _norm(d.get("deviceName")) == _norm(device_name)),
|
||||
None,
|
||||
)
|
||||
helix_entries = helix_data.get("entries", [])
|
||||
helix_ci = helix_entries[0] if helix_entries else None
|
||||
|
||||
diffs: list[dict] = []
|
||||
for field, lk, ik, hk in [
|
||||
("manufacturer", "manufacturer", "manufacturer", "Manufacturer"),
|
||||
("serialNumber", "serialNumber", "serialNumber", "Serial Number"),
|
||||
]:
|
||||
ls_val = _pick(ls_asset, field)
|
||||
intune_val = _pick(intune_device, ik)
|
||||
helix_val = _pick(helix_ci, "values", hk)
|
||||
for sa, sb, va, vb in [
|
||||
("Lansweeper", "Intune", ls_val, intune_val),
|
||||
("Lansweeper", "Helix", ls_val, helix_val),
|
||||
]:
|
||||
d = _drift(sa, sb, field, va, vb)
|
||||
if d:
|
||||
diffs.append(d)
|
||||
|
||||
return {
|
||||
"device_name": device_name,
|
||||
"systems_checked": ["Lansweeper", "Intune", "HelixCMDB"],
|
||||
"lansweeper_found": ls_asset is not None,
|
||||
"intune_found": intune_device is not None,
|
||||
"helix_found": helix_ci is not None,
|
||||
"discrepancy_count": len(diffs),
|
||||
"discrepancies": diffs,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def audit_entra_ad_sync_drift(limit: int = 200) -> dict:
|
||||
"""Compare Entra ID synced users against Active Directory for field drift.
|
||||
|
||||
Args:
|
||||
limit: Number of Entra users to evaluate (default 200).
|
||||
"""
|
||||
if _USE_MOCK:
|
||||
synced = [u for u in M.ENTRA_USERS if u.get("onPremisesSyncEnabled")][:limit]
|
||||
drifted = []
|
||||
for u in synced:
|
||||
upn = u.get("userPrincipalName", "")
|
||||
sam = upn.split("@")[0] if "@" in upn else upn
|
||||
ad_user = M.AD_USERS_BY_SAM.get(sam.lower())
|
||||
if not ad_user:
|
||||
drifted.append({"upn": upn, "issue": "Not found in AD", "discrepancies": []})
|
||||
continue
|
||||
diffs = []
|
||||
for field, ek, ak in [("displayName", "displayName", "displayName"),
|
||||
("jobTitle", "jobTitle", "title"),
|
||||
("department", "department", "department")]:
|
||||
d = _drift("Entra", "ActiveDirectory", field, u.get(ek), ad_user.get(ak))
|
||||
if d:
|
||||
diffs.append(d)
|
||||
if diffs:
|
||||
drifted.append({"upn": upn, "discrepancies": diffs})
|
||||
return {
|
||||
"users_evaluated": len(synced),
|
||||
"drifted_users_count": len(drifted),
|
||||
"drifted_users": drifted,
|
||||
}
|
||||
users = (await _get_entra().get(
|
||||
"/users",
|
||||
params={
|
||||
"$select": "id,displayName,userPrincipalName,mail,jobTitle,department,onPremisesSyncEnabled",
|
||||
"$top": min(limit, 999),
|
||||
},
|
||||
)).get("value", [])
|
||||
|
||||
drifted = []
|
||||
for u in users:
|
||||
if not u.get("onPremisesSyncEnabled"):
|
||||
continue
|
||||
upn = u.get("userPrincipalName", "")
|
||||
sam = upn.split("@")[0] if "@" in upn else upn
|
||||
ad_user = await asyncio.to_thread(_get_ad().get_user, sam)
|
||||
if not ad_user:
|
||||
drifted.append({"upn": upn, "issue": "Not found in AD", "discrepancies": []})
|
||||
continue
|
||||
diffs = []
|
||||
for field, ek, ak in [
|
||||
("displayName", "displayName", "displayName"),
|
||||
("jobTitle", "jobTitle", "title"),
|
||||
("department", "department", "department"),
|
||||
]:
|
||||
d = _drift("Entra", "ActiveDirectory", field, u.get(ek), ad_user.get(ak))
|
||||
if d:
|
||||
diffs.append(d)
|
||||
if diffs:
|
||||
drifted.append({"upn": upn, "discrepancies": diffs})
|
||||
|
||||
return {
|
||||
"users_evaluated": len(users),
|
||||
"drifted_users_count": len(drifted),
|
||||
"drifted_users": drifted,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def audit_intune_lansweeper_device_drift(limit: int = 100) -> dict:
|
||||
"""Compare Intune managed devices against Lansweeper for manufacturer/serial drift.
|
||||
|
||||
Args:
|
||||
limit: Number of Intune devices to evaluate (default 100).
|
||||
"""
|
||||
if _USE_MOCK:
|
||||
devices = M.INTUNE_DEVICES[:limit]
|
||||
ls_idx = {a["assetName"].lower(): a for a in M.LANSWEEPER_ASSETS}
|
||||
drifted, missing = [], []
|
||||
for device in devices:
|
||||
name = device.get("deviceName", "")
|
||||
ls_asset = ls_idx.get(name.lower())
|
||||
if not ls_asset:
|
||||
missing.append(name)
|
||||
continue
|
||||
diffs = []
|
||||
for field, ik, lk in [("manufacturer", "manufacturer", "manufacturer"),
|
||||
("serialNumber", "serialNumber", "serialNumber")]:
|
||||
d = _drift("Intune", "Lansweeper", field, device.get(ik), ls_asset.get(lk))
|
||||
if d:
|
||||
diffs.append(d)
|
||||
if diffs:
|
||||
drifted.append({"device": name, "discrepancies": diffs})
|
||||
return {
|
||||
"intune_devices_evaluated": len(devices),
|
||||
"drifted_count": len(drifted),
|
||||
"missing_in_lansweeper_count": len(missing),
|
||||
"missing_in_lansweeper": missing,
|
||||
"drifted_devices": drifted,
|
||||
}
|
||||
intune_devices = (await _get_intune().get(
|
||||
"/deviceManagement/managedDevices", params={"$top": min(limit, 1000)}
|
||||
)).get("value", [])
|
||||
|
||||
from config import LansweeperConfig
|
||||
site_id = LansweeperConfig().site_id
|
||||
drifted, missing = [], []
|
||||
|
||||
for device in intune_devices:
|
||||
name = device.get("deviceName", "")
|
||||
q = """
|
||||
query S($siteId: String!, $q: String!) {
|
||||
site(id: $siteId) {
|
||||
assetResources(
|
||||
pagination: { limit: 3, page: 1 }
|
||||
assetBasicFilters: { assetName: $q }
|
||||
) { items { assetName manufacturer serialNumber } }
|
||||
}
|
||||
}
|
||||
"""
|
||||
ls_data = await _get_ls().gql(q, {"siteId": site_id, "q": name})
|
||||
ls_items = ls_data["site"]["assetResources"]["items"]
|
||||
ls_asset = next(
|
||||
(a for a in ls_items if _norm(a.get("assetName")) == _norm(name)), None
|
||||
)
|
||||
if not ls_asset:
|
||||
missing.append(name)
|
||||
continue
|
||||
diffs = []
|
||||
for field, ik, lk in [
|
||||
("manufacturer", "manufacturer", "manufacturer"),
|
||||
("serialNumber", "serialNumber", "serialNumber"),
|
||||
]:
|
||||
d = _drift("Intune", "Lansweeper", field, device.get(ik), ls_asset.get(lk))
|
||||
if d:
|
||||
diffs.append(d)
|
||||
if diffs:
|
||||
drifted.append({"device": name, "discrepancies": diffs})
|
||||
|
||||
return {
|
||||
"intune_devices_evaluated": len(intune_devices),
|
||||
"drifted_count": len(drifted),
|
||||
"missing_in_lansweeper_count": len(missing),
|
||||
"missing_in_lansweeper": missing,
|
||||
"drifted_devices": drifted,
|
||||
}
|
||||
|
||||
# ── Reporting tools ────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
async def generate_weekly_report(save_to_file: bool = True) -> dict:
|
||||
"""Generate a comprehensive weekly operational report across all enterprise systems.
|
||||
|
||||
Covers Workday headcount, Entra account health, Intune compliance,
|
||||
Lansweeper inventory, and Helix ITSM ticket volumes.
|
||||
|
||||
Args:
|
||||
save_to_file: Save the report as JSON to the configured output directory.
|
||||
"""
|
||||
if _USE_MOCK:
|
||||
one_week_ago = (datetime.datetime.utcnow() - datetime.timedelta(days=7)).strftime("%Y-%m-%d")
|
||||
|
||||
def _hf(e: dict, k: str) -> str:
|
||||
return (e.get("values") or {}).get(k) or "unknown"
|
||||
|
||||
report = {
|
||||
"report_type": "weekly_operational_report",
|
||||
"week": _week(),
|
||||
"generated_at": _ts(),
|
||||
"mock": True,
|
||||
"workday": {
|
||||
"total_workers": len(M.WORKDAY_WORKERS),
|
||||
"new_hires_this_week": len([w for w in M.WORKDAY_WORKERS if (w.get("hireDate") or "") >= one_week_ago]),
|
||||
},
|
||||
"entra_id": {
|
||||
"total_users": len(M.ENTRA_USERS),
|
||||
"enabled": len([u for u in M.ENTRA_USERS if u.get("accountEnabled")]),
|
||||
"disabled": len([u for u in M.ENTRA_USERS if not u.get("accountEnabled")]),
|
||||
"on_prem_synced": len([u for u in M.ENTRA_USERS if u.get("onPremisesSyncEnabled")]),
|
||||
"licensed": len([u for u in M.ENTRA_USERS if u.get("assignedLicenses")]),
|
||||
},
|
||||
"intune": {
|
||||
"total_managed_devices": len(M.INTUNE_DEVICES),
|
||||
"compliance_breakdown": _count_by(M.INTUNE_DEVICES, "complianceState"),
|
||||
"os_breakdown": _count_by(M.INTUNE_DEVICES, "operatingSystem"),
|
||||
"noncompliant_devices": [d.get("deviceName") for d in M.INTUNE_DEVICES if d.get("complianceState") == "noncompliant"],
|
||||
},
|
||||
"lansweeper": {
|
||||
"total_assets": len(M.LANSWEEPER_ASSETS),
|
||||
"assets_by_type": _count_by(M.LANSWEEPER_ASSETS, "assetType"),
|
||||
"assets_by_os": _count_by(M.LANSWEEPER_ASSETS, "operatingSystem"),
|
||||
},
|
||||
"helix_itsm": {
|
||||
"open_incidents": len([i for i in M.HELIX_INCIDENTS if _hf(i, "Status") != "Resolved"]),
|
||||
"incident_by_status": _count_by([{"s": _hf(i, "Status")} for i in M.HELIX_INCIDENTS], "s"),
|
||||
"open_changes": len([c for c in M.HELIX_CHANGES if _hf(c, "Status") not in ("Completed", "Cancelled")]),
|
||||
"change_by_status": _count_by([{"s": _hf(c, "Status")} for c in M.HELIX_CHANGES], "s"),
|
||||
},
|
||||
}
|
||||
if save_to_file:
|
||||
report["saved_to"] = _save(report, f"weekly_report_{_week()}.json")
|
||||
return report
|
||||
|
||||
wd = _get_wd()
|
||||
entra = _get_entra()
|
||||
intune = _get_intune()
|
||||
ls = _get_ls()
|
||||
helix = _get_helix()
|
||||
from config import LansweeperConfig
|
||||
site_id = LansweeperConfig().site_id
|
||||
|
||||
ls_gql = """
|
||||
query($siteId: String!) {
|
||||
site(id: $siteId) {
|
||||
assetResources(pagination: { limit: 500, page: 1 }) {
|
||||
items { assetType operatingSystem }
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
(workers_resp, entra_users, intune_devices, ls_data, incidents, changes) = (
|
||||
await asyncio.gather(
|
||||
wd.get("/staffing/v6/workers", params={"limit": 500}),
|
||||
entra.get("/users", params={"$select": "id,accountEnabled,onPremisesSyncEnabled,assignedLicenses", "$top": 999}),
|
||||
intune.get("/deviceManagement/managedDevices", params={"$top": 1000}),
|
||||
ls.gql(ls_gql, {"siteId": site_id}),
|
||||
helix.get("/api/arsys/v1/entry/HPD:Help%20Desk", params={"q": "('Status' != \"Closed\")", "limit": 200}),
|
||||
helix.get("/api/arsys/v1/entry/CHG:ChangeInterface_Create", params={"q": "'Status' != \"Closed\"", "limit": 200}),
|
||||
)
|
||||
)
|
||||
|
||||
workers = workers_resp.get("data", [])
|
||||
entra_list = entra_users.get("value", [])
|
||||
intune_list = intune_devices.get("value", [])
|
||||
ls_assets = ls_data["site"]["assetResources"]["items"]
|
||||
incident_list = incidents.get("entries", [])
|
||||
change_list = changes.get("entries", [])
|
||||
|
||||
one_week_ago = (datetime.datetime.utcnow() - datetime.timedelta(days=7)).strftime("%Y-%m-%d")
|
||||
|
||||
def _hfield(e: dict, k: str) -> str:
|
||||
return (e.get("values") or {}).get(k) or "unknown"
|
||||
|
||||
report = {
|
||||
"report_type": "weekly_operational_report",
|
||||
"week": _week(),
|
||||
"generated_at": _ts(),
|
||||
"workday": {
|
||||
"total_workers": len(workers),
|
||||
"new_hires_this_week": len([w for w in workers if (w.get("hireDate") or "") >= one_week_ago]),
|
||||
},
|
||||
"entra_id": {
|
||||
"total_users": len(entra_list),
|
||||
"enabled": len([u for u in entra_list if u.get("accountEnabled")]),
|
||||
"disabled": len([u for u in entra_list if not u.get("accountEnabled")]),
|
||||
"on_prem_synced": len([u for u in entra_list if u.get("onPremisesSyncEnabled")]),
|
||||
"licensed": len([u for u in entra_list if u.get("assignedLicenses")]),
|
||||
},
|
||||
"intune": {
|
||||
"total_managed_devices": len(intune_list),
|
||||
"compliance_breakdown": _count_by(intune_list, "complianceState"),
|
||||
"os_breakdown": _count_by(intune_list, "operatingSystem"),
|
||||
"noncompliant_devices": [
|
||||
d.get("deviceName") for d in intune_list if d.get("complianceState") == "noncompliant"
|
||||
][:50],
|
||||
},
|
||||
"lansweeper": {
|
||||
"total_assets": len(ls_assets),
|
||||
"assets_by_type": _count_by(ls_assets, "assetType"),
|
||||
"assets_by_os": _count_by(ls_assets, "operatingSystem"),
|
||||
},
|
||||
"helix_itsm": {
|
||||
"open_incidents": len(incident_list),
|
||||
"incident_by_status": _count_by(
|
||||
[{"s": _hfield(i, "Status")} for i in incident_list], "s"
|
||||
),
|
||||
"open_changes": len(change_list),
|
||||
"change_by_status": _count_by(
|
||||
[{"s": _hfield(c, "Status")} for c in change_list], "s"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
if save_to_file:
|
||||
report["saved_to"] = _save(report, f"weekly_report_{_week()}.json")
|
||||
|
||||
return report
|
||||
|
||||
@mcp.tool()
|
||||
async def generate_compliance_report(save_to_file: bool = True) -> dict:
|
||||
"""Generate a device compliance and identity risk report.
|
||||
|
||||
Covers Intune non-compliant devices and Entra ID risky users.
|
||||
|
||||
Args:
|
||||
save_to_file: Save the report as JSON to the configured output directory.
|
||||
"""
|
||||
if _USE_MOCK:
|
||||
nc = [d for d in M.INTUNE_DEVICES if d["complianceState"] == "noncompliant"]
|
||||
report = {
|
||||
"report_type": "compliance_report",
|
||||
"generated_at": _ts(),
|
||||
"mock": True,
|
||||
"intune": {
|
||||
"noncompliant_device_count": len(nc),
|
||||
"noncompliant_devices": [
|
||||
{"name": d["deviceName"], "user": d.get("userPrincipalName"),
|
||||
"os": d["operatingSystem"], "last_sync": d["lastSyncDateTime"],
|
||||
"isEncrypted": d.get("isEncrypted")}
|
||||
for d in nc
|
||||
],
|
||||
},
|
||||
"entra_identity_protection": {
|
||||
"risky_user_count": len(M.ENTRA_RISKY_USERS),
|
||||
"risky_users": [
|
||||
{"id": u["id"], "risk_level": u["riskLevel"],
|
||||
"risk_state": u["riskState"], "upn": u.get("userPrincipalName"),
|
||||
"risk_last_updated": u["riskLastUpdatedDateTime"]}
|
||||
for u in M.ENTRA_RISKY_USERS
|
||||
],
|
||||
},
|
||||
"entra_summary": {
|
||||
"total_users": len(M.ENTRA_USERS),
|
||||
"disabled": len([u for u in M.ENTRA_USERS if not u.get("accountEnabled")]),
|
||||
"licensed": len([u for u in M.ENTRA_USERS if u.get("assignedLicenses")]),
|
||||
},
|
||||
"ad_disabled_accounts": [u["sAMAccountName"] for u in M.AD_USERS if u.get("userAccountControl") == "514"],
|
||||
}
|
||||
if save_to_file:
|
||||
report["saved_to"] = _save(report, f"compliance_report_{_ts()[:10]}.json")
|
||||
return report
|
||||
|
||||
noncompliant, risky, all_users = await asyncio.gather(
|
||||
_get_intune().get("/deviceManagement/managedDevices", params={"$filter": "complianceState eq 'noncompliant'"}),
|
||||
_get_entra().get("/identityProtection/riskyUsers"),
|
||||
_get_entra().get("/users", params={"$select": "id,accountEnabled,assignedLicenses", "$top": 999}),
|
||||
)
|
||||
|
||||
report = {
|
||||
"report_type": "compliance_report",
|
||||
"generated_at": _ts(),
|
||||
"intune": {
|
||||
"noncompliant_device_count": len(noncompliant.get("value", [])),
|
||||
"noncompliant_devices": [
|
||||
{"name": d.get("deviceName"), "user": d.get("userPrincipalName"),
|
||||
"os": d.get("operatingSystem"), "last_sync": d.get("lastSyncDateTime")}
|
||||
for d in noncompliant.get("value", [])
|
||||
],
|
||||
},
|
||||
"entra_identity_protection": {
|
||||
"risky_user_count": len(risky.get("value", [])),
|
||||
"risky_users": [
|
||||
{"id": u.get("id"), "risk_level": u.get("riskLevel"),
|
||||
"risk_state": u.get("riskState"), "risk_last_updated": u.get("riskLastUpdatedDateTime")}
|
||||
for u in risky.get("value", [])
|
||||
],
|
||||
},
|
||||
"entra_summary": {
|
||||
"total_users": len(all_users.get("value", [])),
|
||||
"disabled": len([u for u in all_users.get("value", []) if not u.get("accountEnabled")]),
|
||||
"licensed": len([u for u in all_users.get("value", []) if u.get("assignedLicenses")]),
|
||||
},
|
||||
}
|
||||
|
||||
if save_to_file:
|
||||
ts_date = _ts()[:10]
|
||||
report["saved_to"] = _save(report, f"compliance_report_{ts_date}.json")
|
||||
|
||||
return report
|
||||
|
||||
@mcp.tool()
|
||||
async def generate_asset_reconciliation_report(save_to_file: bool = True) -> dict:
|
||||
"""Compare Intune and Lansweeper inventories — find gaps and field mismatches.
|
||||
|
||||
Args:
|
||||
save_to_file: Save the report as JSON to the configured output directory.
|
||||
"""
|
||||
if _USE_MOCK:
|
||||
intune_list = M.INTUNE_DEVICES
|
||||
ls_list = M.LANSWEEPER_ASSETS
|
||||
intune_idx = {(_norm(d.get("deviceName")) or ""): d for d in intune_list}
|
||||
ls_idx = {(_norm(a.get("assetName")) or ""): a for a in ls_list}
|
||||
only_intune = [n for n in intune_idx if n not in ls_idx]
|
||||
only_ls = [n for n in ls_idx if n not in intune_idx]
|
||||
in_both = [n for n in intune_idx if n in ls_idx]
|
||||
mismatches = []
|
||||
for name in in_both:
|
||||
i = intune_idx[name]
|
||||
l = ls_idx[name]
|
||||
diffs = []
|
||||
for field, ik, lk in [("manufacturer", "manufacturer", "manufacturer"),
|
||||
("serialNumber", "serialNumber", "serialNumber")]:
|
||||
iv, lv = _norm(i.get(ik)), _norm(l.get(lk))
|
||||
if iv and lv and iv != lv:
|
||||
diffs.append({"field": field, "intune": i.get(ik), "lansweeper": l.get(lk)})
|
||||
if diffs:
|
||||
mismatches.append({"device": name, "discrepancies": diffs})
|
||||
report = {
|
||||
"report_type": "asset_reconciliation_report",
|
||||
"generated_at": _ts(),
|
||||
"mock": True,
|
||||
"intune_device_count": len(intune_list),
|
||||
"lansweeper_asset_count": len(ls_list),
|
||||
"only_in_intune_count": len(only_intune),
|
||||
"only_in_lansweeper_count": len(only_ls),
|
||||
"in_both_count": len(in_both),
|
||||
"mismatch_count": len(mismatches),
|
||||
"only_in_intune": only_intune,
|
||||
"only_in_lansweeper": only_ls,
|
||||
"mismatches": mismatches,
|
||||
}
|
||||
if save_to_file:
|
||||
report["saved_to"] = _save(report, f"asset_reconciliation_{_ts()[:10]}.json")
|
||||
return report
|
||||
|
||||
from config import LansweeperConfig
|
||||
site_id = LansweeperConfig().site_id
|
||||
ls_gql = """
|
||||
query($siteId: String!) {
|
||||
site(id: $siteId) {
|
||||
assetResources(pagination: { limit: 500, page: 1 }) {
|
||||
items { assetName manufacturer serialNumber }
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
intune_resp, ls_data = await asyncio.gather(
|
||||
_get_intune().get("/deviceManagement/managedDevices", params={"$top": 1000}),
|
||||
_get_ls().gql(ls_gql, {"siteId": site_id}),
|
||||
)
|
||||
|
||||
intune_list = intune_resp.get("value", [])
|
||||
ls_list = ls_data["site"]["assetResources"]["items"]
|
||||
|
||||
intune_idx = {(_norm(d.get("deviceName")) or ""): d for d in intune_list}
|
||||
ls_idx = {(_norm(a.get("assetName")) or ""): a for a in ls_list}
|
||||
|
||||
only_intune = [n for n in intune_idx if n not in ls_idx]
|
||||
only_ls = [n for n in ls_idx if n not in intune_idx]
|
||||
in_both = [n for n in intune_idx if n in ls_idx]
|
||||
|
||||
mismatches = []
|
||||
for name in in_both:
|
||||
i = intune_idx[name]
|
||||
l = ls_idx[name]
|
||||
diffs = []
|
||||
for field, ik, lk in [("manufacturer", "manufacturer", "manufacturer"),
|
||||
("serialNumber", "serialNumber", "serialNumber")]:
|
||||
iv, lv = _norm(i.get(ik)), _norm(l.get(lk))
|
||||
if iv and lv and iv != lv:
|
||||
diffs.append({"field": field, "intune": i.get(ik), "lansweeper": l.get(lk)})
|
||||
if diffs:
|
||||
mismatches.append({"device": name, "discrepancies": diffs})
|
||||
|
||||
report = {
|
||||
"report_type": "asset_reconciliation_report",
|
||||
"generated_at": _ts(),
|
||||
"intune_device_count": len(intune_list),
|
||||
"lansweeper_asset_count": len(ls_list),
|
||||
"only_in_intune_count": len(only_intune),
|
||||
"only_in_lansweeper_count": len(only_ls),
|
||||
"in_both_count": len(in_both),
|
||||
"mismatch_count": len(mismatches),
|
||||
"only_in_intune": only_intune[:100],
|
||||
"only_in_lansweeper": only_ls[:100],
|
||||
"mismatches": mismatches,
|
||||
}
|
||||
|
||||
if save_to_file:
|
||||
ts_date = _ts()[:10]
|
||||
report["saved_to"] = _save(report, f"asset_reconciliation_{ts_date}.json")
|
||||
|
||||
return report
|
||||
|
||||
@mcp.tool()
|
||||
async def generate_itsm_weekly_summary(save_to_file: bool = True) -> dict:
|
||||
"""Generate a weekly ITSM summary from BMC Helix — incidents and changes.
|
||||
|
||||
Args:
|
||||
save_to_file: Save the report as JSON to the configured output directory.
|
||||
"""
|
||||
if _USE_MOCK:
|
||||
def _f(e: dict, k: str) -> str:
|
||||
return (e.get("values") or {}).get(k) or "unknown"
|
||||
|
||||
report = {
|
||||
"report_type": "itsm_weekly_summary",
|
||||
"week": _week(),
|
||||
"generated_at": _ts(),
|
||||
"mock": True,
|
||||
"incidents": {
|
||||
"total": len(M.HELIX_INCIDENTS),
|
||||
"by_status": _count_by([{"s": _f(i, "Status")} for i in M.HELIX_INCIDENTS], "s"),
|
||||
"by_priority": _count_by([{"p": _f(i, "Priority")} for i in M.HELIX_INCIDENTS], "p"),
|
||||
"highlights": [
|
||||
{"id": _f(i, "Incident Number"), "summary": _f(i, "Summary"),
|
||||
"priority": _f(i, "Priority"), "assignee": _f(i, "Assignee")}
|
||||
for i in M.HELIX_INCIDENTS if _f(i, "Status") not in ("Resolved", "Closed")
|
||||
],
|
||||
},
|
||||
"changes": {
|
||||
"total": len(M.HELIX_CHANGES),
|
||||
"by_status": _count_by([{"s": _f(c, "Status")} for c in M.HELIX_CHANGES], "s"),
|
||||
"by_type": _count_by([{"t": _f(c, "Change Type")} for c in M.HELIX_CHANGES], "t"),
|
||||
},
|
||||
"problems": {
|
||||
"total": len(M.HELIX_PROBLEMS),
|
||||
"open": [{"id": _f(p, "Problem Number"), "summary": _f(p, "Summary")} for p in M.HELIX_PROBLEMS],
|
||||
},
|
||||
}
|
||||
if save_to_file:
|
||||
report["saved_to"] = _save(report, f"itsm_summary_{_week()}.json")
|
||||
return report
|
||||
|
||||
helix = _get_helix()
|
||||
incidents, changes = await asyncio.gather(
|
||||
helix.get("/api/arsys/v1/entry/HPD:Help%20Desk", params={"q": "('Status' != \"Closed\")", "limit": 500}),
|
||||
helix.get("/api/arsys/v1/entry/CHG:ChangeInterface_Create", params={"q": "'Status' != \"Closed\"", "limit": 500}),
|
||||
)
|
||||
|
||||
def _f(e: dict, k: str) -> str:
|
||||
return (e.get("values") or {}).get(k) or "unknown"
|
||||
|
||||
i_list = incidents.get("entries", [])
|
||||
c_list = changes.get("entries", [])
|
||||
|
||||
report = {
|
||||
"report_type": "itsm_weekly_summary",
|
||||
"week": _week(),
|
||||
"generated_at": _ts(),
|
||||
"incidents": {
|
||||
"total": len(i_list),
|
||||
"by_status": _count_by([{"s": _f(i, "Status")} for i in i_list], "s"),
|
||||
"by_priority": _count_by([{"p": _f(i, "Priority")} for i in i_list], "p"),
|
||||
},
|
||||
"changes": {
|
||||
"total": len(c_list),
|
||||
"by_status": _count_by([{"s": _f(c, "Status")} for c in c_list], "s"),
|
||||
"by_type": _count_by([{"t": _f(c, "Change Type")} for c in c_list], "t"),
|
||||
},
|
||||
}
|
||||
|
||||
if save_to_file:
|
||||
report["saved_to"] = _save(report, f"itsm_summary_{_week()}.json")
|
||||
|
||||
return report
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Identity Shard — Active Directory + Microsoft Entra ID tools.
|
||||
|
||||
Status: 🟢 Green | WIS-017
|
||||
Mock: Set USE_MOCK=true to use built-in sample data (no credentials needed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "lib"))
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
import mock_data as M
|
||||
|
||||
_USE_MOCK = os.getenv("USE_MOCK", "false").lower() == "true"
|
||||
|
||||
_ad = None
|
||||
_entra = None
|
||||
|
||||
|
||||
def _get_ad():
|
||||
global _ad
|
||||
if _ad is None:
|
||||
from ad_adapter import ActiveDirectoryAdapter
|
||||
_ad = ActiveDirectoryAdapter()
|
||||
return _ad
|
||||
|
||||
|
||||
def _get_entra():
|
||||
global _entra
|
||||
if _entra is None:
|
||||
from entra_client import EntraClient
|
||||
_entra = EntraClient()
|
||||
return _entra
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
|
||||
# ── Active Directory ───────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
async def ad_get_user(sam_account_name: str) -> dict | None:
|
||||
"""Look up an Active Directory user by their sAMAccountName (login name)."""
|
||||
if _USE_MOCK:
|
||||
return M.AD_USERS_BY_SAM.get(sam_account_name.lower())
|
||||
return await asyncio.to_thread(_get_ad().get_user, sam_account_name)
|
||||
|
||||
@mcp.tool()
|
||||
async def ad_get_user_by_email(email: str) -> dict | None:
|
||||
"""Look up an Active Directory user by their email address."""
|
||||
if _USE_MOCK:
|
||||
return M.AD_USERS_BY_EMAIL.get(email.lower())
|
||||
return await asyncio.to_thread(_get_ad().get_user_by_email, email)
|
||||
|
||||
@mcp.tool()
|
||||
async def ad_search_users(query: str, limit: int = 50) -> list[dict]:
|
||||
"""Search Active Directory users by display name or sAMAccountName fragment."""
|
||||
if _USE_MOCK:
|
||||
q = query.lower()
|
||||
matches = [
|
||||
u for u in M.AD_USERS
|
||||
if q in u["displayName"].lower() or q in u["sAMAccountName"].lower()
|
||||
]
|
||||
return matches[:limit]
|
||||
return await asyncio.to_thread(_get_ad().search_users, query, limit)
|
||||
|
||||
@mcp.tool()
|
||||
async def ad_list_groups(limit: int = 200) -> list[dict]:
|
||||
"""List all security and distribution groups in Active Directory."""
|
||||
if _USE_MOCK:
|
||||
return M.AD_GROUPS[:limit]
|
||||
return await asyncio.to_thread(_get_ad().get_groups, limit)
|
||||
|
||||
@mcp.tool()
|
||||
async def ad_get_group_members(group_dn: str) -> list[dict]:
|
||||
"""Return all members of an Active Directory group by its distinguished name."""
|
||||
if _USE_MOCK:
|
||||
group_cn = group_dn.split(",")[0].replace("CN=", "").lower()
|
||||
return [
|
||||
{"dn": u["dn"], "cn": u["cn"], "sAMAccountName": u["sAMAccountName"],
|
||||
"mail": u["mail"], "title": u["title"]}
|
||||
for u in M.AD_USERS
|
||||
if group_cn in (u.get("memberOf") or "").lower()
|
||||
]
|
||||
return await asyncio.to_thread(_get_ad().get_group_members, group_dn)
|
||||
|
||||
@mcp.tool()
|
||||
async def ad_get_disabled_accounts() -> list[dict]:
|
||||
"""Return all disabled user accounts in Active Directory.
|
||||
|
||||
userAccountControl value 514 = normal account (512) + disabled (2).
|
||||
"""
|
||||
if _USE_MOCK:
|
||||
return [u for u in M.AD_USERS if u.get("userAccountControl") == "514"]
|
||||
return await asyncio.to_thread(_get_ad().get_disabled_accounts)
|
||||
|
||||
@mcp.tool()
|
||||
async def ad_get_stale_accounts(days_inactive: int = 90) -> list[dict]:
|
||||
"""Return Active Directory accounts with no login for the given number of days.
|
||||
|
||||
Args:
|
||||
days_inactive: Inactivity threshold in days (default 90).
|
||||
"""
|
||||
if _USE_MOCK:
|
||||
import datetime
|
||||
cutoff = (
|
||||
datetime.datetime.utcnow() - datetime.timedelta(days=days_inactive)
|
||||
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
return [
|
||||
u for u in M.AD_USERS
|
||||
if u.get("userAccountControl") != "514"
|
||||
and (u.get("lastLogonTimestamp") or "9999") < cutoff
|
||||
]
|
||||
return await asyncio.to_thread(_get_ad().get_stale_accounts, days_inactive)
|
||||
|
||||
# ── Microsoft Entra ID ────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
async def entra_list_users(limit: int = 100) -> list[dict]:
|
||||
"""List users in Microsoft Entra ID (Azure AD)."""
|
||||
if _USE_MOCK:
|
||||
return M.ENTRA_USERS[:limit]
|
||||
fields = (
|
||||
"id,displayName,userPrincipalName,mail,jobTitle,department,"
|
||||
"accountEnabled,createdDateTime,onPremisesSyncEnabled,assignedLicenses"
|
||||
)
|
||||
data = await _get_entra().get(
|
||||
"/users", params={"$select": fields, "$top": min(limit, 999)}
|
||||
)
|
||||
return data.get("value", [])
|
||||
|
||||
@mcp.tool()
|
||||
async def entra_get_user(user_id_or_upn: str) -> dict | None:
|
||||
"""Retrieve a single Entra ID user by object ID or UPN (user@company.com)."""
|
||||
if _USE_MOCK:
|
||||
return (
|
||||
M.ENTRA_USERS_BY_UPN.get(user_id_or_upn)
|
||||
or M.ENTRA_USERS_BY_MAIL.get(user_id_or_upn)
|
||||
or next((u for u in M.ENTRA_USERS if u["id"] == user_id_or_upn), None)
|
||||
)
|
||||
return await _get_entra().get(f"/users/{user_id_or_upn}")
|
||||
|
||||
@mcp.tool()
|
||||
async def entra_list_groups(limit: int = 100) -> list[dict]:
|
||||
"""List all groups in Microsoft Entra ID."""
|
||||
if _USE_MOCK:
|
||||
return M.ENTRA_GROUPS[:limit]
|
||||
data = await _get_entra().get("/groups", params={"$top": min(limit, 999)})
|
||||
return data.get("value", [])
|
||||
|
||||
@mcp.tool()
|
||||
async def entra_get_group_members(group_id: str) -> list[dict]:
|
||||
"""List members of an Entra ID group by its object ID."""
|
||||
if _USE_MOCK:
|
||||
group = next((g for g in M.ENTRA_GROUPS if g["id"] == group_id), None)
|
||||
if not group:
|
||||
return []
|
||||
gname = group["displayName"].lower()
|
||||
return [
|
||||
u for u in M.ENTRA_USERS
|
||||
if gname in (u.get("department") or "").lower()
|
||||
or gname in u.get("displayName", "").lower()
|
||||
]
|
||||
return await _get_entra().get_all_pages(f"/groups/{group_id}/members")
|
||||
|
||||
@mcp.tool()
|
||||
async def entra_list_service_principals(limit: int = 100) -> list[dict]:
|
||||
"""List service principals (app registrations / enterprise apps) in Entra ID."""
|
||||
if _USE_MOCK:
|
||||
return M.ENTRA_SERVICE_PRINCIPALS[:limit]
|
||||
data = await _get_entra().get(
|
||||
"/servicePrincipals", params={"$top": min(limit, 999)}
|
||||
)
|
||||
return data.get("value", [])
|
||||
|
||||
@mcp.tool()
|
||||
async def entra_get_conditional_access_policies() -> list[dict]:
|
||||
"""List all Conditional Access policies configured in Entra ID."""
|
||||
if _USE_MOCK:
|
||||
return M.ENTRA_CONDITIONAL_ACCESS_POLICIES
|
||||
data = await _get_entra().get("/identity/conditionalAccess/policies")
|
||||
return data.get("value", [])
|
||||
|
||||
@mcp.tool()
|
||||
async def entra_get_signin_logs(limit: int = 50) -> list[dict]:
|
||||
"""Retrieve recent sign-in log entries from Entra ID.
|
||||
|
||||
Requires AuditLog.Read.All Graph permission.
|
||||
"""
|
||||
if _USE_MOCK:
|
||||
return M.ENTRA_SIGNIN_LOGS[:limit]
|
||||
data = await _get_entra().get(
|
||||
"/auditLogs/signIns",
|
||||
params={"$top": limit, "$orderby": "createdDateTime desc"},
|
||||
)
|
||||
return data.get("value", [])
|
||||
|
||||
@mcp.tool()
|
||||
async def entra_get_risky_users() -> list[dict]:
|
||||
"""List users flagged as risky by Entra ID Identity Protection.
|
||||
|
||||
Requires IdentityRiskyUser.Read.All Graph permission.
|
||||
"""
|
||||
if _USE_MOCK:
|
||||
return M.ENTRA_RISKY_USERS
|
||||
data = await _get_entra().get("/identityProtection/riskyUsers")
|
||||
return data.get("value", [])
|
||||
@@ -0,0 +1,123 @@
|
||||
"""ITSM Shard — BMC Helix Incidents, Changes, Problems & CMDB.
|
||||
|
||||
Status: 🔴 Red (Planned)
|
||||
Mock: Set USE_MOCK=true to use built-in sample data (no credentials needed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "lib"))
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
import mock_data as M
|
||||
|
||||
_USE_MOCK = os.getenv("USE_MOCK", "false").lower() == "true"
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
def _get():
|
||||
global _client
|
||||
if _client is None:
|
||||
from helix_client import HelixClient
|
||||
_client = HelixClient()
|
||||
return _client
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
|
||||
@mcp.tool()
|
||||
async def helix_list_incidents(
|
||||
status: str | None = None,
|
||||
assignee: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[dict]:
|
||||
"""List incidents from BMC Helix ITSM.
|
||||
|
||||
Args:
|
||||
status: Filter by status e.g. 'Assigned', 'In Progress', 'Pending', 'Resolved'.
|
||||
assignee: Partial assignee name to filter on.
|
||||
limit: Maximum results to return.
|
||||
"""
|
||||
if _USE_MOCK:
|
||||
results = M.HELIX_INCIDENTS
|
||||
if status:
|
||||
results = [e for e in results if e["values"].get("Status", "").lower() == status.lower()]
|
||||
if assignee:
|
||||
results = [e for e in results if assignee.lower() in (e["values"].get("Assignee") or "").lower()]
|
||||
return results[:limit]
|
||||
parts = []
|
||||
if status:
|
||||
parts.append(f"'Status' = \"{status}\"")
|
||||
if assignee:
|
||||
parts.append(f"'Assignee' LIKE \"%{assignee}%\"")
|
||||
q = " AND ".join(parts) if parts else "('Status' != \"Closed\")"
|
||||
data = await _get().get(
|
||||
"/api/arsys/v1/entry/HPD:Help%20Desk",
|
||||
params={"q": q, "limit": limit},
|
||||
)
|
||||
return data.get("entries", [])
|
||||
|
||||
@mcp.tool()
|
||||
async def helix_get_incident(incident_id: str) -> dict | None:
|
||||
"""Retrieve full details for a single Helix incident by its Entry ID (e.g. INC0001234)."""
|
||||
if _USE_MOCK:
|
||||
return M.HELIX_INCIDENTS_BY_ID.get(incident_id)
|
||||
return await _get().get(f"/api/arsys/v1/entry/HPD:Help%20Desk/{incident_id}")
|
||||
|
||||
@mcp.tool()
|
||||
async def helix_list_changes(status: str | None = None, limit: int = 50) -> list[dict]:
|
||||
"""List change requests from BMC Helix.
|
||||
|
||||
Args:
|
||||
status: Optional status filter e.g. 'Draft', 'Scheduled', 'In Progress', 'Completed'.
|
||||
limit: Maximum results to return.
|
||||
"""
|
||||
if _USE_MOCK:
|
||||
results = M.HELIX_CHANGES
|
||||
if status:
|
||||
results = [e for e in results if e["values"].get("Status", "").lower() == status.lower()]
|
||||
return results[:limit]
|
||||
q = f"'Status' = \"{status}\"" if status else "'Status' != \"Closed\""
|
||||
data = await _get().get(
|
||||
"/api/arsys/v1/entry/CHG:ChangeInterface_Create",
|
||||
params={"q": q, "limit": limit},
|
||||
)
|
||||
return data.get("entries", [])
|
||||
|
||||
@mcp.tool()
|
||||
async def helix_get_problem(problem_id: str) -> dict | None:
|
||||
"""Retrieve a Helix problem investigation ticket by ID (e.g. PRB0000456)."""
|
||||
if _USE_MOCK:
|
||||
return next(
|
||||
(e for e in M.HELIX_PROBLEMS if e["values"].get("Problem Number") == problem_id),
|
||||
None,
|
||||
)
|
||||
return await _get().get(
|
||||
f"/api/arsys/v1/entry/PBM:Problem%20Investigation/{problem_id}"
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def helix_search_cmdb(ci_name: str) -> list[dict]:
|
||||
"""Search the BMC Helix CMDB for configuration items matching a name fragment."""
|
||||
if _USE_MOCK:
|
||||
q = ci_name.lower()
|
||||
return [e for e in M.HELIX_CMDB if q in e["values"]["Name"].lower()]
|
||||
data = await _get().get(
|
||||
"/api/arsys/v1/entry/BMC.CORE:BMC_ComputerSystem",
|
||||
params={"q": f"'Name' LIKE \"%{ci_name}%\"", "limit": 50},
|
||||
)
|
||||
return data.get("entries", [])
|
||||
|
||||
@mcp.tool()
|
||||
async def helix_list_cmdb_assets(limit: int = 100) -> list[dict]:
|
||||
"""List hardware assets from the BMC Helix CMDB."""
|
||||
if _USE_MOCK:
|
||||
return M.HELIX_CMDB[:limit]
|
||||
data = await _get().get(
|
||||
"/api/arsys/v1/entry/BMC.CORE:BMC_ComputerSystem",
|
||||
params={"limit": limit},
|
||||
)
|
||||
return data.get("entries", [])
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Logistics Shard — FedEx shipment tracking & shipping tools.
|
||||
|
||||
Status: 🔴 Red (Planned — credentials pending)
|
||||
Mock: Set USE_MOCK=true to use built-in sample data (no credentials needed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "lib"))
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
import mock_data as M
|
||||
|
||||
_USE_MOCK = os.getenv("USE_MOCK", "false").lower() == "true"
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
def _get():
|
||||
global _client
|
||||
if _client is None:
|
||||
from fedex_client import FedExClient
|
||||
_client = FedExClient()
|
||||
return _client
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
"""Register all Logistics shard tools onto the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def fedex_track_shipment(tracking_number: str) -> dict:
|
||||
"""Track a FedEx shipment by tracking number and return full tracking details."""
|
||||
if _USE_MOCK:
|
||||
record = M.FEDEX_TRACKING.get(tracking_number)
|
||||
if not record:
|
||||
return {"error": f"No mock tracking data for {tracking_number}",
|
||||
"available_numbers": list(M.FEDEX_TRACKING.keys())}
|
||||
results = record.get("trackResults", [])
|
||||
return {"trackingNumber": tracking_number, "trackResults": results}
|
||||
payload = {
|
||||
"includeDetailedScans": True,
|
||||
"trackingInfo": [
|
||||
{"trackingNumberInfo": {"trackingNumber": tracking_number}}
|
||||
],
|
||||
}
|
||||
data = await _get().post("/track/v1/trackingnumbers", payload)
|
||||
results = data.get("output", {}).get("completeTrackResults", [])
|
||||
return results[0] if results else {"error": "No tracking results found"}
|
||||
|
||||
@mcp.tool()
|
||||
async def fedex_track_multiple(tracking_numbers: list[str]) -> list[dict]:
|
||||
"""Track multiple FedEx shipments in one request (up to 30).
|
||||
|
||||
Args:
|
||||
tracking_numbers: List of FedEx tracking numbers to query.
|
||||
"""
|
||||
if _USE_MOCK:
|
||||
return [
|
||||
{"trackingNumber": tn, "trackResults": M.FEDEX_TRACKING[tn]["trackResults"]}
|
||||
if tn in M.FEDEX_TRACKING
|
||||
else {"trackingNumber": tn, "error": "Not found in mock data"}
|
||||
for tn in tracking_numbers[:30]
|
||||
]
|
||||
payload = {
|
||||
"includeDetailedScans": True,
|
||||
"trackingInfo": [
|
||||
{"trackingNumberInfo": {"trackingNumber": tn}}
|
||||
for tn in tracking_numbers[:30]
|
||||
],
|
||||
}
|
||||
data = await _get().post("/track/v1/trackingnumbers", payload)
|
||||
return data.get("output", {}).get("completeTrackResults", [])
|
||||
|
||||
@mcp.tool()
|
||||
async def fedex_get_shipment_events(tracking_number: str) -> list[dict]:
|
||||
"""Return the ordered list of scan events for a FedEx tracking number."""
|
||||
if _USE_MOCK:
|
||||
record = M.FEDEX_TRACKING.get(tracking_number)
|
||||
if not record:
|
||||
return []
|
||||
track_results = record.get("trackResults", [])
|
||||
return track_results[0].get("scanEvents", []) if track_results else []
|
||||
payload = {
|
||||
"includeDetailedScans": True,
|
||||
"trackingInfo": [
|
||||
{"trackingNumberInfo": {"trackingNumber": tracking_number}}
|
||||
],
|
||||
}
|
||||
data = await _get().post("/track/v1/trackingnumbers", payload)
|
||||
results = data.get("output", {}).get("completeTrackResults", [])
|
||||
if not results:
|
||||
return []
|
||||
track_results = results[0].get("trackResults", [])
|
||||
return track_results[0].get("scanEvents", []) if track_results else []
|
||||
|
||||
@mcp.tool()
|
||||
async def fedex_validate_address(
|
||||
street: str,
|
||||
city: str,
|
||||
state: str,
|
||||
postal: str,
|
||||
country: str = "US",
|
||||
) -> dict:
|
||||
"""Validate a shipping address with the FedEx Address Validation API."""
|
||||
if _USE_MOCK:
|
||||
return {
|
||||
"addressesToValidate": [{"address": {"streetLines": [street], "city": city,
|
||||
"stateOrProvinceCode": state, "postalCode": postal, "countryCode": country}}],
|
||||
"output": {"resolvedAddresses": [{"classification": "RESIDENTIAL",
|
||||
"attributes": {"PO_BOX": "false", "VALID_RESIDENTIAL": "true"},
|
||||
"streetLinesToken": [street], "city": city.upper(),
|
||||
"stateOrProvinceCode": state.upper(), "postalCode": postal,
|
||||
"countryCode": country}]},
|
||||
"mock": True,
|
||||
}
|
||||
payload = {
|
||||
"addressesToValidate": [
|
||||
{
|
||||
"address": {
|
||||
"streetLines": [street],
|
||||
"city": city,
|
||||
"stateOrProvinceCode": state,
|
||||
"postalCode": postal,
|
||||
"countryCode": country,
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
return await _get().post("/address/v1/addresses/resolve", payload)
|
||||
|
||||
@mcp.tool()
|
||||
async def fedex_get_rates(
|
||||
origin_postal: str,
|
||||
dest_postal: str,
|
||||
weight_lb: float,
|
||||
origin_country: str = "US",
|
||||
dest_country: str = "US",
|
||||
) -> list[dict]:
|
||||
"""Get available FedEx shipping rates between two postal codes."""
|
||||
if _USE_MOCK:
|
||||
return [
|
||||
{**rate, "requestedWeight": {"units": "LB", "value": weight_lb},
|
||||
"origin": origin_postal, "destination": dest_postal}
|
||||
for rate in M.FEDEX_RATES_SAMPLE
|
||||
]
|
||||
from config import FedExConfig
|
||||
account = FedExConfig().account_number
|
||||
payload = {
|
||||
"accountNumber": {"value": account},
|
||||
"requestedShipment": {
|
||||
"shipper": {"address": {"postalCode": origin_postal, "countryCode": origin_country}},
|
||||
"recipient": {"address": {"postalCode": dest_postal, "countryCode": dest_country}},
|
||||
"pickupType": "DROPOFF_AT_FEDEX_LOCATION",
|
||||
"requestedPackageLineItems": [
|
||||
{"weight": {"units": "LB", "value": weight_lb}}
|
||||
],
|
||||
},
|
||||
}
|
||||
data = await _get().post("/rate/v1/rates/quotes", payload)
|
||||
return data.get("output", {}).get("rateReplyDetails", [])
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Workday Shard — Worker, Organisation & Compensation tools.
|
||||
|
||||
Status: 🟡 Yellow | WIS-009
|
||||
Mock: Set USE_MOCK=true to use built-in sample data (no credentials needed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "lib"))
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
import mock_data as M
|
||||
|
||||
_USE_MOCK = os.getenv("USE_MOCK", "false").lower() == "true"
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
def _get():
|
||||
global _client
|
||||
if _client is None:
|
||||
from workday_client import WorkdayClient
|
||||
_client = WorkdayClient()
|
||||
return _client
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
|
||||
@mcp.tool()
|
||||
async def workday_list_workers(limit: int = 100, offset: int = 0) -> list[dict]:
|
||||
"""List workers from Workday HCM.
|
||||
|
||||
Args:
|
||||
limit: Page size.
|
||||
offset: Pagination offset.
|
||||
"""
|
||||
if _USE_MOCK:
|
||||
return M.WORKDAY_WORKERS[offset:offset + limit]
|
||||
data = await _get().get(
|
||||
"/staffing/v6/workers", params={"limit": limit, "offset": offset}
|
||||
)
|
||||
return data.get("data", [])
|
||||
|
||||
@mcp.tool()
|
||||
async def workday_get_worker(worker_id: str) -> dict | None:
|
||||
"""Retrieve full details for a single Workday worker by their Workday ID."""
|
||||
if _USE_MOCK:
|
||||
return next((w for w in M.WORKDAY_WORKERS if w["id"] == worker_id), None)
|
||||
return await _get().get(f"/staffing/v6/workers/{worker_id}")
|
||||
|
||||
@mcp.tool()
|
||||
async def workday_find_worker_by_email(email: str) -> dict | None:
|
||||
"""Find a Workday worker by their primary work email address."""
|
||||
if _USE_MOCK:
|
||||
return M.WORKDAY_WORKERS_BY_EMAIL.get(email.lower())
|
||||
data = await _get().get("/staffing/v6/workers", params={"limit": 500})
|
||||
for w in data.get("data", []):
|
||||
if (w.get("primaryWorkEmail") or "").lower() == email.lower():
|
||||
return w
|
||||
return None
|
||||
|
||||
@mcp.tool()
|
||||
async def workday_list_positions(limit: int = 100) -> list[dict]:
|
||||
"""List open and filled positions in Workday."""
|
||||
if _USE_MOCK:
|
||||
return M.WORKDAY_POSITIONS[:limit]
|
||||
data = await _get().get("/staffing/v6/positions", params={"limit": limit})
|
||||
return data.get("data", [])
|
||||
|
||||
@mcp.tool()
|
||||
async def workday_get_compensation(worker_id: str) -> dict | None:
|
||||
"""Retrieve compensation details (grade, salary band) for a Workday worker."""
|
||||
if _USE_MOCK:
|
||||
return M.WORKDAY_COMPENSATION.get(worker_id)
|
||||
return await _get().get(f"/compensation/v1/employees/{worker_id}")
|
||||
|
||||
@mcp.tool()
|
||||
async def workday_list_organizations(limit: int = 200) -> list[dict]:
|
||||
"""List supervisory organisations in Workday."""
|
||||
if _USE_MOCK:
|
||||
return M.WORKDAY_ORGANIZATIONS[:limit]
|
||||
data = await _get().get("/organization/v2/orgs", params={"limit": limit})
|
||||
return data.get("data", [])
|
||||
|
||||
@mcp.tool()
|
||||
async def workday_run_raas_report(report_path: str, params: dict | None = None) -> list[dict]:
|
||||
"""Execute a Workday Report-as-a-Service (RaaS) custom report.
|
||||
|
||||
Args:
|
||||
report_path: Path after /ccx/service/customreport2/<tenant>/
|
||||
params: Optional extra query parameters.
|
||||
"""
|
||||
if _USE_MOCK:
|
||||
return [
|
||||
{
|
||||
"reportName": report_path,
|
||||
"note": "Mock mode — returning synthetic report data",
|
||||
"rows": [
|
||||
{"employee": w["descriptor"], "department": w["primaryJob"]["businessUnit"]["descriptor"],
|
||||
"title": w["primaryJob"]["jobProfile"]["descriptor"]}
|
||||
for w in M.WORKDAY_WORKERS
|
||||
],
|
||||
}
|
||||
]
|
||||
return await _get().raas(report_path, params)
|
||||
@@ -0,0 +1 @@
|
||||
# Test configuration for Identity MCP
|
||||
@@ -0,0 +1,284 @@
|
||||
import asyncio
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from ad_adapter import ActiveDirectoryIdentityBackend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ad_backend():
|
||||
"""Create AD adapter instance for testing without credentials."""
|
||||
return ActiveDirectoryIdentityBackend(timeout_seconds=5.0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ad_backend_with_creds():
|
||||
"""Create AD adapter with test credentials."""
|
||||
return ActiveDirectoryIdentityBackend(
|
||||
username="test_user", password="test_pass", timeout_seconds=5.0
|
||||
)
|
||||
|
||||
|
||||
class TestActiveDirectoryBackend:
|
||||
"""Unit tests for AD adapter output parsing and error handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_success(self, ad_backend):
|
||||
"""Test successful user lookup with valid JSON response."""
|
||||
mock_output = '{"username":"jane.doe","first_name":"Jane","last_name":"Doe","display_name":"Jane Doe","enabled":true,"ou":"OU=Users,DC=example,DC=local","description":"Test User","last_logon_utc":"2026-03-10T15:30:00.0000000Z"}'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.get_user("jane.doe")
|
||||
|
||||
assert result is not None
|
||||
assert result["username"] == "jane.doe"
|
||||
assert result["first_name"] == "Jane"
|
||||
assert result["last_name"] == "Doe"
|
||||
assert result["display_name"] == "Jane Doe"
|
||||
assert result["enabled"] is True
|
||||
assert result["ou"] == "OU=Users,DC=example,DC=local"
|
||||
assert result["description"] == "Test User"
|
||||
assert "2026-03-10" in result["last_logon_utc"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_users_by_name_success_list(self, ad_backend):
|
||||
"""Test name search parsing for list response."""
|
||||
mock_output = '[{"username":"jane.doe","first_name":"Jane","last_name":"Doe","display_name":"Jane Doe","enabled":true,"ou":"OU=Users,DC=example,DC=local"},{"username":"john.doe","first_name":"John","last_name":"Doe","display_name":"John Doe","enabled":true,"ou":"OU=Users,DC=example,DC=local"}]'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.search_users_by_name("doe", limit=10)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
assert result[0]["username"] == "jane.doe"
|
||||
assert result[0]["display_name"] == "Jane Doe"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_users_by_name_success_single_object(self, ad_backend):
|
||||
"""Test name search parsing for single-object JSON response."""
|
||||
mock_output = '{"username":"jane.doe","first_name":"Jane","last_name":"Doe","display_name":"Jane Doe","enabled":true,"ou":"OU=Users,DC=example,DC=local"}'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.search_users_by_name("Jane Doe", limit=10)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0]["username"] == "jane.doe"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_users_by_name_empty_query(self, ad_backend):
|
||||
"""Test name search rejects blank query."""
|
||||
result = await ad_backend.search_users_by_name(" ", limit=10)
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_not_found(self, ad_backend):
|
||||
"""Test user lookup when user does not exist."""
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": "", "error": None}
|
||||
):
|
||||
result = await ad_backend.get_user("nonexistent")
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_command_failure(self, ad_backend):
|
||||
"""Test user lookup when PowerShell command fails."""
|
||||
with patch.object(
|
||||
ad_backend,
|
||||
"_run_powershell",
|
||||
return_value={"success": False, "data": None, "error": "Access denied"},
|
||||
):
|
||||
result = await ad_backend.get_user("jane.doe")
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_groups_success(self, ad_backend):
|
||||
"""Test group membership retrieval."""
|
||||
mock_output = '["GG-Global-VPN","GG-ServiceDesk"]'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.get_user_groups("jane.doe")
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
assert "GG-Global-VPN" in result
|
||||
assert "GG-ServiceDesk" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_groups_empty(self, ad_backend):
|
||||
"""Test group membership when user has no groups."""
|
||||
mock_output = "[]"
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.get_user_groups("jane.doe")
|
||||
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_group_members_success(self, ad_backend):
|
||||
"""Test retrieving members of a group."""
|
||||
mock_output = '["jane.doe","john.smith"]'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.get_group_members("GG-ServiceDesk")
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
assert "jane.doe" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_stale_users_success(self, ad_backend):
|
||||
"""Test finding stale users with lastLogonTimestamp cutoff."""
|
||||
mock_output = '[{"username":"john.smith","enabled":false,"last_logon_utc":"2025-12-01T10:00:00.0000000Z"}]'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.find_stale_users(60)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0]["username"] == "john.smith"
|
||||
assert result[0]["enabled"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_stale_users_negative_days(self, ad_backend):
|
||||
"""Test stale user query with invalid negative days."""
|
||||
result = await ad_backend.find_stale_users(-5)
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_computer_success(self, ad_backend):
|
||||
"""Test computer lookup returns OU and null assigned_username."""
|
||||
mock_output = '{"computer_name":"LT-1001","ou":"OU=Workstations,DC=example,DC=local","assigned_username":null}'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.get_computer("LT-1001")
|
||||
|
||||
assert result is not None
|
||||
assert result["computer_name"] == "LT-1001"
|
||||
assert result["ou"] == "OU=Workstations,DC=example,DC=local"
|
||||
assert result["assigned_username"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_computer_not_found(self, ad_backend):
|
||||
"""Test computer lookup when computer does not exist."""
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": "", "error": None}
|
||||
):
|
||||
result = await ad_backend.get_computer("NONEXISTENT")
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_powershell_timeout(self, ad_backend):
|
||||
"""Test PowerShell execution timeout handling."""
|
||||
with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError):
|
||||
result = await ad_backend._run_powershell("Start-Sleep -Seconds 60")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "timeout" in result["error"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_powershell_with_credentials(self, ad_backend_with_creds):
|
||||
"""Test PowerShell command includes credential block when configured."""
|
||||
mock_process = AsyncMock()
|
||||
mock_process.communicate.return_value = (b"", b"")
|
||||
mock_process.returncode = 0
|
||||
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", return_value=mock_process
|
||||
) as mock_subprocess:
|
||||
await ad_backend_with_creds._run_powershell("Get-ADUser test")
|
||||
|
||||
# Verify credential block was included in command args
|
||||
call_args = mock_subprocess.call_args[0]
|
||||
full_command = call_args[4] # 5th arg is the command string
|
||||
assert "ConvertTo-SecureString" in full_command
|
||||
assert "PSCredential" in full_command
|
||||
|
||||
|
||||
class TestBackendContract:
|
||||
"""Contract tests ensuring AD adapter matches IdentityBackend interface."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_return_shape(self, ad_backend):
|
||||
"""Verify get_user returns correct shape or None."""
|
||||
mock_output = '{"username":"test","first_name":"Test","last_name":"User","display_name":"Test User","enabled":true,"ou":"OU=Test","description":"","last_logon_utc":""}'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.get_user("test")
|
||||
|
||||
assert result is None or isinstance(result, dict)
|
||||
if result:
|
||||
assert "username" in result
|
||||
assert "first_name" in result
|
||||
assert "last_name" in result
|
||||
assert "display_name" in result
|
||||
assert "enabled" in result
|
||||
assert "ou" in result
|
||||
assert "description" in result
|
||||
assert "last_logon_utc" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_users_by_name_return_shape(self, ad_backend):
|
||||
"""Verify search_users_by_name returns list of expected user records."""
|
||||
mock_output = '[{"username":"test","first_name":"Test","last_name":"User","display_name":"Test User","enabled":true,"ou":"OU=Test"}]'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.search_users_by_name("test", limit=10)
|
||||
|
||||
assert isinstance(result, list)
|
||||
for user in result:
|
||||
assert "username" in user
|
||||
assert "first_name" in user
|
||||
assert "last_name" in user
|
||||
assert "display_name" in user
|
||||
assert "enabled" in user
|
||||
assert "ou" in user
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_groups_return_shape(self, ad_backend):
|
||||
"""Verify get_user_groups always returns list[str]."""
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": "[]", "error": None}
|
||||
):
|
||||
result = await ad_backend.get_user_groups("test")
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert all(isinstance(item, str) for item in result)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_stale_users_return_shape(self, ad_backend):
|
||||
"""Verify find_stale_users returns list of dicts with correct keys."""
|
||||
mock_output = '[{"username":"test","enabled":true,"last_logon_utc":""}]'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.find_stale_users(30)
|
||||
|
||||
assert isinstance(result, list)
|
||||
for user in result:
|
||||
assert "username" in user
|
||||
assert "enabled" in user
|
||||
assert "last_logon_utc" in user
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Integration smoke tests for AD adapter against non-production AD environment.
|
||||
|
||||
These tests require:
|
||||
1. Access to a non-production AD environment
|
||||
2. Test credentials set via environment variables:
|
||||
- AD_TEST_USERNAME
|
||||
- AD_TEST_PASSWORD
|
||||
3. Known test objects in AD for validation
|
||||
|
||||
Run with: pytest tests/test_integration.py -v
|
||||
Skip with: pytest tests/ --ignore=tests/test_integration.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from ad_adapter import ActiveDirectoryIdentityBackend
|
||||
|
||||
# Skip all integration tests if credentials not configured
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.getenv("AD_TEST_USERNAME") or not os.getenv("AD_TEST_PASSWORD"),
|
||||
reason="AD test credentials not configured (set AD_TEST_USERNAME and AD_TEST_PASSWORD)",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ad_integration_backend():
|
||||
"""Create AD adapter with test credentials from environment."""
|
||||
username = os.getenv("AD_TEST_USERNAME")
|
||||
password = os.getenv("AD_TEST_PASSWORD")
|
||||
return ActiveDirectoryIdentityBackend(
|
||||
username=username, password=password, timeout_seconds=30.0
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_smoke(ad_integration_backend):
|
||||
"""Smoke test: get_user returns expected shape for known test user.
|
||||
|
||||
TODO: Replace 'test_user' with actual test username in your AD environment.
|
||||
"""
|
||||
test_username = os.getenv("AD_TEST_USER", "test_user")
|
||||
result = await ad_integration_backend.get_user(test_username)
|
||||
|
||||
# Should return user data or None if user doesn't exist
|
||||
assert result is None or isinstance(result, dict)
|
||||
if result:
|
||||
assert "username" in result
|
||||
assert "enabled" in result
|
||||
assert "ou" in result
|
||||
assert result["username"] == test_username
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_groups_smoke(ad_integration_backend):
|
||||
"""Smoke test: get_user_groups returns list for known test user."""
|
||||
test_username = os.getenv("AD_TEST_USER", "test_user")
|
||||
result = await ad_integration_backend.get_user_groups(test_username)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert all(isinstance(group, str) for group in result)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_users_by_name_smoke(ad_integration_backend):
|
||||
"""Smoke test: search_users_by_name returns list with expected keys."""
|
||||
test_name_query = os.getenv("AD_TEST_NAME_QUERY", "test")
|
||||
result = await ad_integration_backend.search_users_by_name(test_name_query, limit=10)
|
||||
|
||||
assert isinstance(result, list)
|
||||
for user in result:
|
||||
assert "username" in user
|
||||
assert "first_name" in user
|
||||
assert "last_name" in user
|
||||
assert "display_name" in user
|
||||
assert "enabled" in user
|
||||
assert "ou" in user
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_group_members_smoke(ad_integration_backend):
|
||||
"""Smoke test: get_group_members returns list for known test group."""
|
||||
test_group = os.getenv("AD_TEST_GROUP", "Domain Users")
|
||||
result = await ad_integration_backend.get_group_members(test_group)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert all(isinstance(member, str) for member in result)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_stale_users_smoke(ad_integration_backend):
|
||||
"""Smoke test: find_stale_users returns list with proper shape."""
|
||||
result = await ad_integration_backend.find_stale_users(90)
|
||||
|
||||
assert isinstance(result, list)
|
||||
for user in result:
|
||||
assert "username" in user
|
||||
assert "enabled" in user
|
||||
assert "last_logon_utc" in user
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_computer_smoke(ad_integration_backend):
|
||||
"""Smoke test: get_computer returns expected shape for known test computer.
|
||||
|
||||
TODO: Replace 'test_computer' with actual test computer name in your AD.
|
||||
"""
|
||||
test_computer = os.getenv("AD_TEST_COMPUTER", "test_computer")
|
||||
result = await ad_integration_backend.get_computer(test_computer)
|
||||
|
||||
# Should return computer data or None if computer doesn't exist
|
||||
assert result is None or isinstance(result, dict)
|
||||
if result:
|
||||
assert "computer_name" in result
|
||||
assert "ou" in result
|
||||
assert "assigned_username" in result
|
||||
assert result["assigned_username"] is None # Phase 1 requirement
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_nonexistent_user_returns_none(ad_integration_backend):
|
||||
"""Verify nonexistent users return None, not error."""
|
||||
result = await ad_integration_backend.get_user("nonexistent_user_12345")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_nonexistent_group_returns_empty(ad_integration_backend):
|
||||
"""Verify nonexistent groups return empty list, not error."""
|
||||
result = await ad_integration_backend.get_group_members("nonexistent_group_12345")
|
||||
assert result == []
|
||||
@@ -0,0 +1,92 @@
|
||||
from lib.data import (
|
||||
scan_department_drift,
|
||||
scan_job_title_mismatches,
|
||||
scan_name_variance,
|
||||
scan_status_reconciliation_mismatches,
|
||||
)
|
||||
from server import (
|
||||
scan_department_mismatches,
|
||||
scan_job_title_drift,
|
||||
scan_name_variance_mismatches,
|
||||
scan_status_reconciliation,
|
||||
)
|
||||
|
||||
|
||||
def test_scan_status_reconciliation_mismatches_returns_expected_record() -> None:
|
||||
result = scan_status_reconciliation_mismatches()
|
||||
|
||||
assert result["scan_summary"]["total_records_checked"] == 9
|
||||
assert result["scan_summary"]["mismatches_found"] == 1
|
||||
assert result["mismatches"] == [
|
||||
{
|
||||
"employee_id": "EMP002",
|
||||
"employee_name": "Terminated User",
|
||||
"workday_status": "Terminated",
|
||||
"ad_enabled": True,
|
||||
"mismatch_type": "terminated_but_enabled",
|
||||
"severity": "high",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_scan_job_title_mismatches_returns_expected_record() -> None:
|
||||
result = scan_job_title_mismatches()
|
||||
|
||||
assert result["scan_summary"]["total_records_checked"] == 9
|
||||
assert result["scan_summary"]["mismatches_found"] == 1
|
||||
assert result["mismatches"] == [
|
||||
{
|
||||
"employee_id": "EMP003",
|
||||
"employee_name": "Alicia",
|
||||
"workday_title": "Senior Systems Analyst",
|
||||
"ad_title": "Systems Analyst",
|
||||
"mismatch_type": "job_title_mismatch",
|
||||
"severity": "medium",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_scan_department_drift_returns_expected_record() -> None:
|
||||
result = scan_department_drift()
|
||||
|
||||
assert result["scan_summary"]["total_records_checked"] == 9
|
||||
assert result["scan_summary"]["mismatches_found"] == 1
|
||||
assert result["mismatches"] == [
|
||||
{
|
||||
"employee_id": "EMP004",
|
||||
"employee_name": "Jordan",
|
||||
"workday_department": "Finance",
|
||||
"workday_cost_center": "CC300-FIN",
|
||||
"ad_department": "Accounting",
|
||||
"mismatch_type": "department_drift",
|
||||
"severity": "medium",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_scan_name_variance_returns_expected_records() -> None:
|
||||
result = scan_name_variance()
|
||||
|
||||
assert result["scan_summary"]["total_records_checked"] == 9
|
||||
assert result["scan_summary"]["mismatches_found"] == 3
|
||||
assert [item["employee_id"] for item in result["mismatches"]] == [
|
||||
"EMP010",
|
||||
"EMP020",
|
||||
"EMP777",
|
||||
]
|
||||
|
||||
|
||||
def test_scan_status_reconciliation_tool_matches_detector() -> None:
|
||||
assert scan_status_reconciliation() == scan_status_reconciliation_mismatches()
|
||||
|
||||
|
||||
def test_scan_job_title_drift_tool_matches_detector() -> None:
|
||||
assert scan_job_title_drift() == scan_job_title_mismatches()
|
||||
|
||||
|
||||
def test_scan_department_mismatches_tool_matches_detector() -> None:
|
||||
assert scan_department_mismatches() == scan_department_drift()
|
||||
|
||||
|
||||
def test_scan_name_variance_mismatches_tool_matches_detector() -> None:
|
||||
assert scan_name_variance_mismatches() == scan_name_variance()
|
||||
Reference in New Issue
Block a user