From f4ec8b1d9a8cc87d387d73eb419ddec96ff8ea5d Mon Sep 17 00:00:00 2001 From: Nathan Castaldi Date: Wed, 15 Apr 2026 10:44:58 -0400 Subject: [PATCH] feat: implement AD backend aliases and fix identity shard async calls (#3) * docs: add comprehensive Nexus MCP test cases for identity shards * fix: enhance Active Directory user retrieval methods and logging --- TEST_CASES.md | 165 +++++++++++++++++++++++++++++++ nexus-mcp/src/shards/identity.py | 33 +++++-- 2 files changed, 190 insertions(+), 8 deletions(-) create mode 100644 TEST_CASES.md diff --git a/TEST_CASES.md b/TEST_CASES.md new file mode 100644 index 0000000..ee9aa00 --- /dev/null +++ b/TEST_CASES.md @@ -0,0 +1,165 @@ +# Nexus MCP Test Cases + +## ๐ŸŸข Identity Shard โ€” Active Directory + +Test the core lookup tools first since those are the ones we just fixed. + +### User lookups + + Look up the AD user "jsmith" + + + + Find the Active Directory account for email "john.smith@wheels.com" + + + + Search for AD users matching "martinez" + +### Group tools + + List all Active Directory groups + + + + Get the members of the AD group "CN=IT-Admins,OU=Groups,DC=wheels,DC=com" + +### Account hygiene (the ones we just fixed) + + Show me all disabled accounts in Active Directory + + + + Find AD accounts that haven't logged in for 90 days + + + + Show stale accounts inactive for more than 60 days + +*** + +## ๐ŸŸข Identity Shard โ€” Microsoft Entra ID + + List users in Entra ID + + + + Get the Entra ID user for "john.smith@wheels.com" + + + + List all Entra ID groups + + + + Show me all Entra ID service principals + + + + Get the Conditional Access policies from Entra ID + + + + Show recent sign-in logs from Entra ID + + + + List users flagged as risky in Entra ID Identity Protection + +*** + +## ๐ŸŸก Workday Shard + + List workers in Workday + + + + Get the Workday worker record for employee ID "EMP001" + + + + Find the Workday worker with email "john.smith@wheels.com" + + + + List all supervisory organizations in Workday + + + + Show open positions in Workday + +*** + +## ๐ŸŸก Audit Shard (the most interesting ones) + +These are your cross-system drift tools โ€” great for confirming the full pipeline works end-to-end. + + Scan for terminated workers who still have active AD accounts + + + + Run a job title drift scan between Workday and Active Directory + + + + Check for department mismatches between Workday and AD + + + + Scan for name variance mismatches between Workday and AD + + + + Show me the last 20 Nexus audit log entries + + + + Give me Nexus audit statistics + +*** + +## ๐Ÿ”ด Stub Shards (these should return empty or stub responses, not errors) + +These confirm your feature flag / holding pattern works correctly โ€” the server should accept the call and return gracefully. + + List incidents from BMC Helix + + + + Track FedEx shipment "449044304137821" + + + + List assets from Lansweeper + + + + Show me Intune managed devices + +*** + +## ๐Ÿงช Suggested Test Order (most value, least noise) + +Run them in this order for a clean "smoke test" progression: + +| # | Command | What it validates | +| - | ----------------------------------------------------- | ---------------------------------------------- | +| 1 | `Show me all disabled accounts in Active Directory` | Fixed `query_users` path โœ… | +| 2 | `Find stale AD accounts inactive for 90 days` | Fixed `find_stale_users` rename โœ… | +| 3 | `Search for AD users matching "smith"` | Fixed `search_users_by_name` rename โœ… | +| 4 | `Find the AD user with email "john.smith@wheels.com"` | Fixed `ad_get_user_by_email` path โœ… | +| 5 | `List all Active Directory groups` | Confirms mock path + WIS-018 holding pattern โœ… | +| 6 | `Scan for terminated workers still active in AD` | Confirms cross-shard audit works โœ… | +| 7 | `Show me the last 20 Nexus audit log entries` | Confirms SOC 2 logging is active โœ… | +| 8 | `List incidents from BMC Helix` | Confirms stub shards fail gracefully โœ… | + +*** + +## One thing to watch for + +If any tool returns an **empty list `[]` that you didn't expect**, check: + +* Is `USE_MOCK=true` confirmed in the MCP server output? +* Does the mock data in `mock_data.py` have entries for that tool? + +If a tool **errors** instead of returning empty, that's a real bug worth capturing โ€” paste the error here and we'll triage it. diff --git a/nexus-mcp/src/shards/identity.py b/nexus-mcp/src/shards/identity.py index 4a3c9b5..91037d0 100644 --- a/nexus-mcp/src/shards/identity.py +++ b/nexus-mcp/src/shards/identity.py @@ -6,6 +6,7 @@ Mock: Set USE_MOCK=true to use built-in sample data (no credentials needed). from __future__ import annotations import asyncio +import logging import os import sys @@ -16,6 +17,8 @@ import mock_data as M from adapters import ADUserAdapter, EntraUserAdapter from schemas import CanonicalUser +logger = logging.getLogger(__name__) + _USE_MOCK = os.getenv("USE_MOCK", "false").lower() == "true" _ad = None @@ -54,7 +57,7 @@ def register(mcp: FastMCP) -> None: canonical = ADUserAdapter.to_canonical(ad_dict) return canonical.model_dump(mode='json', exclude_none=True) - ad_dict = await asyncio.to_thread(_get_ad().get_user, sam_account_name) + ad_dict = await _get_ad().get_user(sam_account_name) if not ad_dict: return None canonical = ADUserAdapter.to_canonical(ad_dict) @@ -72,8 +75,18 @@ def register(mcp: FastMCP) -> None: return None canonical = ADUserAdapter.to_canonical(ad_dict) return canonical.model_dump(mode='json', exclude_none=True) - - ad_dict = await asyncio.to_thread(_get_ad().get_user_by_email, email) + + # No dedicated email lookup in backend โ€” bounded paginated scan via query_users. + resp = await _get_ad().query_users( + fields=["username", "display_name", "first_name", "last_name", + "enabled", "ou", "description", "last_logon_utc", "email"], + page_size=500, + ) + target = email.lower() + ad_dict = next( + (u for u in resp.get("items", []) if (u.get("email") or "").lower() == target), + None, + ) if not ad_dict: return None canonical = ADUserAdapter.to_canonical(ad_dict) @@ -95,7 +108,7 @@ def register(mcp: FastMCP) -> None: canonical_users = [ADUserAdapter.to_canonical(u) for u in matches[:limit]] return [u.model_dump(mode='json', exclude_none=True) for u in canonical_users] - ad_dicts = await asyncio.to_thread(_get_ad().search_users, query, limit) + ad_dicts = await _get_ad().search_users_by_name(query, limit) canonical_users = [ADUserAdapter.to_canonical(u) for u in ad_dicts] return [u.model_dump(mode='json', exclude_none=True) for u in canonical_users] @@ -104,7 +117,9 @@ def register(mcp: FastMCP) -> None: """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) + # TODO: AD backend does not yet expose group enumeration (see WIS-018). + logger.warning("ad_list_groups: group enumeration not yet implemented in AD backend") + return [] @mcp.tool() async def ad_get_group_members(group_dn: str) -> list[dict]: @@ -117,7 +132,8 @@ def register(mcp: FastMCP) -> None: 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) + usernames = await _get_ad().get_group_members(group_dn) + return [{"sAMAccountName": u} for u in usernames] @mcp.tool() async def ad_get_disabled_accounts() -> list[dict]: @@ -127,7 +143,8 @@ def register(mcp: FastMCP) -> None: """ 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) + resp = await _get_ad().query_users(filter_params={"enabled": False}, page_size=200) + return resp.get("items", []) @mcp.tool() async def ad_get_stale_accounts(days_inactive: int = 90) -> list[dict]: @@ -146,7 +163,7 @@ def register(mcp: FastMCP) -> None: if u.get("userAccountControl") != "514" and (u.get("lastLogonTimestamp") or "9999") < cutoff ] - return await asyncio.to_thread(_get_ad().get_stale_accounts, days_inactive) + return await _get_ad().find_stale_users(days_inactive) # โ”€โ”€ Microsoft Entra ID โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€