Added current MCP working files
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
*.pyc
|
||||
.coverage
|
||||
htmlcov/
|
||||
@@ -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.
|
||||
|
||||
***
|
||||
@@ -0,0 +1,910 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("identity-mcp.ad-adapter")
|
||||
|
||||
|
||||
class ActiveDirectoryIdentityBackend:
|
||||
"""PowerShell-based Active Directory backend for read-only identity queries.
|
||||
|
||||
Uses subprocess calls to approved Get-AD* cmdlets with deterministic output
|
||||
parsing. All methods maintain the same async contract and return shapes as
|
||||
IdentityBackend interface.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
timeout_seconds: float = 30.0,
|
||||
) -> None:
|
||||
"""Initialize AD adapter with optional explicit credentials for testing.
|
||||
|
||||
Args:
|
||||
username: Optional explicit username for test environments only
|
||||
password: Optional explicit password for test environments only
|
||||
timeout_seconds: Per-query timeout limit
|
||||
"""
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.timeout = timeout_seconds
|
||||
|
||||
@staticmethod
|
||||
def _escape_ps_single_quoted(value: str) -> str:
|
||||
return value.replace("'", "''")
|
||||
|
||||
async def _run_powershell(
|
||||
self, command: str, params: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Execute PowerShell command and return parsed output or error.
|
||||
|
||||
Args:
|
||||
command: PowerShell command string
|
||||
params: Optional parameters for logging context
|
||||
|
||||
Returns:
|
||||
dict with "success" (bool), "data" (Any), "error" (str | None)
|
||||
"""
|
||||
# Build credential block if explicit auth is configured
|
||||
cred_block = ""
|
||||
if self.username and self.password:
|
||||
# Escape single quotes in password for PowerShell
|
||||
escaped_password = self.password.replace("'", "''")
|
||||
# Use single quotes to avoid variable expansion and special char issues
|
||||
cred_block = f"$secpass = ConvertTo-SecureString '{escaped_password}' -AsPlainText -Force; $cred = New-Object System.Management.Automation.PSCredential('{self.username}', $secpass); "
|
||||
|
||||
full_command = cred_block + command
|
||||
|
||||
try:
|
||||
# Use create_subprocess_exec to avoid shell $ interpretation
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
'powershell',
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
full_command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(), timeout=self.timeout
|
||||
)
|
||||
|
||||
stdout_text = stdout.decode("utf-8", errors="replace").strip()
|
||||
stderr_text = stderr.decode("utf-8", errors="replace").strip()
|
||||
|
||||
if process.returncode != 0:
|
||||
logger.warning(
|
||||
"PowerShell command failed: returncode=%d stderr=%s",
|
||||
process.returncode,
|
||||
stderr_text,
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": f"Command failed: {stderr_text[:200]}",
|
||||
}
|
||||
|
||||
if stderr_text:
|
||||
logger.debug("PowerShell stderr: %s", stderr_text)
|
||||
|
||||
return {"success": True, "data": stdout_text, "error": None}
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("PowerShell command timeout after %s seconds", self.timeout)
|
||||
return {
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": f"Query timeout after {self.timeout} seconds",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("PowerShell execution error: %s", str(e))
|
||||
return {
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": f"Execution error: {str(e)[:200]}",
|
||||
}
|
||||
|
||||
async def get_user(self, username: str) -> dict[str, Any] | None:
|
||||
"""Get user state for a username.
|
||||
|
||||
Returns enabled/disabled, OU, description, and last logon timestamp.
|
||||
"""
|
||||
# Build PowerShell command using JSON output for reliable parsing
|
||||
escaped_username = self._escape_ps_single_quoted(username)
|
||||
command = f"""
|
||||
$user = Get-ADUser -Filter "samAccountName -eq '{escaped_username}'" -Properties GivenName,Surname,DisplayName,Enabled,DistinguishedName,Description,lastLogonTimestamp -ErrorAction Stop
|
||||
if ($user) {{
|
||||
$lastLogon = if ($user.lastLogonTimestamp) {{ [DateTime]::FromFileTime($user.lastLogonTimestamp).ToUniversalTime().ToString('o') }} else {{ $null }}
|
||||
@{{
|
||||
username = $user.SamAccountName
|
||||
first_name = if ($user.GivenName) {{ $user.GivenName }} else {{ '' }}
|
||||
last_name = if ($user.Surname) {{ $user.Surname }} else {{ '' }}
|
||||
display_name = if ($user.DisplayName) {{ $user.DisplayName }} else {{ '' }}
|
||||
enabled = $user.Enabled
|
||||
ou = $user.DistinguishedName
|
||||
description = if ($user.Description) {{ $user.Description }} else {{ '' }}
|
||||
last_logon_utc = $lastLogon
|
||||
}} | ConvertTo-Json -Compress
|
||||
}}
|
||||
"""
|
||||
|
||||
result = await self._run_powershell(command, {"username": username})
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning(
|
||||
"get_user failed for username=%s: %s", username, result["error"]
|
||||
)
|
||||
return None
|
||||
|
||||
if not result["data"]:
|
||||
return None
|
||||
|
||||
try:
|
||||
user_data = json.loads(result["data"])
|
||||
return {
|
||||
"username": user_data["username"],
|
||||
"first_name": user_data.get("first_name", "") or "",
|
||||
"last_name": user_data.get("last_name", "") or "",
|
||||
"display_name": user_data.get("display_name", "") or "",
|
||||
"enabled": user_data["enabled"],
|
||||
"ou": user_data["ou"],
|
||||
"description": user_data["description"] or "",
|
||||
"last_logon_utc": user_data["last_logon_utc"] or "",
|
||||
}
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
logger.error("Failed to parse user data: %s", str(e))
|
||||
return None
|
||||
|
||||
async def search_users_by_name(
|
||||
self, name_query: str, limit: int = 20
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search users by first name, last name, or full display name."""
|
||||
query = name_query.strip()
|
||||
if not query:
|
||||
return []
|
||||
|
||||
max_results = max(1, min(limit, 100))
|
||||
escaped_query = self._escape_ps_single_quoted(query)
|
||||
|
||||
command = f"""
|
||||
$query = '{escaped_query}'
|
||||
$tokens = @($query.Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries))
|
||||
|
||||
if ($tokens.Count -ge 2) {{
|
||||
$first = $tokens[0]
|
||||
$last = $tokens[1]
|
||||
$adFilter = "(givenName -like '$($first)*' -and surname -like '$($last)*') -or (displayName -like '$query*')"
|
||||
}} else {{
|
||||
$adFilter = "givenName -like '$query*' -or surname -like '$query*' -or displayName -like '$query*'"
|
||||
}}
|
||||
|
||||
$users = @(Get-ADUser -Filter $adFilter -Properties GivenName,Surname,DisplayName,Enabled,DistinguishedName -ErrorAction Stop |
|
||||
Sort-Object DisplayName |
|
||||
Select-Object -First {max_results} |
|
||||
ForEach-Object {{
|
||||
@{{
|
||||
username = $_.SamAccountName
|
||||
first_name = if ($_.GivenName) {{ $_.GivenName }} else {{ '' }}
|
||||
last_name = if ($_.Surname) {{ $_.Surname }} else {{ '' }}
|
||||
display_name = if ($_.DisplayName) {{ $_.DisplayName }} else {{ '' }}
|
||||
enabled = $_.Enabled
|
||||
ou = $_.DistinguishedName
|
||||
}}
|
||||
}})
|
||||
|
||||
$users | ConvertTo-Json -Compress
|
||||
"""
|
||||
|
||||
result = await self._run_powershell(
|
||||
command,
|
||||
{"name_query": name_query, "limit": max_results},
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning(
|
||||
"search_users_by_name failed for query=%s: %s",
|
||||
name_query,
|
||||
result["error"],
|
||||
)
|
||||
return []
|
||||
|
||||
if not result["data"]:
|
||||
return []
|
||||
|
||||
try:
|
||||
users = json.loads(result["data"])
|
||||
if isinstance(users, dict):
|
||||
users = [users]
|
||||
if not isinstance(users, list):
|
||||
return []
|
||||
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
"username": user.get("username", "") or "",
|
||||
"first_name": user.get("first_name", "") or "",
|
||||
"last_name": user.get("last_name", "") or "",
|
||||
"display_name": user.get("display_name", "") or "",
|
||||
"enabled": bool(user.get("enabled", False)),
|
||||
"ou": user.get("ou", "") or "",
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to parse search user data: %s", str(e))
|
||||
return []
|
||||
|
||||
async def get_user_groups(self, username: str) -> list[str]:
|
||||
"""Get all group memberships for a user."""
|
||||
escaped_username = self._escape_ps_single_quoted(username)
|
||||
command = f"""
|
||||
$user = Get-ADUser -Filter "samAccountName -eq '{escaped_username}'" -Properties MemberOf -ErrorAction Stop
|
||||
if ($user -and $user.MemberOf) {{
|
||||
$groups = @($user.MemberOf | ForEach-Object {{
|
||||
$group = Get-ADGroup $_ -ErrorAction SilentlyContinue
|
||||
if ($group) {{ $group.Name }}
|
||||
}})
|
||||
$groups | ConvertTo-Json -Compress
|
||||
}} else {{
|
||||
@() | ConvertTo-Json -Compress
|
||||
}}
|
||||
"""
|
||||
|
||||
result = await self._run_powershell(command, {"username": username})
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning(
|
||||
"get_user_groups failed for username=%s: %s",
|
||||
username,
|
||||
result["error"],
|
||||
)
|
||||
return []
|
||||
|
||||
if not result["data"]:
|
||||
return []
|
||||
|
||||
try:
|
||||
groups = json.loads(result["data"])
|
||||
return sorted(groups) if isinstance(groups, list) else []
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to parse group data: %s", str(e))
|
||||
return []
|
||||
|
||||
async def get_group_members(self, group_name: str) -> list[str]:
|
||||
"""Get all members of a named group."""
|
||||
escaped_group_name = self._escape_ps_single_quoted(group_name)
|
||||
command = f"""
|
||||
$group = Get-ADGroup -Filter "Name -eq '{escaped_group_name}'" -ErrorAction Stop
|
||||
if ($group) {{
|
||||
$members = @(Get-ADGroupMember $group -ErrorAction Stop | Where-Object {{ $_.objectClass -eq 'user' }} | ForEach-Object {{ $_.SamAccountName }})
|
||||
$members | ConvertTo-Json -Compress
|
||||
}} else {{
|
||||
@() | ConvertTo-Json -Compress
|
||||
}}
|
||||
"""
|
||||
|
||||
result = await self._run_powershell(command, {"group_name": group_name})
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning(
|
||||
"get_group_members failed for group=%s: %s",
|
||||
group_name,
|
||||
result["error"],
|
||||
)
|
||||
return []
|
||||
|
||||
if not result["data"]:
|
||||
return []
|
||||
|
||||
try:
|
||||
members = json.loads(result["data"])
|
||||
return sorted(members) if isinstance(members, list) else []
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to parse member data: %s", str(e))
|
||||
return []
|
||||
|
||||
async def find_stale_users(self, days: int) -> list[dict[str, Any]]:
|
||||
"""Get users with no logon activity in N days using lastLogonTimestamp."""
|
||||
if days < 0:
|
||||
return []
|
||||
|
||||
command = f"""
|
||||
$cutoff = (Get-Date).AddDays(-{days})
|
||||
$cutoffFileTime = $cutoff.ToFileTime()
|
||||
$users = @(Get-ADUser -Filter * -Properties Enabled,lastLogonTimestamp -ErrorAction Stop | Where-Object {{
|
||||
(-not $_.lastLogonTimestamp) -or ($_.lastLogonTimestamp -lt $cutoffFileTime)
|
||||
}} | Select-Object -First 100 | ForEach-Object {{
|
||||
$lastLogon = if ($_.lastLogonTimestamp) {{ [DateTime]::FromFileTime($_.lastLogonTimestamp).ToUniversalTime().ToString('o') }} else {{ '' }}
|
||||
@{{
|
||||
username = $_.SamAccountName
|
||||
enabled = $_.Enabled
|
||||
last_logon_utc = $lastLogon
|
||||
}}
|
||||
}})
|
||||
$users | ConvertTo-Json -Compress
|
||||
"""
|
||||
|
||||
result = await self._run_powershell(command, {"days": days})
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning("find_stale_users failed: %s", result["error"])
|
||||
return []
|
||||
|
||||
if not result["data"]:
|
||||
return []
|
||||
|
||||
try:
|
||||
users = json.loads(result["data"])
|
||||
if not isinstance(users, list):
|
||||
users = [users] if users else []
|
||||
return sorted(users, key=lambda u: u.get("username", ""))
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to parse stale user data: %s", str(e))
|
||||
return []
|
||||
|
||||
async def get_computer(self, computer_name: str) -> dict[str, Any] | None:
|
||||
"""Get computer account details including OU placement.
|
||||
|
||||
Note: assigned_username returns null in this phase per requirements.
|
||||
"""
|
||||
escaped_computer_name = self._escape_ps_single_quoted(computer_name)
|
||||
command = f"""
|
||||
$computer = Get-ADComputer -Filter "Name -eq '{escaped_computer_name}'" -Properties DistinguishedName -ErrorAction Stop
|
||||
if ($computer) {{
|
||||
@{{
|
||||
computer_name = $computer.Name
|
||||
ou = $computer.DistinguishedName
|
||||
assigned_username = $null
|
||||
}} | ConvertTo-Json -Compress
|
||||
}}
|
||||
"""
|
||||
|
||||
result = await self._run_powershell(command, {"computer_name": computer_name})
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning(
|
||||
"get_computer failed for computer=%s: %s",
|
||||
computer_name,
|
||||
result["error"],
|
||||
)
|
||||
return None
|
||||
|
||||
if not result["data"]:
|
||||
return None
|
||||
|
||||
try:
|
||||
computer_data = json.loads(result["data"])
|
||||
return {
|
||||
"computer_name": computer_data["computer_name"],
|
||||
"ou": computer_data["ou"],
|
||||
"assigned_username": None,
|
||||
}
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
logger.error("Failed to parse computer data: %s", str(e))
|
||||
return None
|
||||
|
||||
async def query_users(
|
||||
self,
|
||||
filter_params: dict[str, Any] | None = None,
|
||||
fields: list[str] | None = None,
|
||||
sort_by: str = "display_name",
|
||||
sort_direction: str = "asc",
|
||||
page_size: int = 50,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Query users with flexible validated filters and pagination."""
|
||||
from identity_backend import ALLOWED_USER_FIELDS
|
||||
|
||||
filter_params = filter_params or {}
|
||||
fields = fields or list(ALLOWED_USER_FIELDS)
|
||||
|
||||
# Validate fields
|
||||
invalid_fields = set(fields) - ALLOWED_USER_FIELDS
|
||||
if invalid_fields:
|
||||
return {
|
||||
"items": [],
|
||||
"next_cursor": None,
|
||||
"page_size": page_size,
|
||||
"total_estimate": 0,
|
||||
"applied_filter": filter_params,
|
||||
"warnings": [f"Invalid fields requested: {', '.join(invalid_fields)}"],
|
||||
}
|
||||
|
||||
# Build AD filter from filter_params
|
||||
ad_filter_parts = []
|
||||
|
||||
if "enabled" in filter_params:
|
||||
enabled_val = "True" if filter_params["enabled"] else "False"
|
||||
ad_filter_parts.append(f"Enabled -eq ${enabled_val}")
|
||||
|
||||
if "name_contains" in filter_params:
|
||||
name_query = self._escape_ps_single_quoted(filter_params["name_contains"])
|
||||
ad_filter_parts.append(f"DisplayName -like '*{name_query}*'")
|
||||
|
||||
if "username_prefix" in filter_params:
|
||||
prefix = self._escape_ps_single_quoted(filter_params["username_prefix"])
|
||||
ad_filter_parts.append(f"samAccountName -like '{prefix}*'")
|
||||
|
||||
if "ou_contains" in filter_params:
|
||||
ou_part = self._escape_ps_single_quoted(filter_params["ou_contains"])
|
||||
ad_filter_parts.append(f"DistinguishedName -like '*{ou_part}*'")
|
||||
|
||||
if "description_contains" in filter_params:
|
||||
desc_part = self._escape_ps_single_quoted(filter_params["description_contains"])
|
||||
ad_filter_parts.append(f"Description -like '*{desc_part}*'")
|
||||
|
||||
# Build final filter - default to all users if no filters specified
|
||||
if ad_filter_parts:
|
||||
ad_filter = " -and ".join(ad_filter_parts)
|
||||
else:
|
||||
ad_filter = "*"
|
||||
|
||||
# Determine which AD properties to fetch
|
||||
ad_properties = [
|
||||
"SamAccountName",
|
||||
"GivenName",
|
||||
"Surname",
|
||||
"DisplayName",
|
||||
"Enabled",
|
||||
"DistinguishedName",
|
||||
"Description",
|
||||
"lastLogonTimestamp",
|
||||
]
|
||||
if "when_created_utc" in fields:
|
||||
ad_properties.append("whenCreated")
|
||||
if "department" in fields:
|
||||
ad_properties.append("Department")
|
||||
if "title" in fields:
|
||||
ad_properties.append("Title")
|
||||
if "email" in fields:
|
||||
ad_properties.append("EmailAddress")
|
||||
|
||||
# Build sort expression
|
||||
sort_field_map = {
|
||||
"display_name": "DisplayName",
|
||||
"username": "SamAccountName",
|
||||
"last_logon_utc": "lastLogonTimestamp",
|
||||
"when_created_utc": "whenCreated",
|
||||
"department": "Department",
|
||||
}
|
||||
sort_field = sort_field_map.get(sort_by, "DisplayName")
|
||||
|
||||
# Clamp page size
|
||||
clamped_size = min(max(1, page_size), 200)
|
||||
|
||||
# Note: Simple offset-based pagination for Phase 2
|
||||
# For production, consider lastLogonTimestamp-based keyset pagination
|
||||
start_index = 0
|
||||
if cursor:
|
||||
try:
|
||||
start_index = int(cursor)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Build PowerShell command
|
||||
props_csv = ", ".join(ad_properties)
|
||||
command = f"""
|
||||
$filter = "{ad_filter}"
|
||||
$users = Get-ADUser -Filter $filter -Properties {props_csv} -ErrorAction Stop | Sort-Object {sort_field}"""
|
||||
|
||||
if sort_direction == "desc":
|
||||
command += " -Descending"
|
||||
|
||||
command += "\n"
|
||||
|
||||
# Handle last_logon_before_days post-filter (requires FileTime comparison)
|
||||
if "last_logon_before_days" in filter_params:
|
||||
days = filter_params["last_logon_before_days"]
|
||||
command += f"$users = $users | Where-Object {{ if ($_.lastLogonTimestamp) {{ $cutoff = (Get-Date).AddDays(-{days}).ToFileTime(); $_.lastLogonTimestamp -lt $cutoff }} else {{ $true }} }}\n"
|
||||
|
||||
# Handle group_any post-filter
|
||||
if "group_any" in filter_params:
|
||||
groups_json = json.dumps(filter_params["group_any"])
|
||||
escaped_groups_json = groups_json.replace("'", "''")
|
||||
command += f"$users = $users | Where-Object {{ $targetGroups = '{escaped_groups_json}' | ConvertFrom-Json; $userGroups = @($_.MemberOf | ForEach-Object {{ $g = Get-ADGroup $_ -ErrorAction SilentlyContinue; if ($g) {{ $g.Name }} }}); $found = $false; foreach ($tg in $targetGroups) {{ if ($userGroups -contains $tg) {{ $found = $true; break }} }}; $found }}\n"
|
||||
|
||||
# Add pagination and field projection
|
||||
command += f"""
|
||||
$users = @($users)
|
||||
$total = $users.Count
|
||||
$pageUsers = $users | Select-Object -Skip {start_index} -First {clamped_size}
|
||||
$hasMore = ($total -gt {start_index + clamped_size})
|
||||
|
||||
$items = @($pageUsers | ForEach-Object {{
|
||||
$lastLogon = if ($_.lastLogonTimestamp) {{
|
||||
[DateTime]::FromFileTime($_.lastLogonTimestamp).ToUniversalTime().ToString('o')
|
||||
}} else {{ '' }}
|
||||
$whenCreated = if ($_.whenCreated) {{
|
||||
$_.whenCreated.ToUniversalTime().ToString('o')
|
||||
}} else {{ '' }}
|
||||
|
||||
@{{
|
||||
username = $_.SamAccountName
|
||||
display_name = if ($_.DisplayName) {{ $_.DisplayName }} else {{ '' }}
|
||||
first_name = if ($_.GivenName) {{ $_.GivenName }} else {{ '' }}
|
||||
last_name = if ($_.Surname) {{ $_.Surname }} else {{ '' }}
|
||||
enabled = $_.Enabled
|
||||
ou = $_.DistinguishedName
|
||||
description = if ($_.Description) {{ $_.Description }} else {{ '' }}
|
||||
last_logon_utc = $lastLogon
|
||||
when_created_utc = $whenCreated
|
||||
department = if ($_.Department) {{ $_.Department }} else {{ '' }}
|
||||
title = if ($_.Title) {{ $_.Title }} else {{ '' }}
|
||||
email = if ($_.EmailAddress) {{ $_.EmailAddress }} else {{ '' }}
|
||||
}}
|
||||
}})
|
||||
|
||||
$nextCursor = if ($hasMore) {{ {start_index + clamped_size} }} else {{ $null }}
|
||||
|
||||
@{{
|
||||
items = $items
|
||||
next_cursor = $nextCursor
|
||||
page_size = {clamped_size}
|
||||
total_estimate = $total
|
||||
}} | ConvertTo-Json -Depth 3 -Compress
|
||||
"""
|
||||
|
||||
result = await self._run_powershell(
|
||||
command,
|
||||
{"filter_params": filter_params, "page_size": clamped_size},
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning("query_users failed: %s", result["error"])
|
||||
return {
|
||||
"items": [],
|
||||
"next_cursor": None,
|
||||
"page_size": clamped_size,
|
||||
"total_estimate": 0,
|
||||
"applied_filter": filter_params,
|
||||
"warnings": [f"Query failed: {result['error'][:100]}"],
|
||||
}
|
||||
|
||||
if not result["data"]:
|
||||
return {
|
||||
"items": [],
|
||||
"next_cursor": None,
|
||||
"page_size": clamped_size,
|
||||
"total_estimate": 0,
|
||||
"applied_filter": filter_params,
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
try:
|
||||
response = json.loads(result["data"])
|
||||
|
||||
# Filter fields based on requested fields
|
||||
filtered_items = []
|
||||
for item in response.get("items", []):
|
||||
filtered_item = {k: v for k, v in item.items() if k in fields}
|
||||
filtered_items.append(filtered_item)
|
||||
|
||||
return {
|
||||
"items": filtered_items,
|
||||
"next_cursor": response.get("next_cursor"),
|
||||
"page_size": clamped_size,
|
||||
"total_estimate": response.get("total_estimate", 0),
|
||||
"applied_filter": filter_params,
|
||||
"warnings": [],
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to parse query_users data: %s", str(e))
|
||||
return {
|
||||
"items": [],
|
||||
"next_cursor": None,
|
||||
"page_size": clamped_size,
|
||||
"total_estimate": 0,
|
||||
"applied_filter": filter_params,
|
||||
"warnings": ["Failed to parse query results"],
|
||||
}
|
||||
|
||||
async def count_users(
|
||||
self,
|
||||
filter_params: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Count users matching filter without returning full records."""
|
||||
filter_params = filter_params or {}
|
||||
|
||||
# Build AD filter from filter_params (same logic as query_users)
|
||||
ad_filter_parts = []
|
||||
|
||||
if "enabled" in filter_params:
|
||||
enabled_val = "True" if filter_params["enabled"] else "False"
|
||||
ad_filter_parts.append(f"Enabled -eq ${enabled_val}")
|
||||
|
||||
if "name_contains" in filter_params:
|
||||
name_query = self._escape_ps_single_quoted(filter_params["name_contains"])
|
||||
ad_filter_parts.append(f"DisplayName -like '*{name_query}*'")
|
||||
|
||||
if "username_prefix" in filter_params:
|
||||
prefix = self._escape_ps_single_quoted(filter_params["username_prefix"])
|
||||
ad_filter_parts.append(f"samAccountName -like '{prefix}*'")
|
||||
|
||||
if "ou_contains" in filter_params:
|
||||
ou_part = self._escape_ps_single_quoted(filter_params["ou_contains"])
|
||||
ad_filter_parts.append(f"DistinguishedName -like '*{ou_part}*'")
|
||||
|
||||
if "description_contains" in filter_params:
|
||||
desc_part = self._escape_ps_single_quoted(filter_params["description_contains"])
|
||||
ad_filter_parts.append(f"Description -like '*{desc_part}*'")
|
||||
|
||||
if ad_filter_parts:
|
||||
ad_filter = " -and ".join(ad_filter_parts)
|
||||
else:
|
||||
ad_filter = "*"
|
||||
|
||||
command = f"""
|
||||
$filter = "{ad_filter}"
|
||||
$users = Get-ADUser -Filter $filter -Properties lastLogonTimestamp,MemberOf -ErrorAction Stop
|
||||
"""
|
||||
|
||||
# Handle last_logon_before_days post-filter
|
||||
if "last_logon_before_days" in filter_params:
|
||||
days = filter_params["last_logon_before_days"]
|
||||
command += f"$users = $users | Where-Object {{ if ($_.lastLogonTimestamp) {{ $cutoff = (Get-Date).AddDays(-{days}).ToFileTime(); $_.lastLogonTimestamp -lt $cutoff }} else {{ $true }} }}\n"
|
||||
|
||||
# Handle group_any post-filter
|
||||
if "group_any" in filter_params:
|
||||
groups_json = json.dumps(filter_params["group_any"])
|
||||
escaped_groups_json = groups_json.replace("'", "''")
|
||||
command += f"$users = $users | Where-Object {{ $targetGroups = '{escaped_groups_json}' | ConvertFrom-Json; $userGroups = @($_.MemberOf | ForEach-Object {{ $g = Get-ADGroup $_ -ErrorAction SilentlyContinue; if ($g) {{ $g.Name }} }}); $found = $false; foreach ($tg in $targetGroups) {{ if ($userGroups -contains $tg) {{ $found = $true; break }} }}; $found }}\n"
|
||||
|
||||
command += """
|
||||
$users = @($users)
|
||||
@{
|
||||
count = $users.Count
|
||||
} | ConvertTo-Json -Compress
|
||||
"""
|
||||
|
||||
result = await self._run_powershell(command, {"filter_params": filter_params})
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning("count_users failed: %s", result["error"])
|
||||
return {
|
||||
"count": 0,
|
||||
"applied_filter": filter_params,
|
||||
}
|
||||
|
||||
if not result["data"]:
|
||||
return {
|
||||
"count": 0,
|
||||
"applied_filter": filter_params,
|
||||
}
|
||||
|
||||
try:
|
||||
response = json.loads(result["data"])
|
||||
return {
|
||||
"count": response.get("count", 0),
|
||||
"applied_filter": filter_params,
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to parse count_users data: %s", str(e))
|
||||
return {
|
||||
"count": 0,
|
||||
"applied_filter": filter_params,
|
||||
}
|
||||
|
||||
async def summarize_users(
|
||||
self,
|
||||
filter_params: dict[str, Any] | None = None,
|
||||
group_by: str = "enabled",
|
||||
top: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
"""Return grouped aggregates for users matching filter."""
|
||||
filter_params = filter_params or {}
|
||||
|
||||
# Build AD filter (same as count_users)
|
||||
ad_filter_parts = []
|
||||
|
||||
if "enabled" in filter_params:
|
||||
enabled_val = "True" if filter_params["enabled"] else "False"
|
||||
ad_filter_parts.append(f"Enabled -eq ${enabled_val}")
|
||||
|
||||
if "name_contains" in filter_params:
|
||||
name_query = self._escape_ps_single_quoted(filter_params["name_contains"])
|
||||
ad_filter_parts.append(f"DisplayName -like '*{name_query}*'")
|
||||
|
||||
if "username_prefix" in filter_params:
|
||||
prefix = self._escape_ps_single_quoted(filter_params["username_prefix"])
|
||||
ad_filter_parts.append(f"samAccountName -like '{prefix}*'")
|
||||
|
||||
if "ou_contains" in filter_params:
|
||||
ou_part = self._escape_ps_single_quoted(filter_params["ou_contains"])
|
||||
ad_filter_parts.append(f"DistinguishedName -like '*{ou_part}*'")
|
||||
|
||||
if "description_contains" in filter_params:
|
||||
desc_part = self._escape_ps_single_quoted(filter_params["description_contains"])
|
||||
ad_filter_parts.append(f"Description -like '*{desc_part}*'")
|
||||
|
||||
if ad_filter_parts:
|
||||
ad_filter = " -and ".join(ad_filter_parts)
|
||||
else:
|
||||
ad_filter = "*"
|
||||
|
||||
# Determine grouping field
|
||||
group_field_map = {
|
||||
"enabled": "Enabled",
|
||||
"ou": "DistinguishedName",
|
||||
"department": "Department",
|
||||
"title": "Title",
|
||||
}
|
||||
group_field = group_field_map.get(group_by, "Enabled")
|
||||
|
||||
# Determine required properties
|
||||
required_props = ["Enabled", "lastLogonTimestamp", "MemberOf", "whenCreated"]
|
||||
if group_by in ["department", "title", "ou"]:
|
||||
if group_by == "department":
|
||||
required_props.append("Department")
|
||||
elif group_by == "title":
|
||||
required_props.append("Title")
|
||||
|
||||
props_csv = ", ".join(set(required_props))
|
||||
clamped_top = min(max(1, top), 50)
|
||||
|
||||
command = f"""
|
||||
$filter = "{ad_filter}"
|
||||
$users = Get-ADUser -Filter $filter -Properties {props_csv} -ErrorAction Stop
|
||||
"""
|
||||
|
||||
# Handle last_logon_before_days post-filter
|
||||
if "last_logon_before_days" in filter_params:
|
||||
days = filter_params["last_logon_before_days"]
|
||||
command += f"$users = $users | Where-Object {{ if ($_.lastLogonTimestamp) {{ $cutoff = (Get-Date).AddDays(-{days}).ToFileTime(); $_.lastLogonTimestamp -lt $cutoff }} else {{ $true }} }}\n"
|
||||
|
||||
# Handle group_any post-filter
|
||||
if "group_any" in filter_params:
|
||||
groups_json = json.dumps(filter_params["group_any"])
|
||||
escaped_groups_json = groups_json.replace("'", "''")
|
||||
command += f"$users = $users | Where-Object {{ $targetGroups = '{escaped_groups_json}' | ConvertFrom-Json; $userGroups = @($_.MemberOf | ForEach-Object {{ $g = Get-ADGroup $_ -ErrorAction SilentlyContinue; if ($g) {{ $g.Name }} }}); $found = $false; foreach ($tg in $targetGroups) {{ if ($userGroups -contains $tg) {{ $found = $true; break }} }}; $found }}\n"
|
||||
|
||||
# Group by logic
|
||||
command += """
|
||||
$users = @($users)
|
||||
"""
|
||||
if group_by == "enabled":
|
||||
command += """
|
||||
$buckets = $users | Group-Object Enabled | ForEach-Object {
|
||||
@{
|
||||
key = if ($_.Name -eq "True") { "Enabled" } else { "Disabled" }
|
||||
count = $_.Count
|
||||
}
|
||||
} | Sort-Object count -Descending
|
||||
"""
|
||||
elif group_by == "ou":
|
||||
command += """
|
||||
$buckets = $users | ForEach-Object {
|
||||
# Extract OU from DN
|
||||
$dn = $_.DistinguishedName
|
||||
if ($dn -match 'OU=([^,]+)') {
|
||||
$matches[1]
|
||||
} else {
|
||||
"Root"
|
||||
}
|
||||
} | Group-Object | ForEach-Object {
|
||||
@{
|
||||
key = $_.Name
|
||||
count = $_.Count
|
||||
}
|
||||
} | Sort-Object count -Descending
|
||||
"""
|
||||
elif group_by == "department":
|
||||
command += """
|
||||
$buckets = $users | ForEach-Object {
|
||||
if ($_.Department) { $_.Department } else { "No Department" }
|
||||
} | Group-Object | ForEach-Object {
|
||||
@{
|
||||
key = $_.Name
|
||||
count = $_.Count
|
||||
}
|
||||
} | Sort-Object count -Descending
|
||||
"""
|
||||
elif group_by == "title":
|
||||
command += """
|
||||
$buckets = $users | ForEach-Object {
|
||||
if ($_.Title) { $_.Title } else { "No Title" }
|
||||
} | Group-Object | ForEach-Object {
|
||||
@{
|
||||
key = $_.Name
|
||||
count = $_.Count
|
||||
}
|
||||
} | Sort-Object count -Descending
|
||||
"""
|
||||
elif group_by == "last_logon_bucket":
|
||||
command += """
|
||||
$now = Get-Date
|
||||
$buckets = $users | ForEach-Object {
|
||||
if ($_.lastLogonTimestamp) {
|
||||
$lastLogon = [DateTime]::FromFileTime($_.lastLogonTimestamp)
|
||||
$daysAgo = ($now - $lastLogon).Days
|
||||
if ($daysAgo -lt 7) { "Last 7 days" }
|
||||
elseif ($daysAgo -lt 30) { "Last 30 days" }
|
||||
elseif ($daysAgo -lt 90) { "Last 90 days" }
|
||||
else { "90+ days" }
|
||||
} else {
|
||||
"Never logged in"
|
||||
}
|
||||
} | Group-Object | ForEach-Object {
|
||||
@{
|
||||
key = $_.Name
|
||||
count = $_.Count
|
||||
}
|
||||
} | Sort-Object count -Descending
|
||||
"""
|
||||
elif group_by == "created_month":
|
||||
command += """
|
||||
$buckets = $users | ForEach-Object {
|
||||
if ($_.whenCreated) {
|
||||
$_.whenCreated.ToString("yyyy-MM")
|
||||
} else {
|
||||
"Unknown"
|
||||
}
|
||||
} | Group-Object | ForEach-Object {
|
||||
@{
|
||||
key = $_.Name
|
||||
count = $_.Count
|
||||
}
|
||||
} | Sort-Object key -Descending
|
||||
"""
|
||||
else:
|
||||
command += """
|
||||
$buckets = @(@{
|
||||
key = "Unknown"
|
||||
count = $users.Count
|
||||
})
|
||||
"""
|
||||
|
||||
command += f"""
|
||||
@{{
|
||||
buckets = @($buckets | Select-Object -First {clamped_top})
|
||||
total = $users.Count
|
||||
}} | ConvertTo-Json -Depth 3 -Compress
|
||||
"""
|
||||
|
||||
result = await self._run_powershell(
|
||||
command,
|
||||
{"filter_params": filter_params, "group_by": group_by},
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning("summarize_users failed: %s", result["error"])
|
||||
return {
|
||||
"buckets": [],
|
||||
"total": 0,
|
||||
"applied_filter": filter_params,
|
||||
}
|
||||
|
||||
if not result["data"]:
|
||||
return {
|
||||
"buckets": [],
|
||||
"total": 0,
|
||||
"applied_filter": filter_params,
|
||||
}
|
||||
|
||||
try:
|
||||
response = json.loads(result["data"])
|
||||
buckets = response.get("buckets", [])
|
||||
if not isinstance(buckets, list):
|
||||
buckets = [buckets] if buckets else []
|
||||
|
||||
return {
|
||||
"buckets": buckets,
|
||||
"total": response.get("total", 0),
|
||||
"applied_filter": filter_params,
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to parse summarize_users data: %s", str(e))
|
||||
return {
|
||||
"buckets": [],
|
||||
"total": 0,
|
||||
"applied_filter": filter_params,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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 |
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
# Test configuration for Identity MCP
|
||||
@@ -0,0 +1,284 @@
|
||||
import asyncio
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from ad_adapter import ActiveDirectoryIdentityBackend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ad_backend():
|
||||
"""Create AD adapter instance for testing without credentials."""
|
||||
return ActiveDirectoryIdentityBackend(timeout_seconds=5.0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ad_backend_with_creds():
|
||||
"""Create AD adapter with test credentials."""
|
||||
return ActiveDirectoryIdentityBackend(
|
||||
username="test_user", password="test_pass", timeout_seconds=5.0
|
||||
)
|
||||
|
||||
|
||||
class TestActiveDirectoryBackend:
|
||||
"""Unit tests for AD adapter output parsing and error handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_success(self, ad_backend):
|
||||
"""Test successful user lookup with valid JSON response."""
|
||||
mock_output = '{"username":"jane.doe","first_name":"Jane","last_name":"Doe","display_name":"Jane Doe","enabled":true,"ou":"OU=Users,DC=example,DC=local","description":"Test User","last_logon_utc":"2026-03-10T15:30:00.0000000Z"}'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.get_user("jane.doe")
|
||||
|
||||
assert result is not None
|
||||
assert result["username"] == "jane.doe"
|
||||
assert result["first_name"] == "Jane"
|
||||
assert result["last_name"] == "Doe"
|
||||
assert result["display_name"] == "Jane Doe"
|
||||
assert result["enabled"] is True
|
||||
assert result["ou"] == "OU=Users,DC=example,DC=local"
|
||||
assert result["description"] == "Test User"
|
||||
assert "2026-03-10" in result["last_logon_utc"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_users_by_name_success_list(self, ad_backend):
|
||||
"""Test name search parsing for list response."""
|
||||
mock_output = '[{"username":"jane.doe","first_name":"Jane","last_name":"Doe","display_name":"Jane Doe","enabled":true,"ou":"OU=Users,DC=example,DC=local"},{"username":"john.doe","first_name":"John","last_name":"Doe","display_name":"John Doe","enabled":true,"ou":"OU=Users,DC=example,DC=local"}]'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.search_users_by_name("doe", limit=10)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
assert result[0]["username"] == "jane.doe"
|
||||
assert result[0]["display_name"] == "Jane Doe"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_users_by_name_success_single_object(self, ad_backend):
|
||||
"""Test name search parsing for single-object JSON response."""
|
||||
mock_output = '{"username":"jane.doe","first_name":"Jane","last_name":"Doe","display_name":"Jane Doe","enabled":true,"ou":"OU=Users,DC=example,DC=local"}'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.search_users_by_name("Jane Doe", limit=10)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0]["username"] == "jane.doe"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_users_by_name_empty_query(self, ad_backend):
|
||||
"""Test name search rejects blank query."""
|
||||
result = await ad_backend.search_users_by_name(" ", limit=10)
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_not_found(self, ad_backend):
|
||||
"""Test user lookup when user does not exist."""
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": "", "error": None}
|
||||
):
|
||||
result = await ad_backend.get_user("nonexistent")
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_command_failure(self, ad_backend):
|
||||
"""Test user lookup when PowerShell command fails."""
|
||||
with patch.object(
|
||||
ad_backend,
|
||||
"_run_powershell",
|
||||
return_value={"success": False, "data": None, "error": "Access denied"},
|
||||
):
|
||||
result = await ad_backend.get_user("jane.doe")
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_groups_success(self, ad_backend):
|
||||
"""Test group membership retrieval."""
|
||||
mock_output = '["GG-Global-VPN","GG-ServiceDesk"]'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.get_user_groups("jane.doe")
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
assert "GG-Global-VPN" in result
|
||||
assert "GG-ServiceDesk" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_groups_empty(self, ad_backend):
|
||||
"""Test group membership when user has no groups."""
|
||||
mock_output = "[]"
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.get_user_groups("jane.doe")
|
||||
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_group_members_success(self, ad_backend):
|
||||
"""Test retrieving members of a group."""
|
||||
mock_output = '["jane.doe","john.smith"]'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.get_group_members("GG-ServiceDesk")
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
assert "jane.doe" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_stale_users_success(self, ad_backend):
|
||||
"""Test finding stale users with lastLogonTimestamp cutoff."""
|
||||
mock_output = '[{"username":"john.smith","enabled":false,"last_logon_utc":"2025-12-01T10:00:00.0000000Z"}]'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.find_stale_users(60)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0]["username"] == "john.smith"
|
||||
assert result[0]["enabled"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_stale_users_negative_days(self, ad_backend):
|
||||
"""Test stale user query with invalid negative days."""
|
||||
result = await ad_backend.find_stale_users(-5)
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_computer_success(self, ad_backend):
|
||||
"""Test computer lookup returns OU and null assigned_username."""
|
||||
mock_output = '{"computer_name":"LT-1001","ou":"OU=Workstations,DC=example,DC=local","assigned_username":null}'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.get_computer("LT-1001")
|
||||
|
||||
assert result is not None
|
||||
assert result["computer_name"] == "LT-1001"
|
||||
assert result["ou"] == "OU=Workstations,DC=example,DC=local"
|
||||
assert result["assigned_username"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_computer_not_found(self, ad_backend):
|
||||
"""Test computer lookup when computer does not exist."""
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": "", "error": None}
|
||||
):
|
||||
result = await ad_backend.get_computer("NONEXISTENT")
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_powershell_timeout(self, ad_backend):
|
||||
"""Test PowerShell execution timeout handling."""
|
||||
with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError):
|
||||
result = await ad_backend._run_powershell("Start-Sleep -Seconds 60")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "timeout" in result["error"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_powershell_with_credentials(self, ad_backend_with_creds):
|
||||
"""Test PowerShell command includes credential block when configured."""
|
||||
mock_process = AsyncMock()
|
||||
mock_process.communicate.return_value = (b"", b"")
|
||||
mock_process.returncode = 0
|
||||
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", return_value=mock_process
|
||||
) as mock_subprocess:
|
||||
await ad_backend_with_creds._run_powershell("Get-ADUser test")
|
||||
|
||||
# Verify credential block was included in command args
|
||||
call_args = mock_subprocess.call_args[0]
|
||||
full_command = call_args[4] # 5th arg is the command string
|
||||
assert "ConvertTo-SecureString" in full_command
|
||||
assert "PSCredential" in full_command
|
||||
|
||||
|
||||
class TestBackendContract:
|
||||
"""Contract tests ensuring AD adapter matches IdentityBackend interface."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_return_shape(self, ad_backend):
|
||||
"""Verify get_user returns correct shape or None."""
|
||||
mock_output = '{"username":"test","first_name":"Test","last_name":"User","display_name":"Test User","enabled":true,"ou":"OU=Test","description":"","last_logon_utc":""}'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.get_user("test")
|
||||
|
||||
assert result is None or isinstance(result, dict)
|
||||
if result:
|
||||
assert "username" in result
|
||||
assert "first_name" in result
|
||||
assert "last_name" in result
|
||||
assert "display_name" in result
|
||||
assert "enabled" in result
|
||||
assert "ou" in result
|
||||
assert "description" in result
|
||||
assert "last_logon_utc" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_users_by_name_return_shape(self, ad_backend):
|
||||
"""Verify search_users_by_name returns list of expected user records."""
|
||||
mock_output = '[{"username":"test","first_name":"Test","last_name":"User","display_name":"Test User","enabled":true,"ou":"OU=Test"}]'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.search_users_by_name("test", limit=10)
|
||||
|
||||
assert isinstance(result, list)
|
||||
for user in result:
|
||||
assert "username" in user
|
||||
assert "first_name" in user
|
||||
assert "last_name" in user
|
||||
assert "display_name" in user
|
||||
assert "enabled" in user
|
||||
assert "ou" in user
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_groups_return_shape(self, ad_backend):
|
||||
"""Verify get_user_groups always returns list[str]."""
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": "[]", "error": None}
|
||||
):
|
||||
result = await ad_backend.get_user_groups("test")
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert all(isinstance(item, str) for item in result)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_stale_users_return_shape(self, ad_backend):
|
||||
"""Verify find_stale_users returns list of dicts with correct keys."""
|
||||
mock_output = '[{"username":"test","enabled":true,"last_logon_utc":""}]'
|
||||
|
||||
with patch.object(
|
||||
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||
):
|
||||
result = await ad_backend.find_stale_users(30)
|
||||
|
||||
assert isinstance(result, list)
|
||||
for user in result:
|
||||
assert "username" in user
|
||||
assert "enabled" in user
|
||||
assert "last_logon_utc" in user
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Integration smoke tests for AD adapter against non-production AD environment.
|
||||
|
||||
These tests require:
|
||||
1. Access to a non-production AD environment
|
||||
2. Test credentials set via environment variables:
|
||||
- AD_TEST_USERNAME
|
||||
- AD_TEST_PASSWORD
|
||||
3. Known test objects in AD for validation
|
||||
|
||||
Run with: pytest tests/test_integration.py -v
|
||||
Skip with: pytest tests/ --ignore=tests/test_integration.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from ad_adapter import ActiveDirectoryIdentityBackend
|
||||
|
||||
# Skip all integration tests if credentials not configured
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.getenv("AD_TEST_USERNAME") or not os.getenv("AD_TEST_PASSWORD"),
|
||||
reason="AD test credentials not configured (set AD_TEST_USERNAME and AD_TEST_PASSWORD)",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ad_integration_backend():
|
||||
"""Create AD adapter with test credentials from environment."""
|
||||
username = os.getenv("AD_TEST_USERNAME")
|
||||
password = os.getenv("AD_TEST_PASSWORD")
|
||||
return ActiveDirectoryIdentityBackend(
|
||||
username=username, password=password, timeout_seconds=30.0
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_smoke(ad_integration_backend):
|
||||
"""Smoke test: get_user returns expected shape for known test user.
|
||||
|
||||
TODO: Replace 'test_user' with actual test username in your AD environment.
|
||||
"""
|
||||
test_username = os.getenv("AD_TEST_USER", "test_user")
|
||||
result = await ad_integration_backend.get_user(test_username)
|
||||
|
||||
# Should return user data or None if user doesn't exist
|
||||
assert result is None or isinstance(result, dict)
|
||||
if result:
|
||||
assert "username" in result
|
||||
assert "enabled" in result
|
||||
assert "ou" in result
|
||||
assert result["username"] == test_username
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_groups_smoke(ad_integration_backend):
|
||||
"""Smoke test: get_user_groups returns list for known test user."""
|
||||
test_username = os.getenv("AD_TEST_USER", "test_user")
|
||||
result = await ad_integration_backend.get_user_groups(test_username)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert all(isinstance(group, str) for group in result)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_users_by_name_smoke(ad_integration_backend):
|
||||
"""Smoke test: search_users_by_name returns list with expected keys."""
|
||||
test_name_query = os.getenv("AD_TEST_NAME_QUERY", "test")
|
||||
result = await ad_integration_backend.search_users_by_name(test_name_query, limit=10)
|
||||
|
||||
assert isinstance(result, list)
|
||||
for user in result:
|
||||
assert "username" in user
|
||||
assert "first_name" in user
|
||||
assert "last_name" in user
|
||||
assert "display_name" in user
|
||||
assert "enabled" in user
|
||||
assert "ou" in user
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_group_members_smoke(ad_integration_backend):
|
||||
"""Smoke test: get_group_members returns list for known test group."""
|
||||
test_group = os.getenv("AD_TEST_GROUP", "Domain Users")
|
||||
result = await ad_integration_backend.get_group_members(test_group)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert all(isinstance(member, str) for member in result)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_stale_users_smoke(ad_integration_backend):
|
||||
"""Smoke test: find_stale_users returns list with proper shape."""
|
||||
result = await ad_integration_backend.find_stale_users(90)
|
||||
|
||||
assert isinstance(result, list)
|
||||
for user in result:
|
||||
assert "username" in user
|
||||
assert "enabled" in user
|
||||
assert "last_logon_utc" in user
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_computer_smoke(ad_integration_backend):
|
||||
"""Smoke test: get_computer returns expected shape for known test computer.
|
||||
|
||||
TODO: Replace 'test_computer' with actual test computer name in your AD.
|
||||
"""
|
||||
test_computer = os.getenv("AD_TEST_COMPUTER", "test_computer")
|
||||
result = await ad_integration_backend.get_computer(test_computer)
|
||||
|
||||
# Should return computer data or None if computer doesn't exist
|
||||
assert result is None or isinstance(result, dict)
|
||||
if result:
|
||||
assert "computer_name" in result
|
||||
assert "ou" in result
|
||||
assert "assigned_username" in result
|
||||
assert result["assigned_username"] is None # Phase 1 requirement
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_nonexistent_user_returns_none(ad_integration_backend):
|
||||
"""Verify nonexistent users return None, not error."""
|
||||
result = await ad_integration_backend.get_user("nonexistent_user_12345")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_nonexistent_group_returns_empty(ad_integration_backend):
|
||||
"""Verify nonexistent groups return empty list, not error."""
|
||||
result = await ad_integration_backend.get_group_members("nonexistent_group_12345")
|
||||
assert result == []
|
||||
Reference in New Issue
Block a user