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:
2026-04-13 09:20:35 -04:00
parent 475888ad01
commit 0c9aebf97a
32 changed files with 6208 additions and 32 deletions
+74
View File
@@ -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
+333
View File
@@ -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.
+202
View File
@@ -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.
View File
+910
View File
@@ -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,
}
+258
View File
@@ -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")),
}
+73
View File
@@ -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"
+72
View File
@@ -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()
+47
View File
@@ -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()
+52
View File
@@ -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()
+54
View File
@@ -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()
+45
View File
@@ -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()
+905
View File
@@ -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"},
]
+62
View File
@@ -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()
+31
View File
@@ -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"
View File
+192
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""Shard registration helpers."""
+220
View File
@@ -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", [])
+902
View File
@@ -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
+209
View File
@@ -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", [])
+123
View File
@@ -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", [])
+162
View File
@@ -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", [])
+107
View File
@@ -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)
View File
@@ -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()