Added current MCP working files

This commit is contained in:
2026-03-27 15:26:14 -04:00
parent a1397c7bcd
commit 96a04e6535
24 changed files with 5787 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
.venv/
.pytest_cache/
*.pyc
.coverage
htmlcov/
+57
View File
@@ -0,0 +1,57 @@
# Identity MCP → Copilot Studio Quick Start
## Prerequisites
- [ ] Identity MCP server running with streamable HTTP transport
- [ ] HTTPS endpoint publicly accessible from Power Platform
- [ ] API key generated and configured
## Quick setup commands
```bash
# 1. Test streamable transport locally
cd "MCP Servers/Identity"
export MCP_TRANSPORT=streamable
export MCP_PORT=8000
export IDENTITY_BACKEND=memory
python identity_mcp_server.py
# 2. Generate API key
openssl rand -hex 32
# 3. Test endpoint
curl -X POST http://localhost:8000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":"1"}'
```
## OpenAPI file configuration
Edit `identity-mcp-openapi.yaml`:
1. Line 16: Replace `your-identity-mcp-host.yourdomain.com` with your actual host
2. Lines 26-30: Ensure API key security is uncommented (default)
3. Save file
## Import to Copilot Studio
1. Copilot Studio → Tools → Add tool → New tool → Custom connector
2. Power Apps → New custom connector → Import OpenAPI file
3. Upload `identity-mcp-openapi.yaml` → Continue
4. Security tab → API Key → Header → `X-API-Key` → Create
5. Test tab → New connection → Enter API key → Test operation
6. Return to Copilot Studio → Add tool → Select connector → Add to agent
## Test prompts
- "Get user details for jsmith"
- "Who are the members of VPN-Users?"
- "Find stale users over 90 days"
## Files created
✅ `identity-mcp-openapi.yaml` — OpenAPI schema for Power Apps import
✅ `identity_mcp_server.py` — Updated with streamable HTTP support
✅ `copilot-studio-deployment-guide.md` — Full step-by-step walkthrough
---
**Next step:** Follow [copilot-studio-deployment-guide.md](copilot-studio-deployment-guide.md) for full deployment procedure.
@@ -0,0 +1,297 @@
This is a **solid restructure** already — you’ve done the hard conceptual work. What’s missing now (and what you asked for) is **operational depth**: the *“do this, then this”* detail that lets someone other than you actually deploy it without tribal knowledge.
Below I’ll **flesh out step‑by‑step instructions only where they add real value**, and I’ll be explicit about **where you should *not* over‑specify** (to avoid freezing the design too early).
I’ll organize this as:
1. **What to keep high‑level (already correct)**
2. **Sections that need step‑by‑step expansion**
3. **Proposed step‑by‑step inserts (ready to drop into the guide)**
4. **Optional appendix you may want to add later**
Everything below is written to **slot directly into your existing document** without rewriting its structure.
***
## 1. What you should *not* over‑specify (and you got right)
You were correct to **not** add step‑by‑steps for:
* MCP framework selection
* Programming language
* Hosting model (VM vs container)
* AI client choice
Those belong in **implementation runbooks**, not the install guide. Keep those abstract.
✅ Your guide is positioned correctly as a **governance + deployment guide**, not a dev README.
***
## 2. Sections that *should* be fleshed out with steps
These are the places where future readers will stumble without explicit instructions:
| Section | Why |
| ------------------------------------------- | --------------------------------------- |
| **Prerequisites / Required tools & access** | Needs concrete validation steps |
| **Phase 0: Pre‑deployment alignment** | Needs explicit deliverables |
| **Phase 1: Read‑only identity deployment** | Needs “how to implement safely” steps |
| **Phase 3: Controlled write actions** | Needs guardrail mechanics spelled out |
| **Troubleshooting / Escalation** | Needs decision logic, not just symptoms |
I’ll give you **drop‑in step lists** for each.
***
## 3. Step‑by‑step expansions (drop‑in ready)
***
### ✅ Expand: *Prerequisites / Required tools & access*
**Add this immediately after the checklist.**
```md
### Validation steps (Prerequisites)
Before proceeding, explicitly validate each prerequisite:
1. **Approved identity operations list**
- Confirm the list is written and versioned.
- Confirm each operation maps to an existing SOP step.
- Confirm Security has reviewed the list.
2. **Service account**
- Verify the service account:
- Is non-interactive
- Is not a member of any admin groups
- Has no delegated privileges beyond Read Directory Data
- Test authentication using the service account credentials from the MCP host.
3. **Read vs write boundary**
- Confirm Phases 1 and 2 include no write-capable tools.
- Confirm write-capable tools are not even defined in source control prior to Phase 3 approval.
4. **Version control**
- Confirm the MCP repository exists.
- Confirm tool definitions are committed before deployment.
- Confirm changes require review (PR or equivalent).
Deployment must not proceed until all validation steps are complete.
```
This turns the checklist into something **auditable**.
***
### ✅ Expand: *Phase 0 — Pre‑deployment alignment*
Right now this is conceptually strong but operationally vague.
**Add the following sub‑section:**
```md
#### Phase 0 – Execution steps
1. Inventory all identity-related SOPs that involve:
- Group membership changes
- OU movement
- Termination handling
- Device-user relationships
2. For each SOP:
- Identify which steps are read-only
- Identify which steps modify identity objects
- Mark which steps are reversible
3. Build the **Approved Identity Operations List**:
- One line per operation
- Include: object type, scope, risk level, SOP reference
4. Review the list with:
- IT Management (operational fit)
- Security (risk and auditability)
5. Create the MCP service account:
- Naming standard: svc-identity-mcp
- No mailbox
- No interactive logon
- Read Directory Data only
6. Document and circulate the final Phase 0 sign-off artifact.
```
This makes Phase 0 **repeatable**, not just theoretical.
***
### ✅ Expand: *Phase 1 — Read‑only identity deployment*
This is the most important place to add steps.
**Add this under Phase 1:**
```md
#### Phase 1 – Implementation steps
1. Deploy the MCP server host:
- Join the host to the domain (if required).
- Confirm network connectivity to domain controllers.
- Confirm time sync and DNS resolution.
2. Configure MCP runtime identity:
- Configure the MCP process to authenticate using the service account.
- Confirm no fallback credentials exist.
3. Implement read-only tools:
- Each tool must:
- Call an existing PowerShell query unchanged
- Return structured output only
- Perform no filtering beyond what the script already does
4. Validate tool behavior:
- Run each tool against:
- An enabled user
- A disabled user
- A known test OU
- Confirm outputs match manual PowerShell execution.
5. Enable logging:
- Verify logs include:
- Tool name
- Parameters
- Timestamp
- Result count
- Confirm logs are retained per security policy.
6. Restrict exposure:
- Expose tools only to approved AI clients.
- Do not expose tools directly to end users.
```
This makes Phase 1 **safe by construction**.
***
### ✅ Expand: *Phase 3 — Controlled write actions*
You did the right thing conceptually — now spell out *how the guardrail works*.
**Add this subsection:**
```md
#### Phase 3 – Write-action enforcement mechanics
Every write-capable tool must implement the following controls:
1. **Pre-execution validation**
- Verify the target object exists.
- Verify the operation is listed in the Approved Identity Operations List.
- Verify the target group or OU is on the approved list.
2. **Human approval gate**
- Present a clear summary of the proposed change:
- Object
- Current state
- Proposed state
- Require explicit human confirmation before execution.
3. **Execution**
- Execute exactly one identity change per tool invocation.
- Do not chain changes.
4. **Post-execution verification**
- Re-query the object.
- Confirm the change applied successfully.
5. **Audit logging**
- Record:
- Who approved
- What was changed
- When it occurred
- Ticket reference
```
This prevents **agentic drift** later.
***
### ✅ Expand: *Troubleshooting / Escalation procedures*
Right now this is symptom‑based. Add decision logic.
**Append this block:**
```md
### Troubleshooting decision flow
When an issue occurs:
1. Determine the phase:
- Phase 1 or 2 issue → read-only failure
- Phase 3 or 4 issue → write or workflow failure
2. If read-only:
- Test the equivalent PowerShell query manually.
- Compare output to MCP result.
- If mismatch exists, disable the tool.
3. If write-related:
- Immediately disable the affected write tool.
- Do not retry automatically.
- Validate approval gate behavior before re-enabling.
4. If audit logging fails:
- Suspend all write actions.
- Resume only after log integrity is restored.
Escalate only after isolating the failure to a specific tool or phase.
```
This avoids panic escalations.
***
## 4. Optional (but recommended) appendix
You *don’t need this now*, but consider adding later:
### Appendix A — Tool Approval Matrix
A table mapping:
* Tool name
* SOP reference
* Read/write
* Approval required
* Risk level
This becomes gold during audits.
***
## Final assessment
You’ve built:
* ✅ A **governance‑first MCP guide**
* ✅ Correct separation of authority
* ✅ Safe phased rollout
* ✅ Audit‑friendly structure
By adding the step‑by‑step inserts above, you convert it from:
> “Well‑designed architecture doc”
into:
> **“Deployable enterprise standard”**
If you want, next we can:
* Turn this into a **security review packet**
* Produce a **one‑page exec summary**
* Write a **Phase 1 runbook** for engineers
Just tell me which artifact you want next.
@@ -0,0 +1,217 @@
# Identity MCP – Deployment Plan
## Scope definition (what “Identity MCP” means here)
**Identity MCP** in your environment = an MCP server that exposes **Active Directory + Entra ID identity state and approved identity operations** to AI clients **without replacing existing IAM processes**.
**Authoritative systems remain unchanged**:
* On‑prem Active Directory
* Entra ID (Azure AD)
* Microsoft 365 admin center
* Service desk ticketing
MCP becomes a **governed interface**, not a new identity system.
***
## Phase 0 – Pre‑deployment alignment (required)
### Inputs already in your tenant
Your identity operations are well‑documented and standardized:
* AD scripts and procedures for:
* Group membership
* VPN access
* Termination workflows [\[Active Directory \| OneNote\]](https://wheelsinc.sharepoint.com/sites/WheelsITServiceDesk/_layouts/15/Doc.aspx?action=edit&mobileredirect=true&wdorigin=Sharepoint&DefaultItemOpen=1&sourcedoc={04cb4993-3d7c-4785-b67f-6a6afefdcaa8}&wd=target(/PowerShell.one/)&wdpartid={4d895098-550e-0b0c-194c-af7c0195f51e}{1}&wdsectionfileid={7ffa6051-4ff6-4039-96a0-8533c34d8ade}), [\[Active Directory \| OneNote\]](https://wheelsinc.sharepoint.com/sites/WheelsITServiceDesk/_layouts/15/Doc.aspx?action=edit&mobileredirect=true&wdorigin=Sharepoint&DefaultItemOpen=1&sourcedoc={04cb4993-3d7c-4785-b67f-6a6afefdcaa8}&wd=target(/User Termination.one/)&wdpartid={b2ba40a3-f389-4021-9ec5-54268ce102ab}{1}&wdsectionfileid={33ca8871-68c7-4218-a016-fca812102c86})
* New‑hire and onboarding SOPs with explicit AD and Entra steps [\[Onboarding...ount setup \| Word\]](https://wheelsinc.sharepoint.com/sites/WheelsITServiceDesk/_layouts/15/Doc.aspx?sourcedoc=%7B2594F0FC-A36C-40A2-A5E8-C227EE9ACC6F%7D&file=Onboarding%20Process%20-%20New%20account%20setup.docx&action=default&mobileredirect=true&DefaultItemOpen=1), [\[Latest Ser...ount setup \| Word\]](https://wheelsinc.sharepoint.com/sites/WheelsITDesksideServices/_layouts/15/Doc.aspx?sourcedoc=%7B8B3CF4B1-D9C1-4A6F-A5AA-99277B453783%7D&file=Latest%20Service%20Desk%20Documentation%20-%20New%20account%20setup.docx&action=default&mobileredirect=true&DefaultItemOpen=1)
* Device and user setup SOPs that depend on identity state [\[Device Ima...Setup SoP \| Word\]](https://wheelsinc.sharepoint.com/sites/WheelsITDesksideServices/_layouts/15/Doc.aspx?sourcedoc=%7B8BF1A3D1-C48A-4921-86FD-6A00AC9FE198%7D&file=Device%20Image%20and%20Setup%20SoP.docx&action=default&mobileredirect=true&DefaultItemOpen=1), [\[IT-SOP-009...vice Setup \| PDF\]](https://wheelsinc.sharepoint.com/sites/WheelsITDesksideServices/Shared%20Documents/General/SOPs/IT-SOP-009%20New%20Device%20Setup.pdf?web=1)
### Deliverables
* ✅ List of **approved identity operations**
* ✅ Service account model
* ✅ Read vs write separation
No MCP code is written until this is agreed.
***
## Phase 1 – Read‑only Identity MCP (foundation)
### Objective
Allow AI to **observe identity state safely**.
### MCP server capabilities (read‑only)
Expose **only** what your team already queries manually:
**Users**
* Enabled / disabled
* OU
* Description (termination markers)
* Last logon
**Groups**
* Group membership for a user
* Members of a group
* VPN‑related group membership (already queried today) [\[Active Directory \| OneNote\]](https://wheelsinc.sharepoint.com/sites/WheelsITServiceDesk/_layouts/15/Doc.aspx?action=edit&mobileredirect=true&wdorigin=Sharepoint&DefaultItemOpen=1&sourcedoc={04cb4993-3d7c-4785-b67f-6a6afefdcaa8}&wd=target(/PowerShell.one/)&wdpartid={4d895098-550e-0b0c-194c-af7c0195f51e}{1}&wdsectionfileid={7ffa6051-4ff6-4039-96a0-8533c34d8ade})
**Computers**
* Device accounts
* OU placement
### Technical pattern
* MCP server runs under **dedicated AD service account**
* Permissions: *Read Directory Data only*
* Each MCP tool maps **1:1 to an existing PowerShell query**
No abstraction magic. No new logic.
### Example MCP tools
identity.getUser(username)
identity.getUserGroups(username)
identity.getGroupMembers(groupName)
identity.findStaleUsers(days)
identity.getComputer(computerName)
✅ **Outcome**
AI can answer questions your team already investigates manually—without taking action.
***
## Phase 2 – Correlated identity insight
### Objective
Connect identity data to **device and process context**.
At this point, Identity MCP is used *together with*:
* Intune MCP
* Inventory MCP
* Service Desk MCP (read‑only)
### Example queries unlocked
* “Which users still have VPN access but are no longer active?”
* “Which devices belong to disabled users but are still domain‑joined?”
* “Which onboarding tickets are missing required group assignments?”
This directly supports SOP enforcement without automation.
✅ **Outcome**
Identity becomes **context**, not just attributes.
***
## Phase 3 – Controlled write actions (SOP‑aligned)
### Objective
Introduce **safe, reversible identity actions** that already exist in SOPs.
### Allowed write actions (initial)
Based strictly on documented procedures:
* Add/remove user from **non‑privileged groups**
* Update user description fields (termination markers) [\[Active Directory \| OneNote\]](https://wheelsinc.sharepoint.com/sites/WheelsITServiceDesk/_layouts/15/Doc.aspx?action=edit&mobileredirect=true&wdorigin=Sharepoint&DefaultItemOpen=1&sourcedoc={04cb4993-3d7c-4785-b67f-6a6afefdcaa8}&wd=target(/User Termination.one/)&wdpartid={b2ba40a3-f389-4021-9ec5-54268ce102ab}{1}&wdsectionfileid={33ca8871-68c7-4218-a016-fca812102c86})
* Move users or computers between **approved OUs**
🚫 Explicitly excluded initially:
* Account deletion
* Privileged group changes
* Password resets
* MFA changes
### Guardrail model
1. AI proposes action
2. Human approves
3. MCP executes
4. Result logged (ticket or audit log)
No silent execution.
✅ **Outcome**
AI assists identity work **without becoming an identity admin**.
***
## Phase 4 – Identity MCP + Service Desk coupling
### Objective
Tie identity state to **work tracking and compliance**.
Your SOPs already require ticket updates and closure steps. [\[Latest Ser...ount setup \| Word\]](https://wheelsinc.sharepoint.com/sites/WheelsITDesksideServices/_layouts/15/Doc.aspx?sourcedoc=%7B8B3CF4B1-D9C1-4A6F-A5AA-99277B453783%7D&file=Latest%20Service%20Desk%20Documentation%20-%20New%20account%20setup.docx&action=default&mobileredirect=true&DefaultItemOpen=1)
### MCP enables
* Linking identity actions to tickets automatically
* Preventing “work done, ticket forgotten”
* Auditable identity changes tied to request origin
✅ **Outcome**
Identity actions become traceable, not tribal knowledge.
***
## Security & governance controls (non‑negotiable)
### Identity
* Separate MCP service account
* No reuse of admin credentials
* Least‑privilege per operation
### Audit
* Every MCP call logged
* Tool name + parameters + result recorded
* Correlates to human prompt
### Change control
* MCP tool definitions version‑controlled
* Changes reviewed like scripts
* SOP changes trigger MCP review
***
## What Identity MCP deliberately does *not* do
* Replace ADUC or Azure Portal
* Auto‑provision users
* Decide identity policy
* Bypass approvals
Identity MCP is **assistive infrastructure**, not automation for automation’s sake.
***
## Rollout summary (executive‑safe)
| Phase | Capability | Risk |
| ----- | -------------------------- | ------------------- |
| 1 | Read‑only identity queries | None |
| 2 | Cross‑system correlation | Low |
| 3 | SOP‑approved writes | Medium (controlled) |
| 4 | Ticket integration | Low |
***
## One‑sentence summary
> Identity MCP in your environment should start as a **read‑only mirror of existing AD knowledge**, then gradually expose **only those identity actions already defined in SOPs**, with human approval and audit at every step.
***
+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,
}
+343
View File
@@ -0,0 +1,343 @@
# Identity MCP → Copilot Studio Deployment Guide
## Overview
This guide walks you through deploying your Identity MCP server to Microsoft Copilot Studio using **Option 2: Custom MCP Connector** via Power Apps.
**Prerequisite:** You must have an HTTPS-accessible endpoint for your Identity MCP server. Copilot Studio cannot connect to localhost or STDIO-based servers.
---
## Phase 1: Prepare your Identity MCP for HTTP transport
### Step 1: Update dependencies (if needed)
Your Identity MCP can now run with streamable HTTP transport. Ensure you have the server extras installed:
```bash
cd "MCP Servers/Identity"
uv pip install "mcp[server]>=1.2.0"
```
### Step 2: Configure environment variables
The server now supports two transport modes via environment variables:
**For local/VS Code testing (STDIO):**
```bash
# Default - no env vars needed
export MCP_TRANSPORT=stdio
python identity_mcp_server.py
```
**For Copilot Studio (Streamable HTTP):**
```bash
export MCP_TRANSPORT=streamable
export MCP_HOST=0.0.0.0 # Bind to all interfaces
export MCP_PORT=8000 # Choose your port
export IDENTITY_BACKEND=ad # Use Active Directory backend
python identity_mcp_server.py
```
### Step 3: Test streamable transport locally
Before deploying to production, validate the HTTP endpoint works:
```bash
# Terminal 1: Start the server
cd "MCP Servers/Identity"
export MCP_TRANSPORT=streamable
export MCP_PORT=8000
export IDENTITY_BACKEND=memory # Safe mode for testing
python identity_mcp_server.py
```
```bash
# Terminal 2: Test the endpoint
curl -X POST http://localhost:8000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":"test-1"}'
```
**Expected response:** JSON with available tools (get_user, search_users_by_name, etc.)
---
## Phase 2: Deploy to production hosting
### Hosting options
Your Identity MCP must be accessible via HTTPS from Power Platform cloud services. Common options:
1. **Azure App Service** (recommended for Microsoft ecosystem)
2. **Azure Container Instances** with HTTPS ingress
3. **On-premises IIS** with reverse proxy + public HTTPS endpoint
4. **VM with nginx/caddy** reverse proxy + Let's Encrypt cert
### Security requirements
✅ **HTTPS only** — Power Platform will not connect to HTTP
✅ **Valid SSL certificate** — self-signed certs will fail
✅ **Firewall rules** — allow inbound HTTPS from Power Platform IP ranges
✅ **Authentication** — configure API key or OAuth (see Phase 3)
### Example: Azure App Service deployment
```bash
# Install Azure CLI if not already installed
az login
# Create resource group
az group create --name rg-identity-mcp --location eastus
# Create App Service plan (Linux)
az appservice plan create \
--name plan-identity-mcp \
--resource-group rg-identity-mcp \
--sku B1 \
--is-linux
# Create web app with Python runtime
az webapp create \
--name identity-mcp-wheels \
--resource-group rg-identity-mcp \
--plan plan-identity-mcp \
--runtime "PYTHON:3.11"
# Configure environment variables
az webapp config appsettings set \
--name identity-mcp-wheels \
--resource-group rg-identity-mcp \
--settings \
MCP_TRANSPORT=streamable \
MCP_HOST=0.0.0.0 \
MCP_PORT=8000 \
IDENTITY_BACKEND=ad \
AD_USERNAME="svc-identity-mcp@wheelsinc.com" \
AD_PASSWORD="<secure-password-from-key-vault>"
# Deploy code (from repo root)
cd "MCP Servers/Identity"
zip -r deploy.zip .
az webapp deploy \
--name identity-mcp-wheels \
--resource-group rg-identity-mcp \
--src-path deploy.zip \
--type zip
# Verify deployment
curl https://identity-mcp-wheels.azurewebsites.net/mcp
```
**Your production URL:** `https://identity-mcp-wheels.azurewebsites.net`
---
## Phase 3: Configure authentication (recommended)
### Option A: API Key (simplest for Phase 1)
1. **Generate a strong API key:**
```bash
# Generate a secure random key (save this!)
openssl rand -hex 32
# Example output: a1b2c3d4e5f6...
```
2. **Configure your MCP server to validate API keys:**
Add to your server code (before tool definitions):
```python
import os
from fastapi import HTTPException, Security
from fastapi.security import APIKeyHeader
API_KEY = os.getenv("MCP_API_KEY")
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=True)
async def verify_api_key(key: str = Security(api_key_header)):
if key != API_KEY:
raise HTTPException(status_code=401, detail="Invalid API key")
return key
```
3. **Store API key in your hosting environment:**
```bash
# Azure App Service
az webapp config appsettings set \
--name identity-mcp-wheels \
--resource-group rg-identity-mcp \
--settings MCP_API_KEY="<your-generated-key>"
```
### Option B: OAuth 2.0 (for Phase 3+ with user delegation)
OAuth configuration is outlined in the OpenAPI file. Defer to Phase 3 when write operations are enabled.
---
## Phase 4: Import OpenAPI schema to Power Apps
### Step 1: Customize the OpenAPI file
Edit `identity-mcp-openapi.yaml`:
1. **Update host:** Replace `your-identity-mcp-host.yourdomain.com` with your actual production URL:
```yaml
host: identity-mcp-wheels.azurewebsites.net
```
2. **Configure authentication:** Uncomment the security method you chose (API key is already configured).
3. **Save the file.**
### Step 2: Create custom connector in Power Apps
1. Go to your Copilot Studio agent
2. Navigate to **Tools** page
3. Click **Add a tool**
4. Select **New tool**
5. Select **Custom connector**
➜ You're redirected to Power Apps
6. In Power Apps:
- Click **New custom connector**
- Select **Import OpenAPI file**
- Upload `identity-mcp-openapi.yaml`
- Click **Continue**
### Step 3: Configure connector security
On the **Security** tab:
**If using API Key:**
- Authentication type: **API Key**
- Parameter label: `API Key`
- Parameter name: `X-API-Key`
- Parameter location: **Header**
**If using OAuth 2.0:**
- Follow the OAuth configuration from the Microsoft Learn doc (linked in References)
Click **Create connector** when done.
### Step 4: Test the connector
1. On the **Test** tab, click **New connection**
2. Enter your API key (if using API key auth)
3. Click **Create connection**
4. In the **Operations** section, select `InvokeIdentityMCP`
5. Paste a test MCP request payload:
```json
{
"jsonrpc": "2.0",
"method": "tools/list",
"id": "test-1"
}
```
6. Click **Test operation**
7. **Expected result:** 200 OK with list of available tools
---
## Phase 5: Add connector to Copilot Studio agent
1. Return to Copilot Studio (close Power Apps tab)
2. On the **Add tool** dialog:
- Select your newly created **Identity MCP** connector
- Click **Create a new connection** (or use existing)
3. Authenticate if prompted
4. Click **Add to agent**
---
## Phase 6: Validate end-to-end
### Test in agent chat
1. Open your agent's **Test chat** panel
2. Ask a question that should trigger Identity MCP tools:
**Example prompts:**
- "Get user details for jsmith"
- "Who are the members of the VPN-Users group?"
- "Find users who haven't logged in for 90 days"
3. **Check tool invocation:**
- Open the trace/tool panel (if available)
- Confirm the Identity MCP tool was invoked
- Validate the response matches expected data
### Troubleshooting validation
| Issue | Check |
| --- | --- |
| Tool not invoked | Improve connector description and operation summary so orchestrator understands when to call it |
| 401 Unauthorized | Verify API key matches in both server config and connector connection |
| 500 Server error | Check server logs for backend failures (AD connectivity, permissions) |
| Tool returns empty results | Test the same query via manual PowerShell to isolate MCP vs AD issue |
---
## Security & governance checklist
Before enabling for production use:
- [ ] HTTPS with valid certificate confirmed
- [ ] API key (or OAuth) authentication enabled and tested
- [ ] Service account permissions validated (Read Directory Data only for Phase 1)
- [ ] Audit logging verified (tool name, params, timestamp, result)
- [ ] Connector restricted to authorized agents only
- [ ] Rate limiting configured (if available in hosting environment)
- [ ] Connection timeout tested under load
- [ ] Disaster recovery plan documented (connector re-import procedure)
---
## Maintenance procedures
### Update connector after schema changes
If you modify tool definitions or add new tools:
1. Update `identity-mcp-openapi.yaml` with new endpoint details
2. In Power Apps, navigate to **Custom connectors**
3. Select **Identity MCP** connector
4. Click **Update from OpenAPI file**
5. Upload the updated YAML
6. Click **Update connector**
7. Test updated operations on the **Test** tab
8. Return to Copilot Studio and verify new tools are available
### Monitor API usage
Check connector call metrics regularly:
1. Power Apps → **Custom connectors** → **Identity MCP**
2. View **Analytics** tab for:
- Call volume
- Error rates
- Latency trends
---
## References
- [Microsoft Learn: Add existing MCP server to agent](https://learn.microsoft.com/en-us/microsoft-copilot-studio/mcp-add-existing-server-to-agent#option-2-create-a-custom-mcp-connector-in-power-apps)
- [Microsoft Learn: Custom connectors in Power Apps](https://learn.microsoft.com/en-us/connectors/custom-connectors/)
- [Model Context Protocol Specification](https://modelcontextprotocol.io/specification/)
- Internal: `identity-mcp-install-guide.md` (Phase 0-4 governance procedures)
---
## Revision history
| Version | Date | Author | Changes |
| --- | --- | --- | --- |
| 1.0 | 2026-03-11 | N. Castaldi | Initial deployment guide for Option 2 (Custom Connector) path |
+99
View File
@@ -0,0 +1,99 @@
"""
Debug script to diagnose AD connectivity and find the correct username.
"""
import asyncio
import logging
import sys
from ad_adapter import ActiveDirectoryIdentityBackend
logging.basicConfig(level=logging.DEBUG, format='%(levelname)s: %(message)s')
async def diagnose():
backend = ActiveDirectoryIdentityBackend(
username='cnathan',
password='*********',
timeout_seconds=30.0
)
print('=' * 60)
print('ACTIVE DIRECTORY CONNECTIVITY DIAGNOSTICS')
print('=' * 60)
print()
# Test 1: Basic connectivity - Get domain info
print('📡 Test 1: Verifying AD connectivity...')
domain_result = await backend._run_powershell(
'Get-ADDomain | Select-Object DNSRoot,NetBIOSName | ConvertTo-Json -Compress'
)
if domain_result.get('success'):
print(f'✅ Connected to domain: {domain_result["data"]}')
else:
print(f'❌ Domain connection failed: {domain_result.get("error")[:200]}')
print('\n⚠️ Cannot proceed - AD not reachable or credentials invalid')
return
print()
# Test 2: Get the authenticated user's info
print('👤 Test 2: Identifying authenticated user...')
whoami_result = await backend._run_powershell(
'Get-ADUser -Identity $env:USERNAME -Properties samAccountName,DisplayName,mail | Select-Object samAccountName,DisplayName,mail | ConvertTo-Json -Compress'
)
if whoami_result.get('success'):
print(f'✅ Your AD identity: {whoami_result["data"]}')
else:
print(f'⚠️ Could not resolve $env:USERNAME: {whoami_result.get("error")[:200]}')
print()
# Test 3: List some users to verify queries work
print('📋 Test 3: Listing sample users (first 5)...')
sample_result = await backend._run_powershell(
'Get-ADUser -Filter * -Properties samAccountName | Select-Object -First 5 samAccountName | ConvertTo-Json -Compress'
)
if sample_result.get('success'):
print(f'✅ Sample users found: {sample_result["data"]}')
else:
print(f'❌ Query failed: {sample_result.get("error")[:200]}')
print()
# Test 4: Search for users with partial name match (fixed syntax)
print('🔍 Test 4: Searching for users matching "nathan"...')
search_result = await backend._run_powershell(
'Get-ADUser -Filter {samAccountName -like "*nathan*"} -Properties samAccountName,DisplayName | Select-Object samAccountName,DisplayName | ConvertTo-Json -Compress'
)
if search_result.get('success'):
if search_result['data']:
print(f'✅ Found matches: {search_result["data"]}')
else:
print('⚠️ No users found matching "*nathan*"')
else:
print(f'❌ Search failed: {search_result.get("error")[:200]}')
print()
# Test 5: Try common username variations
print('🔍 Test 5: Testing common username variations...')
variations = ['castn1', 'cnathan', 'nathan', 'cory.nathan', 'nathan.cory']
for username in variations:
result = await backend.get_user(username)
status = '✅ FOUND' if result else '❌ Not found'
print(f' {status}: {username}')
print()
print('=' * 60)
print('RECOMMENDATION:')
print('If Test 1 passed but no users found, your account may not have')
print('permission to read AD users. Check with your AD admin.')
print('=' * 60)
if __name__ == '__main__':
try:
asyncio.run(diagnose())
except KeyboardInterrupt:
print('\n\n⚠️ Interrupted by user')
sys.exit(1)
except Exception as e:
print(f'\n\n❌ Fatal error: {e}')
sys.exit(1)
+51
View File
@@ -0,0 +1,51 @@
import asyncio
import sys
sys.path.insert(0, '.')
from ad_adapter import ActiveDirectoryIdentityBackend
async def main():
backend = ActiveDirectoryIdentityBackend()
# Manually call the low-level PowerShell method to see raw output
command = """
$filter = "Enabled -eq $False"
$users = @(Get-ADUser -Filter $filter -Properties DisplayName, SamAccountName, Enabled -ErrorAction Stop)
$debug_before_sort = @{
count_before = $users.Count
has_user_before = ($null -ne $users[0])
}
if ($users.Count -gt 0) {
$debug_before_sort.sam_before = $users[0].SamAccountName
}
$users = $users | Sort-Object SamAccountName
$users = @($users)
$debug_after_sort = @{
count_after = $users.Count
has_user_after = ($null -ne $users[0])
}
if ($users.Count -gt 0) {
$debug_after_sort.sam_after = $users[0].SamAccountName
}
@{
before_sort = $debug_before_sort
after_sort = $debug_after_sort
} | ConvertTo-Json -Compress -Depth 3
"""
result = await backend._run_powershell(command, {"test": "debug"})
print("=== PowerShell Result ===")
print(f"Success: {result['success']}")
print(f"Error: {result['error']}")
print(f"Data length: {len(result['data']) if result['data'] else 0}")
print("=== Raw Data ===")
print(result['data'])
asyncio.run(main())
+367
View File
@@ -0,0 +1,367 @@
---
title: "Identity MCP — Deployment and install guide"
description: "Step-by-step guide for deploying the Identity MCP server in a phased rollout across Active Directory and Entra ID environments."
type: "Install Guide"
version: "v2"
author: "N. Castaldi"
date: "2026-03-11"
---
<!-- Guide Title and Logo Row -->
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5em;">
<h1 style="margin: 0; font-size: 2em;">Identity MCP — Deployment and install guide</h1>
<img src="https://rwdn-uploads.s3.amazonaws.com/mcgl15001/production/54b7a8d305541296303508cec6e5dfb6.png" alt="Company Logo" style="height:60px; max-width:180px; object-fit:contain;">
</div>
## Table of contents
- [Introduction](#introduction)
- [Definitions](#definitions)
- [Prerequisites / Required tools & access](#prerequisites--required-tools--access)
- [Installation procedure](#installation-procedure)
- [Post-installation actions](#post-installation-actions)
- [Troubleshooting / Escalation procedures](#troubleshooting--escalation-procedures)
- [References / Related documents](#references--related-documents)
- [Revision history](#revision-history)
---
## Introduction
### Purpose
This guide describes how to deploy the **Identity MCP** server in a phased rollout. It covers pre-deployment alignment, read-only AD/Entra ID exposure, correlated cross-system queries, SOP-aligned write actions with human-approval guardrails, and final Service Desk integration.
### Audience
IT Management, Security team, and Developers or Automation engineers responsible for implementing or governing the Identity MCP deployment.
### Scope
This guide covers the deployment of Identity MCP as a **governed interface** to Active Directory and Entra ID. It does not replace any existing IAM system. All authoritative identity systems remain unchanged:
- On-premises Active Directory
- Entra ID (Azure AD)
- Microsoft 365 admin center
- Service desk ticketing system
MCP becomes an assistive interface, not a new identity system.
---
## Definitions
| Term | Definition |
| --- | --- |
| **MCP** | Model Context Protocol — a standard interface that exposes structured data and approved operations to AI clients |
| **Identity MCP** | An MCP server that surfaces AD and Entra ID identity state, and approved identity operations, to AI clients |
| **AD** | Active Directory — on-premises directory service managing users, computers, and groups |
| **Entra ID** | Microsoft cloud identity platform (formerly Azure AD) |
| **OU** | Organizational Unit — a container within AD used to organize objects and apply Group Policy |
| **Service account** | A dedicated, non-personal AD account used exclusively by the MCP server with least-privilege permissions |
---
## Prerequisites / Required tools & access
Complete all items below before beginning any phase of deployment.
- [ ] **Approved identity operations list** — written list of every operation the MCP server is permitted to perform
- [ ] **Service account provisioned** — dedicated AD service account created (e.g., `svc-identity-mcp`); no shared or admin credentials
- [ ] **Read vs write separation documented** — explicit written boundary between read-only phases and write-action phases
- [ ] **AD permissions set** — service account has *Read Directory Data* only (minimum required for Phase 1)
- [ ] **MCP server host prepared** — server or container host ready to run the MCP process
- [ ] **Version control repository** — MCP tool definitions must be tracked in source control from day 1
- [ ] **Access to existing SOP documentation** — see [References / Related documents](#references--related-documents)
> **No MCP code is written until all Phase 0 alignment deliverables are confirmed.**
### Validation steps (Prerequisites)
Before proceeding, explicitly validate each prerequisite:
1. **Approved identity operations list**
- Confirm the list is written and version-controlled.
- Confirm each operation maps to an existing SOP step.
- Confirm Security has reviewed and approved the list.
2. **Service account**
- Verify the service account is non-interactive.
- Verify the service account is not a member of admin groups.
- Verify the account has no delegated permissions beyond *Read Directory Data*.
- Test authentication using service account credentials from the MCP host.
3. **Read vs write boundary**
- Confirm Phases 1 and 2 include no write-capable tools.
- Confirm write-capable tools are not defined in source control before Phase 3 approval.
4. **Version control readiness**
- Confirm the repository exists and is accessible.
- Confirm tool definitions are committed before deployment.
- Confirm all changes require pull request review (or equivalent).
Deployment must not proceed until all validation steps are complete.
---
## Installation procedure
The deployment follows five sequential phases. Each phase must reach its defined exit criteria before the next begins.
```mermaid
flowchart LR
phase-zero[Phase 0: Pre-deployment alignment] --> phase-one[Phase 1: Read-only identity]
phase-one --> phase-two[Phase 2: Correlated insight]
phase-two --> phase-three[Phase 3: Controlled writes]
phase-three --> phase-four[Phase 4: Service Desk coupling]
```
| Phase | Capability | Risk level |
| --- | --- | --- |
| 0 | Pre-deployment alignment | None |
| 1 | Read-only identity queries | None |
| 2 | Cross-system correlation | Low |
| 3 | SOP-approved write actions | Medium (controlled) |
| 4 | Ticket integration | Low |
---
### Phase 0: Pre-deployment alignment
**Objective:** Reach written agreement on what Identity MCP is permitted to do before any code is written.
1. Review all existing identity-related SOPs and PowerShell scripts (see [References](#references--related-documents)).
2. Produce and circulate the **approved identity operations list** — enumerate every operation the MCP server will be allowed to perform.
3. Define the **service account model**:
- Create a dedicated AD service account (e.g., `svc-identity-mcp`).
- Grant *Read Directory Data* permissions only at this stage.
- Do not reuse admin or named-user credentials.
4. Document the **read vs write boundary** in writing. Phases 1 and 2 are strictly read-only. Phase 3 introduces the first write actions.
5. Obtain sign-off from IT Management and the Security team before proceeding to Phase 1.
#### Phase 0 execution steps
1. Inventory all identity-related SOPs that involve group membership changes, OU moves, termination handling, and device-to-user relationships.
2. For each SOP, classify the steps as read-only or write, and mark which write steps are reversible.
3. Build the **Approved Identity Operations List** with one row per operation, including object type, scope, risk level, and SOP reference.
4. Review the operations list with IT Management for operational fit and Security for audit and risk posture.
5. Create and harden the service account using the naming standard `svc-identity-mcp`, with no mailbox and no interactive logon.
6. Publish and circulate the final Phase 0 sign-off artifact before any implementation work begins.
✅ **Exit criteria:** Approved operations list, service account created, read/write boundary documented, sign-off obtained.
---
### Phase 1: Read-only identity deployment
**Objective:** Allow AI clients to observe identity state safely, without taking any action.
1. Configure the MCP server to run under the dedicated AD service account provisioned in Phase 0.
2. Implement the following read-only tools. Each must map **1:1 to an existing PowerShell query** and must not add new logic:
```powershell
identity.getUser(username) # Returns: enabled/disabled, OU, description, last logon
identity.getUserGroups(username) # Returns: all group memberships for a user
identity.getGroupMembers(groupName) # Returns: all members of a named group
identity.findStaleUsers(days) # Returns: users with no logon activity in N days
identity.getComputer(computerName) # Returns: device account, OU placement
```
3. Test each tool against known objects in a non-production OU before enabling it for AI client access.
4. Confirm audit logging is active — each MCP call must record: tool name, input parameters, result, and timestamp.
#### Phase 1 implementation steps
1. Deploy and harden the MCP host.
- Confirm network connectivity to domain controllers.
- Confirm DNS resolution and system time synchronization.
2. Configure runtime authentication.
- Run the MCP process under the dedicated service account.
- Confirm no fallback credentials are configured.
3. Validate functional parity.
- Run each MCP tool against an enabled user, disabled user, and known test OU.
- Compare MCP outputs to the equivalent manual PowerShell query output.
4. Validate logging integrity.
- Confirm logs capture tool name, parameters, timestamp, and result count.
- Confirm log retention matches security policy.
5. Restrict tool exposure.
- Expose tools only to approved AI clients.
- Do not expose tools directly to end users.
✅ **Exit criteria:** All five read-only tools deployed and verified. AI can answer identity questions the team already investigates manually — without taking action.
---
### Phase 2: Correlated identity insight
**Objective:** Connect identity data to device and process context by combining Identity MCP with other MCP servers.
1. Confirm the following MCP servers are operational (or plan their deployment in parallel):
- Intune MCP
- Inventory MCP
- Service Desk MCP (read-only)
2. Define and validate cross-system query patterns. Example queries enabled at this phase:
| Query | MCP servers involved |
| --- | --- |
| Which users still have VPN access but are no longer active? | Identity MCP |
| Which devices belong to disabled users but remain domain-joined? | Identity MCP + Intune MCP |
| Which onboarding tickets are missing required group assignments? | Identity MCP + Service Desk MCP |
3. Validate each cross-system query returns accurate results against real data before exposing it to end users.
✅ **Exit criteria:** Identity is used as enrichment context alongside at least one other MCP source. Cross-system queries support SOP enforcement without performing any automated actions.
---
### Phase 3: Controlled write actions
**Objective:** Introduce safe, reversible identity actions that are already defined in existing SOPs.
> **Warning:** Do not implement any write action that does not have a corresponding documented SOP step.
**Allowed write actions (initial scope):**
- Add or remove a user from a **non-privileged group**
- Update a user's description field (termination markers, per termination SOP)
- Move users or computers between **approved OUs**
**Explicitly excluded from Phase 3:**
- Account deletion
- Privileged group changes
- Password resets
- MFA configuration changes
**Guardrail model — every write action must follow this sequence without exception:**
```
1. AI proposes action
2. Human reviews and explicitly approves
3. MCP executes
4. Result logged (ticket update and/or audit log entry)
```
No silent execution is permitted at any point.
#### Phase 3 write-action enforcement mechanics
Every write-capable tool must implement the controls below:
1. **Pre-execution validation**
- Verify the target object exists.
- Verify the requested operation exists in the Approved Identity Operations List.
- Verify the target group or OU is on the approved target list.
2. **Human approval gate**
- Present object, current state, and proposed state.
- Require explicit human confirmation before execution.
3. **Execution boundaries**
- Execute exactly one identity change per invocation.
- Do not chain multiple write actions in one run.
4. **Post-execution verification**
- Re-query the object and confirm the expected state change is present.
5. **Audit completion**
- Record approver identity, change details, timestamp, and ticket reference.
✅ **Exit criteria:** Write actions deployed behind a human-approval gate. No action executes without an explicit approval step. Audit log entries confirmed for each write operation.
---
### Phase 4: Service Desk coupling
**Objective:** Tie every identity action to a work-tracking record, enforcing ticket-update requirements already present in existing SOPs.
1. Configure the MCP server to require a ticket ID for all write operations.
2. Implement a check that blocks action completion if no ticket reference is provided.
3. Configure automatic ticket updates on action completion to prevent "work done, ticket forgotten."
4. Validate the audit trail end-to-end: each identity change must be traceable from the originating ticket to the MCP call log entry.
✅ **Exit criteria:** Every identity action is tied to a ticket. Full audit trail confirmed from human request → MCP execution → ticket update.
---
## Post-installation actions
Once all phases are deployed, enforce the following controls on an ongoing basis.
### Identity controls
- The MCP service account must never be used interactively or by any other service.
- Apply the principle of least privilege per operation — do not pre-elevate permissions ahead of need.
- Review service account permissions each time a new write operation is added.
### Audit controls
- Every MCP call must log: tool name, input parameters, result, and timestamp.
- Logs must be retained and correlatable to the originating human prompt and ticket.
- Review audit logs regularly — weekly is recommended during initial rollout.
### Change control
- All MCP tool definitions must be version-controlled.
- Changes to tool definitions must go through the same review process as PowerShell script changes.
- Any change to an underlying SOP must trigger a review of the corresponding MCP tool definition.
---
## Troubleshooting / Escalation procedures
| Issue | Resolution |
| --- | --- |
| MCP server cannot connect to AD | Verify service account credentials and network access to the domain controller. Check firewall rules between the MCP host and AD. |
| Tool returns no results for a known user | Confirm the service account has *Read Directory Data* permission. Test the equivalent PowerShell query directly on the MCP host. |
| Write action executes without triggering an approval step | Immediately disable the write-action tool. Review the guardrail configuration. Do not re-enable until the approval gate is confirmed functional. |
| Audit log has missing entries | Halt all write-action operations until logging is restored and the gap is accounted for. |
| Cross-system query returns inconsistent data | Isolate to the specific MCP source (Identity, Intune, or Inventory). Test each source independently with known test data. |
| Phase 3 write operation blocked with no clear reason | Check that a valid ticket ID was provided. Confirm the target group or OU is on the approved list from Phase 0. |
**Important:** Identity MCP is explicitly designed to **not** do the following. If a request falls into this list, fulfil it through standard tooling — it is out of scope for Identity MCP:
- Replace ADUC or the Azure portal for direct identity management
- Auto-provision new user accounts
- Decide or modify identity policy
- Bypass approval workflows or change control
**Escalation contact:** Raise a ticket with the IT Automation team for issues outside the above resolutions.
### Troubleshooting decision flow
When an issue occurs:
1. Determine the failing phase.
- Phase 1 or 2 indicates a read-only failure domain.
- Phase 3 or 4 indicates a write workflow or enforcement failure domain.
2. For read-only failures:
- Run the equivalent manual PowerShell query.
- Compare manual and MCP outputs.
- Disable the affected tool if outputs diverge.
3. For write-related failures:
- Immediately disable the affected write tool.
- Do not auto-retry execution.
- Re-validate approval gate behavior before re-enabling.
4. For audit logging failures:
- Suspend all write actions.
- Resume only after log integrity is restored and verified.
Escalate only after isolating the failure to a specific tool or phase.
---
## References / Related documents
- [Active Directory — PowerShell procedures (OneNote)](https://wheelsinc.sharepoint.com/sites/WheelsITServiceDesk/_layouts/15/Doc.aspx?action=edit&mobileredirect=true&wdorigin=Sharepoint&DefaultItemOpen=1&sourcedoc={04cb4993-3d7c-4785-b67f-6a6afefdcaa8}&wd=target(/PowerShell.one/)&wdpartid={4d895098-550e-0b0c-194c-af7c0195f51e}{1}&wdsectionfileid={7ffa6051-4ff6-4039-96a0-8533c34d8ade})
- [User termination SOP (OneNote)](https://wheelsinc.sharepoint.com/sites/WheelsITServiceDesk/_layouts/15/Doc.aspx?action=edit&mobileredirect=true&wdorigin=Sharepoint&DefaultItemOpen=1&sourcedoc={04cb4993-3d7c-4785-b67f-6a6afefdcaa8}&wd=target(/User Termination.one/)&wdpartid={b2ba40a3-f389-4021-9ec5-54268ce102ab}{1}&wdsectionfileid={33ca8871-68c7-4218-a016-fca812102c86})
- [Onboarding — new account setup (Word)](https://wheelsinc.sharepoint.com/sites/WheelsITServiceDesk/_layouts/15/Doc.aspx?sourcedoc=%7B2594F0FC-A36C-40A2-A5E8-C227EE9ACC6F%7D&file=Onboarding%20Process%20-%20New%20account%20setup.docx&action=default&mobileredirect=true&DefaultItemOpen=1)
- [Service Desk documentation — new account setup (Word)](https://wheelsinc.sharepoint.com/sites/WheelsITDesksideServices/_layouts/15/Doc.aspx?sourcedoc=%7B8B3CF4B1-D9C1-4A6F-A5AA-99277B453783%7D&file=Latest%20Service%20Desk%20Documentation%20-%20New%20account%20setup.docx&action=default&mobileredirect=true&DefaultItemOpen=1)
- [Device image and setup SOP (Word)](https://wheelsinc.sharepoint.com/sites/WheelsITDesksideServices/_layouts/15/Doc.aspx?sourcedoc=%7B8BF1A3D1-C48A-4921-86FD-6A00AC9FE198%7D&file=Device%20Image%20and%20Setup%20SoP.docx&action=default&mobileredirect=true&DefaultItemOpen=1)
- [IT-SOP-009 — New device setup (PDF)](https://wheelsinc.sharepoint.com/sites/WheelsITDesksideServices/Shared%20Documents/General/SOPs/IT-SOP-009%20New%20Device%20Setup.pdf?web=1)
---
## Revision history
| Version | Date | Author | Description |
| --- | --- | --- | --- |
| v2 | 2026-03-11 | N. Castaldi | Added operational validation and enforcement steps from Additional Steps guidance |
| v1 | 2026-03-11 | N. Castaldi | Initial draft |
+155
View File
@@ -0,0 +1,155 @@
swagger: '2.0'
info:
title: Identity MCP Server
description: |
Active Directory identity MCP server exposing read-only user, group, and computer queries.
Phase 1 Tools (Point Lookups):
- get_user: Get user state (enabled/disabled, OU, description, last logon)
- search_users_by_name: Search users by name
- get_user_groups: Get all group memberships for a user
- get_group_members: Get all members of a named group
- find_stale_users: Get users with no logon activity in N days
- get_computer: Get computer account details including OU placement
Phase 2 Tools (Casual Userbase Exploration):
- query_users: Flexible filtered user queries with pagination (supports casual questions about disabled users, stale users, OU populations, group membership)
- count_users: Count users matching filter criteria without returning full records
- summarize_users: Grouped aggregates for leadership-style questions (e.g., users by department, by last logon bucket, by OU)
version: 2.0.0
contact:
name: IT Service Desk
email: itservicedesk@wheels.com
# REQUIRED: Replace with your actual hosted endpoint
# Example: identity-mcp.wheelsinc.com
host: your-identity-mcp-host.yourdomain.com
basePath: /
schemes:
- https
# SECURITY CONFIGURATION
# Uncomment and configure based on your deployment:
# Option 1: No authentication (internal network only)
security: []
# Option 2: API Key in header (recommended for Phase 1)
# securityDefinitions:
# apiKey:
# type: apiKey
# in: header
# name: X-API-Key
# description: API key for authenticating requests to Identity MCP server
# security:
# - apiKey: []
# Option 3: OAuth 2.0 (for Phase 3+ with user delegation)
# securityDefinitions:
# oauth2:
# type: oauth2
# flow: accessCode
# authorizationUrl: https://your-idp.com/authorize
# tokenUrl: https://your-idp.com/token
# scopes:
# identity.read: Read identity information
# identity.write: Modify identity information
# security:
# - oauth2: ['identity.read']
paths:
/mcp:
post:
summary: Identity MCP Streamable HTTP endpoint
description: |
MCP protocol endpoint for identity operations. Invokes tools defined in the Identity MCP server:
Phase 1 Tools (Point Lookups):
- get_user: Get user state (enabled/disabled, OU, description, last logon)
- search_users_by_name: Search users by name
- get_user_groups: Get all group memberships for a user
- get_group_members: Get all members of a named group
- find_stale_users: Get users with no logon activity in N days
- get_computer: Get computer account details including OU placement
Phase 2 Tools (Casual Userbase Exploration):
- query_users: Flexible filtered user queries with pagination supporting casual questions like "show me disabled users" or "find users who haven't logged in for 90 days"
- count_users: Count users matching filter criteria without returning full records for quick sizing questions
- summarize_users: Grouped aggregates for leadership-style questions like "which departments have the most stale users"
All Phase 2 tools support validated filter parameters:
- enabled (bool): Filter by account status
- name_contains (str): Display name search
- username_prefix (str): Username prefix filter
- ou_contains (str): OU path filter
- group_any (list[str]): Match users in any of these groups
- description_contains (str): Description search
- last_logon_before_days (int): Stale user filter
query_users additionally supports:
- fields: Projection (username, display_name, first_name, last_name, enabled, ou, description, last_logon_utc, when_created_utc, department, title, email)
- sort_by: Sort field (display_name, username, last_logon_utc, when_created_utc, department)
- sort_direction: asc or desc
- page_size: Results per page (1-200)
- cursor: Pagination cursor
summarize_users additionally supports:
- group_by: Grouping field (enabled, ou, department, title, created_month, last_logon_bucket)
- top: Max buckets to return (1-50)
operationId: InvokeIdentityMCP
x-ms-agentic-protocol: mcp-streamable-1.0
consumes:
- application/json
produces:
- application/json
parameters:
- name: body
in: body
description: MCP protocol request payload
required: true
schema:
type: object
description: MCP request following Model Context Protocol specification
responses:
'200':
description: Success - MCP protocol response
schema:
type: object
description: MCP response following Model Context Protocol specification
'400':
description: Bad request - invalid MCP payload
'401':
description: Unauthorized - invalid or missing API key
'500':
description: Internal server error - backend failure
definitions:
MCPRequest:
type: object
description: MCP protocol request structure (reference only)
properties:
jsonrpc:
type: string
example: "2.0"
method:
type: string
example: "tools/call"
params:
type: object
id:
type: string
MCPResponse:
type: object
description: MCP protocol response structure (reference only)
properties:
jsonrpc:
type: string
example: "2.0"
result:
type: object
id:
type: string
+476
View File
@@ -0,0 +1,476 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
# Import AD adapter for backend selection
try:
from ad_adapter import ActiveDirectoryIdentityBackend
except ImportError:
ActiveDirectoryIdentityBackend = None # type: ignore
# Type definitions for Phase 2 query contract
SortByField = Literal["display_name", "username", "last_logon_utc", "when_created_utc", "department"]
SortDirection = Literal["asc", "desc"]
GroupByField = Literal["enabled", "ou", "department", "title", "created_month", "last_logon_bucket"]
ALLOWED_USER_FIELDS = {
"username",
"display_name",
"first_name",
"last_name",
"enabled",
"ou",
"description",
"last_logon_utc",
"when_created_utc",
"department",
"title",
"email",
}
@dataclass
class UserRecord:
username: str
first_name: str
last_name: str
display_name: str
enabled: bool
ou: str
description: str
last_logon_utc: datetime
groups: list[str]
@dataclass
class ComputerRecord:
computer_name: str
ou: str
assigned_username: str | None
class IdentityBackend:
"""Backend interface for identity data providers.
Replace this in-memory implementation with approved AD/Entra integrations.
"""
async def get_user(self, username: str) -> dict[str, Any] | None:
raise NotImplementedError
async def search_users_by_name(
self, name_query: str, limit: int = 20
) -> list[dict[str, Any]]:
raise NotImplementedError
async def get_user_groups(self, username: str) -> list[str]:
raise NotImplementedError
async def get_group_members(self, group_name: str) -> list[str]:
raise NotImplementedError
async def find_stale_users(self, days: int) -> list[dict[str, Any]]:
raise NotImplementedError
async def get_computer(self, computer_name: str) -> dict[str, Any] | None:
raise NotImplementedError
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.
Returns:
dict with keys: items, next_cursor, page_size, total_estimate, applied_filter, warnings
"""
raise NotImplementedError
async def count_users(
self,
filter_params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Count users matching filter without returning full records.
Returns:
dict with keys: count, applied_filter
"""
raise NotImplementedError
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.
Returns:
dict with keys: buckets, total, applied_filter
"""
raise NotImplementedError
class InMemoryIdentityBackend(IdentityBackend):
"""Local-safe backend for initial MCP wiring and tool contract validation."""
def __init__(self) -> None:
now = datetime.now(tz=timezone.utc)
self._users: dict[str, UserRecord] = {
"jane.doe": UserRecord(
username="jane.doe",
first_name="Jane",
last_name="Doe",
display_name="Jane Doe",
enabled=True,
ou="OU=Users,DC=example,DC=local",
description="Service Desk",
last_logon_utc=now - timedelta(days=2),
groups=["GG-Global-VPN", "GG-ServiceDesk"],
),
"john.smith": UserRecord(
username="john.smith",
first_name="John",
last_name="Smith",
display_name="John Smith",
enabled=False,
ou="OU=DisabledUsers,DC=example,DC=local",
description="Terminated 2026-02-20",
last_logon_utc=now - timedelta(days=65),
groups=["GG-FormerEmployees"],
), "alice.tech": UserRecord(
username="alice.tech",
first_name="Alice",
last_name="Tech",
display_name="Alice Tech",
enabled=True,
ou="OU=IT,OU=Users,DC=example,DC=local",
description="IT Infrastructure",
last_logon_utc=now - timedelta(days=1),
groups=["GG-IT-Infrastructure", "GG-Global-VPN"],
),
"bob.sales": UserRecord(
username="bob.sales",
first_name="Bob",
last_name="Sales",
display_name="Bob Sales",
enabled=False,
ou="OU=DisabledUsers,DC=example,DC=local",
description="Inactive 2025-12-01",
last_logon_utc=now - timedelta(days=120),
groups=["GG-Sales-Disabled"],
), }
self._computers: dict[str, ComputerRecord] = {
"LT-1001": ComputerRecord(
computer_name="LT-1001",
ou="OU=Workstations,DC=example,DC=local",
assigned_username="jane.doe",
)
}
async def get_user(self, username: str) -> dict[str, Any] | None:
user = self._users.get(username.lower())
if user is None:
return None
return {
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"display_name": user.display_name,
"enabled": user.enabled,
"ou": user.ou,
"description": user.description,
"last_logon_utc": user.last_logon_utc.isoformat(),
}
async def search_users_by_name(
self, name_query: str, limit: int = 20
) -> list[dict[str, Any]]:
query = name_query.strip().lower()
if not query:
return []
max_results = max(1, min(limit, 100))
results: list[dict[str, Any]] = []
for user in self._users.values():
searchable = [user.first_name, user.last_name, user.display_name]
if any(query in value.lower() for value in searchable):
results.append(
{
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"display_name": user.display_name,
"enabled": user.enabled,
"ou": user.ou,
}
)
results = sorted(results, key=lambda row: row["display_name"].lower())
return results[:max_results]
async def get_user_groups(self, username: str) -> list[str]:
user = self._users.get(username.lower())
if user is None:
return []
return list(user.groups)
async def get_group_members(self, group_name: str) -> list[str]:
wanted = group_name.lower()
members: list[str] = []
for user in self._users.values():
if any(g.lower() == wanted for g in user.groups):
members.append(user.username)
return sorted(members)
async def find_stale_users(self, days: int) -> list[dict[str, Any]]:
if days < 0:
return []
cutoff = datetime.now(tz=timezone.utc) - timedelta(days=days)
results: list[dict[str, Any]] = []
for user in self._users.values():
if user.last_logon_utc < cutoff:
results.append(
{
"username": user.username,
"enabled": user.enabled,
"last_logon_utc": user.last_logon_utc.isoformat(),
}
)
return sorted(results, key=lambda row: row["username"])
async def get_computer(self, computer_name: str) -> dict[str, Any] | None:
computer = self._computers.get(computer_name.upper())
if computer is None:
return None
return {
"computer_name": computer.computer_name,
"ou": computer.ou,
"assigned_username": computer.assigned_username,
}
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."""
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)}"],
}
# Filter users
matching_users: list[UserRecord] = []
for user in self._users.values():
if not self._user_matches_filter(user, filter_params):
continue
matching_users.append(user)
# Sort
sort_key_map = {
"display_name": lambda u: u.display_name.lower(),
"username": lambda u: u.username.lower(),
"last_logon_utc": lambda u: u.last_logon_utc,
}
sort_func = sort_key_map.get(sort_by, lambda u: u.display_name.lower())
matching_users = sorted(
matching_users,
key=sort_func,
reverse=(sort_direction == "desc"),
)
# Paginate
clamped_size = min(max(1, page_size), 200)
start_index = 0
if cursor:
try:
start_index = int(cursor)
except ValueError:
pass
page_users = matching_users[start_index:start_index + clamped_size]
next_cursor = None
if start_index + clamped_size < len(matching_users):
next_cursor = str(start_index + clamped_size)
# Project fields
items = []
for user in page_users:
item: dict[str, Any] = {}
if "username" in fields:
item["username"] = user.username
if "display_name" in fields:
item["display_name"] = user.display_name
if "first_name" in fields:
item["first_name"] = user.first_name
if "last_name" in fields:
item["last_name"] = user.last_name
if "enabled" in fields:
item["enabled"] = user.enabled
if "ou" in fields:
item["ou"] = user.ou
if "description" in fields:
item["description"] = user.description
if "last_logon_utc" in fields:
item["last_logon_utc"] = user.last_logon_utc.isoformat()
if "department" in fields:
item["department"] = ""
if "title" in fields:
item["title"] = ""
if "email" in fields:
item["email"] = f"{user.username}@example.local"
if "when_created_utc" in fields:
item["when_created_utc"] = ""
items.append(item)
return {
"items": items,
"next_cursor": next_cursor,
"page_size": clamped_size,
"total_estimate": len(matching_users),
"applied_filter": filter_params,
"warnings": [],
}
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 {}
count = 0
for user in self._users.values():
if self._user_matches_filter(user, filter_params):
count += 1
return {
"count": count,
"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 {}
# Filter users
matching_users: list[UserRecord] = []
for user in self._users.values():
if self._user_matches_filter(user, filter_params):
matching_users.append(user)
# Group
bucket_counts: dict[str, int] = {}
for user in matching_users:
key = self._get_group_key(user, group_by)
bucket_counts[key] = bucket_counts.get(key, 0) + 1
# Sort by count descending and take top N
sorted_buckets = sorted(
bucket_counts.items(),
key=lambda x: x[1],
reverse=True,
)
clamped_top = min(max(1, top), 50)
top_buckets = sorted_buckets[:clamped_top]
buckets = [{"key": key, "count": count} for key, count in top_buckets]
return {
"buckets": buckets,
"total": len(matching_users),
"applied_filter": filter_params,
}
def _user_matches_filter(self, user: UserRecord, filter_params: dict[str, Any]) -> bool:
"""Check if user matches all filter criteria."""
if "enabled" in filter_params:
if user.enabled != filter_params["enabled"]:
return False
if "name_contains" in filter_params:
query = filter_params["name_contains"].lower()
if query not in user.display_name.lower():
return False
if "username_prefix" in filter_params:
prefix = filter_params["username_prefix"].lower()
if not user.username.lower().startswith(prefix):
return False
if "ou_contains" in filter_params:
ou_query = filter_params["ou_contains"].lower()
if ou_query not in user.ou.lower():
return False
if "description_contains" in filter_params:
desc_query = filter_params["description_contains"].lower()
if desc_query not in user.description.lower():
return False
if "last_logon_before_days" in filter_params:
days = filter_params["last_logon_before_days"]
cutoff = datetime.now(tz=timezone.utc) - timedelta(days=days)
if user.last_logon_utc >= cutoff:
return False
if "group_any" in filter_params:
wanted_groups = {g.lower() for g in filter_params["group_any"]}
user_groups = {g.lower() for g in user.groups}
if not wanted_groups.intersection(user_groups):
return False
return True
def _get_group_key(self, user: UserRecord, group_by: str) -> str:
"""Get grouping key for a user."""
if group_by == "enabled":
return "Enabled" if user.enabled else "Disabled"
if group_by == "ou":
return user.ou
if group_by == "department":
return "Unknown"
if group_by == "title":
return "Unknown"
if group_by == "created_month":
return "Unknown"
if group_by == "last_logon_bucket":
days_ago = (datetime.now(tz=timezone.utc) - user.last_logon_utc).days
if days_ago < 7:
return "Last 7 days"
elif days_ago < 30:
return "Last 30 days"
elif days_ago < 90:
return "Last 90 days"
else:
return "90+ days"
return "Unknown"
+534
View File
@@ -0,0 +1,534 @@
from __future__ import annotations
import logging
import os
import sys
from typing import Any
from mcp.server.fastmcp import FastMCP
from identity_backend import IdentityBackend, InMemoryIdentityBackend
mcp = FastMCP("identity")
# Backend selection via environment variable
# Set IDENTITY_BACKEND=ad to use Active Directory adapter
# Set AD_USERNAME and AD_PASSWORD for explicit credentials (test only)
backend_type = os.getenv("IDENTITY_BACKEND", "memory").lower()
if backend_type == "ad":
from ad_adapter import ActiveDirectoryIdentityBackend
ad_username = os.getenv("AD_USERNAME")
ad_password = os.getenv("AD_PASSWORD")
timeout = float(os.getenv("AD_TIMEOUT", "30.0"))
backend: IdentityBackend = ActiveDirectoryIdentityBackend(
username=ad_username,
password=ad_password,
timeout_seconds=timeout,
)
logging.getLogger("identity-mcp").info(
"Using Active Directory backend with %s",
"explicit credentials" if ad_username else "process context",
)
else:
backend: IdentityBackend = InMemoryIdentityBackend()
logging.getLogger("identity-mcp").info("Using in-memory backend (safe mode)")
# STDIO MCP servers must avoid stdout logging to prevent JSON-RPC corruption.
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger("identity-mcp")
def _audit(tool: str, params: dict[str, Any], result: Any) -> None:
result_type = type(result).__name__
if isinstance(result, list):
result_size = len(result)
elif isinstance(result, dict):
result_size = len(result.keys())
else:
result_size = 0
logger.info(
"tool=%s params=%s result_type=%s result_size=%s",
tool,
params,
result_type,
result_size,
)
@mcp.tool()
async def get_user(username: str) -> dict[str, Any] | str:
"""Get user state for a username.
Returns enabled/disabled, OU, description, and last logon.
"""
result = await backend.get_user(username)
if result is None:
message = "User not found."
_audit("get_user", {"username": username}, message)
return message
_audit("get_user", {"username": username}, result)
return result
@mcp.tool()
async def search_users_by_name(name_query: str, limit: int = 20) -> list[dict[str, Any]] | str:
"""Search users by first name, last name, or full display name."""
if not name_query.strip():
message = "name_query must not be empty"
_audit(
"search_users_by_name",
{"name_query": name_query, "limit": limit},
message,
)
return message
if limit < 1:
message = "limit must be >= 1"
_audit(
"search_users_by_name",
{"name_query": name_query, "limit": limit},
message,
)
return message
clamped_limit = min(limit, 100)
result = await backend.search_users_by_name(name_query=name_query, limit=clamped_limit)
_audit(
"search_users_by_name",
{"name_query": name_query, "limit": clamped_limit},
result,
)
return result
@mcp.tool()
async def get_user_groups(username: str) -> list[str]:
"""Get all group memberships for a user."""
result = await backend.get_user_groups(username)
_audit("get_user_groups", {"username": username}, result)
return result
@mcp.tool()
async def get_group_members(group_name: str) -> list[str]:
"""Get all members of a named group."""
result = await backend.get_group_members(group_name)
_audit("get_group_members", {"group_name": group_name}, result)
return result
@mcp.tool()
async def find_stale_users(days: int) -> list[dict[str, Any]] | str:
"""Get users with no logon activity in N days."""
if days < 0:
message = "days must be >= 0"
_audit("find_stale_users", {"days": days}, message)
return message
result = await backend.find_stale_users(days)
_audit("find_stale_users", {"days": days}, result)
return result
@mcp.tool()
async def get_computer(computer_name: str) -> dict[str, Any] | str:
"""Get computer account details including OU placement."""
result = await backend.get_computer(computer_name)
if result is None:
message = "Computer not found."
_audit("get_computer", {"computer_name": computer_name}, message)
return message
_audit("get_computer", {"computer_name": computer_name}, result)
return result
@mcp.tool()
async def query_users(
enabled: bool | None = None,
name_contains: str | None = None,
username_prefix: str | None = None,
ou_contains: str | None = None,
group_any: list[str] | None = None,
description_contains: str | None = None,
last_logon_before_days: int | 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 for casual userbase exploration.
Use this for questions like:
- "Show me all disabled users"
- "Find users in the ServiceDesk OU"
- "Which users haven't logged in for 90 days?"
- "List users with VPN access"
Args:
enabled: Filter by account enabled/disabled state
name_contains: Search display names containing this string (min 2 chars)
username_prefix: Filter usernames starting with this prefix (min 1 char)
ou_contains: Filter by OU path containing this string (min 2 chars)
group_any: List of groups - match users in ANY of these groups (1-10 groups)
description_contains: Search descriptions containing this string (min 2 chars)
last_logon_before_days: Filter users who last logged in more than N days ago (0-3650)
fields: List of fields to return (default: all allowed fields)
sort_by: Field to sort by (display_name, username, last_logon_utc, when_created_utc, department)
sort_direction: Sort direction (asc or desc)
page_size: Results per page (1-200, default 50)
cursor: Pagination cursor from previous response
Returns:
dict with items, next_cursor, page_size, total_estimate, applied_filter, warnings
"""
# Build filter params from provided arguments
filter_params: dict[str, Any] = {}
if enabled is not None:
filter_params["enabled"] = enabled
if name_contains is not None:
if len(name_contains.strip()) < 2:
return {
"items": [],
"next_cursor": None,
"page_size": page_size,
"total_estimate": 0,
"applied_filter": {},
"warnings": ["name_contains must be at least 2 characters"],
}
filter_params["name_contains"] = name_contains.strip()
if username_prefix is not None:
if len(username_prefix.strip()) < 1:
return {
"items": [],
"next_cursor": None,
"page_size": page_size,
"total_estimate": 0,
"applied_filter": {},
"warnings": ["username_prefix must be at least 1 character"],
}
filter_params["username_prefix"] = username_prefix.strip()
if ou_contains is not None:
if len(ou_contains.strip()) < 2:
return {
"items": [],
"next_cursor": None,
"page_size": page_size,
"total_estimate": 0,
"applied_filter": {},
"warnings": ["ou_contains must be at least 2 characters"],
}
filter_params["ou_contains"] = ou_contains.strip()
if group_any is not None:
if not isinstance(group_any, list) or len(group_any) < 1 or len (group_any) > 10:
return {
"items": [],
"next_cursor": None,
"page_size": page_size,
"total_estimate": 0,
"applied_filter": {},
"warnings": ["group_any must be a list of 1-10 group names"],
}
filter_params["group_any"] = group_any
if description_contains is not None:
if len(description_contains.strip()) < 2:
return {
"items": [],
"next_cursor": None,
"page_size": page_size,
"total_estimate": 0,
"applied_filter": {},
"warnings": ["description_contains must be at least 2 characters"],
}
filter_params["description_contains"] = description_contains.strip()
if last_logon_before_days is not None:
if last_logon_before_days < 0 or last_logon_before_days > 3650:
return {
"items": [],
"next_cursor": None,
"page_size": page_size,
"total_estimate": 0,
"applied_filter": {},
"warnings": ["last_logon_before_days must be between 0 and 3650"],
}
filter_params["last_logon_before_days"] = last_logon_before_days
# Validate sort options
valid_sort_by = ["display_name", "username", "last_logon_utc", "when_created_utc", "department"]
if sort_by not in valid_sort_by:
return {
"items": [],
"next_cursor": None,
"page_size": page_size,
"total_estimate": 0,
"applied_filter": filter_params,
"warnings": [f"Invalid sort_by. Must be one of: {', '.join(valid_sort_by)}"],
}
if sort_direction not in ["asc", "desc"]:
return {
"items": [],
"next_cursor": None,
"page_size": page_size,
"total_estimate": 0,
"applied_filter": filter_params,
"warnings": ["sort_direction must be 'asc' or 'desc'"],
}
result = await backend.query_users(
filter_params=filter_params,
fields=fields,
sort_by=sort_by,
sort_direction=sort_direction,
page_size=page_size,
cursor=cursor,
)
_audit("query_users", {"filter": filter_params, "page_size": page_size}, result)
return result
@mcp.tool()
async def count_users(
enabled: bool | None = None,
name_contains: str | None = None,
username_prefix: str | None = None,
ou_contains: str | None = None,
group_any: list[str] | None = None,
description_contains: str | None = None,
last_logon_before_days: int | None = None,
) -> dict[str, Any]:
"""Count users matching filter criteria without returning full records.
Use this for quick sizing questions like:
- "How many disabled users are there?"
- "How many users are in the IT department OU?"
- "How many users haven't logged in for 90 days?"
Args:
enabled: Filter by account enabled/disabled state
name_contains: Search display names containing this string (min 2 chars)
username_prefix: Filter usernames starting with this prefix (min 1 char)
ou_contains: Filter by OU path containing this string (min 2 chars)
group_any: List of groups - match users in ANY of these groups (1-10 groups)
description_contains: Search descriptions containing this string (min 2 chars)
last_logon_before_days: Filter users who last logged in more than N days ago (0-3650)
Returns:
dict with count and applied_filter
"""
# Build filter params (same validation as query_users)
filter_params: dict[str, Any] = {}
warnings: list[str] = []
if enabled is not None:
filter_params["enabled"] = enabled
if name_contains is not None:
if len(name_contains.strip()) < 2:
warnings.append("name_contains must be at least 2 characters")
else:
filter_params["name_contains"] = name_contains.strip()
if username_prefix is not None:
if len(username_prefix.strip()) < 1:
warnings.append("username_prefix must be at least 1 character")
else:
filter_params["username_prefix"] = username_prefix.strip()
if ou_contains is not None:
if len(ou_contains.strip()) < 2:
warnings.append("ou_contains must be at least 2 characters")
else:
filter_params["ou_contains"] = ou_contains.strip()
if group_any is not None:
if not isinstance(group_any, list) or len(group_any) < 1 or len(group_any) > 10:
warnings.append("group_any must be a list of 1-10 group names")
else:
filter_params["group_any"] = group_any
if description_contains is not None:
if len(description_contains.strip()) < 2:
warnings.append("description_contains must be at least 2 characters")
else:
filter_params["description_contains"] = description_contains.strip()
if last_logon_before_days is not None:
if last_logon_before_days < 0 or last_logon_before_days > 3650:
warnings.append("last_logon_before_days must be between 0 and 3650")
else:
filter_params["last_logon_before_days"] = last_logon_before_days
if warnings:
result = {
"count": 0,
"applied_filter": filter_params,
"warnings": warnings,
}
_audit("count_users", {"filter": filter_params}, result)
return result
result = await backend.count_users(filter_params=filter_params)
_audit("count_users", {"filter": filter_params}, result)
return result
@mcp.tool()
async def summarize_users(
group_by: str = "enabled",
top: int = 20,
enabled: bool | None = None,
name_contains: str | None = None,
username_prefix: str | None = None,
ou_contains: str | None = None,
group_any: list[str] | None = None,
description_contains: str | None = None,
last_logon_before_days: int | None = None,
) -> dict[str, Any]:
"""Return grouped aggregates for users matching filter criteria.
Use this for leadership-style questions like:
- "Which departments have the most stale users?"
- "Show me user distribution by OU"
- "How many users per enabled/disabled status?"
- "What's the breakdown of users by last logon activity?"
Args:
group_by: Field to group by (enabled, ou, department, title, created_month, last_logon_bucket)
top: Maximum number of buckets to return (1-50, default 20)
enabled: Filter by account enabled/disabled state
name_contains: Search display names containing this string (min 2 chars)
username_prefix: Filter usernames starting with this prefix (min 1 char)
ou_contains: Filter by OU path containing this string (min 2 chars)
group_any: List of groups - match users in ANY of these groups (1-10 groups)
description_contains: Search descriptions containing this string (min 2 chars)
last_logon_before_days: Filter users who last logged in more than N days ago (0-3650)
Returns:
dict with buckets (array of {key, count}), total, and applied_filter
"""
# Validate group_by
valid_group_by = ["enabled", "ou", "department", "title", "created_month", "last_logon_bucket"]
if group_by not in valid_group_by:
return {
"buckets": [],
"total": 0,
"applied_filter": {},
"warnings": [f"Invalid group_by. Must be one of: {', '.join(valid_group_by)}"],
}
# Validate top
if top < 1 or top > 50:
return {
"buckets": [],
"total": 0,
"applied_filter": {},
"warnings": ["top must be between 1 and 50"],
}
# Build filter params (same validation as query_users)
filter_params: dict[str, Any] = {}
warnings: list[str] = []
if enabled is not None:
filter_params["enabled"] = enabled
if name_contains is not None:
if len(name_contains.strip()) < 2:
warnings.append("name_contains must be at least 2 characters")
else:
filter_params["name_contains"] = name_contains.strip()
if username_prefix is not None:
if len(username_prefix.strip()) < 1:
warnings.append("username_prefix must be at least 1 character")
else:
filter_params["username_prefix"] = username_prefix.strip()
if ou_contains is not None:
if len(ou_contains.strip()) < 2:
warnings.append("ou_contains must be at least 2 characters")
else:
filter_params["ou_contains"] = ou_contains.strip()
if group_any is not None:
if not isinstance(group_any, list) or len(group_any) < 1 or len(group_any) > 10:
warnings.append("group_any must be a list of 1-10 group names")
else:
filter_params["group_any"] = group_any
if description_contains is not None:
if len(description_contains.strip()) < 2:
warnings.append("description_contains must be at least 2 characters")
else:
filter_params["description_contains"] = description_contains.strip()
if last_logon_before_days is not None:
if last_logon_before_days < 0 or last_logon_before_days > 3650:
warnings.append("last_logon_before_days must be between 0 and 3650")
else:
filter_params["last_logon_before_days"] = last_logon_before_days
if warnings:
result = {
"buckets": [],
"total": 0,
"applied_filter": filter_params,
"warnings": warnings,
}
_audit("summarize_users", {"filter": filter_params, "group_by": group_by}, result)
return result
result = await backend.summarize_users(
filter_params=filter_params,
group_by=group_by,
top=top,
)
_audit("summarize_users", {"filter": filter_params, "group_by": group_by}, result)
return result
def main() -> None:
"""Run MCP server with transport determined by environment variable.
Environment variables:
- MCP_TRANSPORT: "stdio" (default) or "streamable" for HTTP
- MCP_HOST: Host to bind to (default: 0.0.0.0 for streamable)
- MCP_PORT: Port to bind to (default: 8000 for streamable)
"""
transport = os.getenv("MCP_TRANSPORT", "stdio").lower()
if transport == "streamable":
# Streamable HTTP transport for Copilot Studio integration
host = os.getenv("MCP_HOST", "0.0.0.0")
port = int(os.getenv("MCP_PORT", "8000"))
logger.info(
"Starting Identity MCP server with streamable HTTP transport on %s:%d",
host,
port,
)
# Note: FastMCP with streamable transport requires mcp[server] extras
# Install with: pip install "mcp[server]" or uv pip install "mcp[server]"
mcp.run(transport="streamable", host=host, port=port)
else:
# STDIO transport for local testing and VS Code integration
logger.info("Starting Identity MCP server with stdio transport")
mcp.run(transport="stdio")
if __name__ == "__main__":
main()
+133
View File
@@ -0,0 +1,133 @@
# Identity MCP implementation kickoff (Phase 1)
## Purpose
This document starts implementation of an Identity MCP server with a read-only baseline.
## What is implemented
- Python MCP server scaffold using FastMCP
- Read-only tool exposed: `get_user`
- Read-only tool exposed: `get_user_groups`
- Read-only tool exposed: `get_group_members`
- Read-only tool exposed: `find_stale_users`
- Read-only tool exposed: `get_computer`
- In-memory backend for local contract testing
- Active Directory backend using PowerShell subprocess wrappers
- Environment-based backend selection (memory vs AD)
- STDERR-only logging for STDIO transport safety
- Basic audit log entries for tool calls
- Unit tests and integration smoke tests
## Files
- `identity_mcp_server.py`: FastMCP server, tool definitions, logging, backend selection, and run entrypoint
- `identity_backend.py`: backend interface and in-memory implementation
- `ad_adapter.py`: Active Directory backend using PowerShell Get-AD* cmdlets
- `tests/test_ad_adapter.py`: unit tests for AD adapter output parsing and error handling
- `tests/test_integration.py`: integration smoke tests against non-production AD
- `pyproject.toml`: project metadata, dependency pin for MCP SDK, and CLI entrypoint
- `.gitignore`: excludes Python bytecode, test cache, and local virtual environment artifacts
## How to run
### Using in-memory backend (default, safe mode)
```bash
uv run identity_mcp_server.py
```
### Using Active Directory backend
Set environment variables before running:
```bash
# Test environment with explicit credentials
export IDENTITY_BACKEND=ad
export AD_USERNAME=your_test_username
export AD_PASSWORD=your_test_password
uv run identity_mcp_server.py
```
```bash
# Production environment with service account context
export IDENTITY_BACKEND=ad
# Service account runs process, no explicit credentials needed
uv run identity_mcp_server.py
```
### Running tests
```bash
# Install test dependencies
uv pip install -e ".[test]"
# Run unit tests only (no AD connection required)
pytest tests/test_ad_adapter.py -v
# Run integration smoke tests (requires AD credentials)
export AD_TEST_USERNAME=your_test_username
export AD_TEST_PASSWORD=your_test_password
export AD_TEST_USER=known_test_user
export AD_TEST_GROUP=known_test_group
export AD_TEST_COMPUTER=known_test_computer
pytest tests/test_integration.py -v
# Run all tests
pytest tests/ -v
```
## Active Directory adapter details
### Query mapping
- `get_user`: Uses `Get-ADUser` with samAccountName filter, retrieves Enabled, DistinguishedName, Description, lastLogonTimestamp
- `get_user_groups`: Uses `Get-ADUser` with MemberOf property, resolves group names
- `get_group_members`: Uses `Get-ADGroup` + `Get-ADGroupMember`, filters user objects only
- `find_stale_users`: Uses `Get-ADUser` with lastLogonTimestamp cutoff (FileTime comparison)
- `get_computer`: Uses `Get-ADComputer`, returns OU and null assigned_username (Phase 1)
### Authentication model
- Test environments: explicit username/password via environment variables
- Production: process runs as dedicated service account, no credentials in code
- Service account requires Read Directory Data permission only (no write permissions)
### Error handling
All backend failures (timeout, auth denied, permission denied) return:
- `None` for get_user and get_computer (tool wrapper converts to "User not found" or "Computer not found")
- Empty list `[]` for get_user_groups, get_group_members, find_stale_users
### Timeouts
Default: 30 seconds per query. Override with `AD_TIMEOUT` environment variable.
## Known gaps (deferred to later phases)
- Ticket-ID enforcement for write actions (Phase 3)
- PII redaction in audit logs (toggle ready, not enabled by default)
- Entra ID/Azure AD integration (separate adapter)
- Production service account provisioning (infrastructure task)
- Host/client configuration details depend on your selected MCP client
## Verification checklist
Before deploying to production:
- [ ] Unit tests pass: `pytest tests/test_ad_adapter.py -v`
- [ ] Integration smoke tests pass against non-production AD: `pytest tests/test_integration.py -v`
- [ ] Service account provisioned with Read Directory Data only
- [ ] MCP tool responses match expected contract shapes
- [ ] Error messages are friendly strings (not exceptions or technical errors)
- [ ] Stale-user logic confirmed using lastLogonTimestamp
- [ ] Computer assigned_username returns null in Phase 1
- [ ] Backend selection tested: memory mode and AD mode both run successfully
## Next implementation slice
1. Add structured PII redaction toggle for audit logs.
2. Add phase gate config that blocks write tools entirely until explicitly enabled (Phase 3 prep).
3. Implement Entra ID/Azure AD adapter (separate backend).
4. Add canary/rollback mechanism for backend switching.
+25
View File
@@ -0,0 +1,25 @@
[project]
name = "identity-mcp-server"
version = "0.1.0"
description = "Identity MCP server baseline for phased rollout"
readme = "implementation-guide.md"
requires-python = ">=3.10"
dependencies = [
"mcp[cli]>=1.2.0",
]
[project.optional-dependencies]
test = [
"pytest>=7.4.0",
"pytest-asyncio>=0.21.0",
]
[project.scripts]
identity-mcp = "identity_mcp_server:main"
[tool.setuptools]
py-modules = ["identity_mcp_server", "identity_backend", "ad_adapter"]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
+1
View File
@@ -0,0 +1 @@
# Test configuration for Identity MCP
+284
View File
@@ -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
+138
View File
@@ -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 == []