diff --git a/README.md b/README.md index b87c6dd..aefa033 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ This page is the high-visibility execution status for Workday Integration Suite | Memory-backed worker status tool (`get_worker_status`) | WIS-009 | 🟢 Green | Returns structured allowlist-oriented worker payloads from deterministic fixtures. | | Manager lookup tool (`get_worker_manager`) | WIS-011, WIS-017 prep | 🟢 Green | Resolves valid, missing-manager, and unresolved-manager scenarios. | | Manager mismatch detector (`scan_manager_mismatches`) | WIS-017 | 🟢 Green | Functional prototype scans full mock set and reports unresolved manager links. | +| Expanded identity drift detectors (`scan_status_reconciliation`, `scan_job_title_drift`, `scan_department_mismatches`, `scan_name_variance_mismatches`) | WIS-014 to WIS-018 scope | 🟢 Green | Mock-backed drift scans now cover terminated-but-enabled, title drift, department drift, and legal-vs-display name review scenarios with focused pytest coverage. | | API token flow and real Workday backend | WIS-008 | 🟡 Yellow | Design path is clear; implementation still pending non-prod credentials and auth closure. | | Non-prod auth/access unblockers | WIS-001 to WIS-003 | 🔴 Red | External decisions and access provisioning remain gate conditions for API-mode validation. | @@ -48,7 +49,7 @@ This page is the high-visibility execution status for Workday Integration Suite | --- | --- | --- | --- | | Unblockers and access readiness | WIS-001 to WIS-005 | 🔴 Red | Pre-implementation dependencies are defined but not yet closed. | | Core Workday MCP buildout | WIS-006 to WIS-012 | 🟡 Yellow | Modular and memory-backed foundation is live; API/auth and resilience controls are next. | -| Correlation and mismatch expansion | WIS-013 to WIS-018 | 🟡 Yellow | WIS-017 manager mismatch path is prototyped; remaining mismatch categories are queued. | +| Correlation and mismatch expansion | WIS-013 to WIS-018 | 🟡 Yellow | Multiple mock-backed mismatch detectors are now implemented and tested; live directory/API correlation and remaining roadmap categories are still pending. | | Automation, reporting, remediation | WIS-019 to WIS-030 | 🔴 Red | Flow automation, KPI instrumentation, and cutover remain roadmap backlog. | ## Recent activity (from git history) @@ -57,6 +58,8 @@ This page is the high-visibility execution status for Workday Integration Suite - Added formalized README update prompt for repeatable status refreshes. - Refined Workday runtime modular structure and validated three core tools. - Completed type-hint quality refinements consistent with Pylance discipline. +- Added four mismatch-detection tools for status, title, department, and name variance review. +- Added focused pytest coverage for Workday mismatch scans and MCP wrappers. ## Next milestone focus diff --git a/Workday/workday-mcp/lib/data.py b/Workday/workday-mcp/lib/data.py index 87ae040..f2a48a0 100644 --- a/Workday/workday-mcp/lib/data.py +++ b/Workday/workday-mcp/lib/data.py @@ -4,66 +4,295 @@ from typing import Any MOCK_WORKERS: dict[str, dict[str, Any]] = { "EMP001": { "name": "Nathan", + "legal_name": "Nathaniel Cole", + "preferred_name": "Nathan", + "ad_display_name": "Nathan Cole", "status": "Active", + "ad_enabled": True, "dept": "IT", + "workday_cost_center": "CC100-IT", + "workday_title": "Systems Engineer", + "ad_title": "Systems Engineer", + "ad_department": "IT", "email": "nathan@example.com", "manager_id": "EMP010", }, "EMP002": { "name": "Terminated User", + "legal_name": "Taylor Brooks", + "preferred_name": "Taylor", + "ad_display_name": "Taylor Brooks", "status": "Terminated", + "ad_enabled": True, "dept": "Sales", + "workday_cost_center": "CC200-SALES", + "workday_title": "Account Executive", + "ad_title": "Account Executive", + "ad_department": "Sales", "email": "user2@example.com", "manager_id": "EMP020", }, "EMP003": { "name": "Alicia", + "legal_name": "Alicia Gomez", + "preferred_name": "Alicia", + "ad_display_name": "Alicia Gomez", "status": "Active", + "ad_enabled": True, "dept": "IT", + "workday_cost_center": "CC100-IT", + "workday_title": "Senior Systems Analyst", + "ad_title": "Systems Analyst", + "ad_department": "IT", "email": "alicia@example.com", "manager_id": "EMP010", }, "EMP004": { "name": "Jordan", + "legal_name": "Jordan Lee", + "preferred_name": "Jordan", + "ad_display_name": "Jordan Lee", "status": "Leave", + "ad_enabled": True, "dept": "Finance", + "workday_cost_center": "CC300-FIN", + "workday_title": "Finance Analyst", + "ad_title": "Finance Analyst", + "ad_department": "Accounting", "email": "jordan@example.com", "manager_id": "EMP030", }, "EMP010": { "name": "Priya Manager", + "legal_name": "Priya Narayanan", + "preferred_name": "Priya", + "ad_display_name": "Priya Manager", "status": "Active", + "ad_enabled": True, "dept": "IT", + "workday_cost_center": "CC110-IT-MGMT", + "workday_title": "IT Manager", + "ad_title": "IT Manager", + "ad_department": "IT", "email": "priya@example.com", "manager_id": "EMP100", }, "EMP020": { "name": "Ramon Director", + "legal_name": "Ramon Alvarez", + "preferred_name": "Ramon", + "ad_display_name": "Ramon Director", "status": "Active", + "ad_enabled": True, "dept": "Sales", + "workday_cost_center": "CC210-SALES-MGMT", + "workday_title": "Sales Director", + "ad_title": "Sales Director", + "ad_department": "Sales", "email": "ramon@example.com", "manager_id": "EMP100", }, "EMP030": { "name": "Morgan Lead", + "legal_name": "Morgan Patel", + "preferred_name": "Morgan", + "ad_display_name": "Morgan Patel", "status": "Active", + "ad_enabled": True, "dept": "Finance", + "workday_cost_center": "CC310-FIN-MGMT", + "workday_title": "Finance Lead", + "ad_title": "Finance Lead", + "ad_department": "Finance", "email": "morgan@example.com", "manager_id": "EMP100", }, "EMP100": { "name": "Chief Exec", + "legal_name": "Evelyn Carter", + "preferred_name": "Evelyn", + "ad_display_name": "Evelyn Carter", "status": "Active", + "ad_enabled": True, "dept": "Executive", + "workday_cost_center": "CC999-EXEC", + "workday_title": "Chief Executive Officer", + "ad_title": "Chief Executive Officer", + "ad_department": "Executive", "email": "ceo@example.com", "manager_id": "", }, # Intentional unresolved manager reference for mismatch test scenarios "EMP777": { "name": "Mismatch Case", + "legal_name": "Alexandra Rivers", + "preferred_name": "Alex", + "ad_display_name": "Jordan Rivers", "status": "Active", + "ad_enabled": True, "dept": "Operations", + "workday_cost_center": "CC400-OPS", + "workday_title": "Operations Specialist", + "ad_title": "Operations Specialist", + "ad_department": "Operations", "email": "mismatch@example.com", "manager_id": "EMP999", }, -} \ No newline at end of file +} + + +def scan_status_reconciliation_mismatches() -> dict[str, Any]: + """Detect workers terminated in Workday but still enabled in AD.""" + mismatches: list[dict[str, Any]] = [] + total_scanned = 0 + + for employee_id, details in MOCK_WORKERS.items(): + total_scanned += 1 + workday_status = details.get("status") + ad_enabled = bool(details.get("ad_enabled", False)) + + if workday_status == "Terminated" and ad_enabled: + mismatches.append( + { + "employee_id": employee_id, + "employee_name": details["name"], + "workday_status": workday_status, + "ad_enabled": ad_enabled, + "mismatch_type": "terminated_but_enabled", + "severity": "high", + } + ) + + return { + "scan_summary": { + "total_records_checked": total_scanned, + "mismatches_found": len(mismatches), + "status": "action_required" if mismatches else "clean", + }, + "mismatches": mismatches, + } + + +def scan_job_title_mismatches() -> dict[str, Any]: + """Detect workers whose Workday title differs from their AD title.""" + mismatches: list[dict[str, Any]] = [] + total_scanned = 0 + + for employee_id, details in MOCK_WORKERS.items(): + total_scanned += 1 + workday_title = details.get("workday_title", "") + ad_title = details.get("ad_title", "") + + if workday_title and ad_title and workday_title != ad_title: + mismatches.append( + { + "employee_id": employee_id, + "employee_name": details["name"], + "workday_title": workday_title, + "ad_title": ad_title, + "mismatch_type": "job_title_mismatch", + "severity": "medium", + } + ) + + return { + "scan_summary": { + "total_records_checked": total_scanned, + "mismatches_found": len(mismatches), + "status": "action_required" if mismatches else "clean", + }, + "mismatches": mismatches, + } + + +def scan_department_drift() -> dict[str, Any]: + """Detect workers whose Workday department context differs from AD department.""" + mismatches: list[dict[str, Any]] = [] + total_scanned = 0 + + for employee_id, details in MOCK_WORKERS.items(): + total_scanned += 1 + workday_department = details.get("dept", "") + workday_cost_center = details.get("workday_cost_center", "") + ad_department = details.get("ad_department", "") + + if workday_department and ad_department and workday_department != ad_department: + mismatches.append( + { + "employee_id": employee_id, + "employee_name": details["name"], + "workday_department": workday_department, + "workday_cost_center": workday_cost_center, + "ad_department": ad_department, + "mismatch_type": "department_drift", + "severity": "medium", + } + ) + + return { + "scan_summary": { + "total_records_checked": total_scanned, + "mismatches_found": len(mismatches), + "status": "action_required" if mismatches else "clean", + }, + "mismatches": mismatches, + } + + +def _normalize_name_tokens(value: str) -> list[str]: + return [token for token in value.lower().replace(".", " ").split() if token] + + +def scan_name_variance() -> dict[str, Any]: + """Detect AD display names that do not align to legal or preferred Workday names.""" + mismatches: list[dict[str, Any]] = [] + total_scanned = 0 + + for employee_id, details in MOCK_WORKERS.items(): + total_scanned += 1 + legal_name = details.get("legal_name", "") + preferred_name = details.get("preferred_name", "") + ad_display_name = details.get("ad_display_name", "") + + if not legal_name or not ad_display_name: + continue + + legal_tokens = _normalize_name_tokens(legal_name) + preferred_tokens = _normalize_name_tokens(preferred_name) + display_tokens = _normalize_name_tokens(ad_display_name) + + if not legal_tokens or not display_tokens: + continue + + legal_first = legal_tokens[0] + legal_last = legal_tokens[-1] + preferred_first = preferred_tokens[0] if preferred_tokens else "" + display_first = display_tokens[0] + display_last = display_tokens[-1] + + first_name_aligned = display_first in {legal_first, preferred_first} + last_name_aligned = display_last == legal_last + + if first_name_aligned and last_name_aligned: + continue + + mismatches.append( + { + "employee_id": employee_id, + "employee_name": details["name"], + "workday_legal_name": legal_name, + "workday_preferred_name": preferred_name, + "ad_display_name": ad_display_name, + "mismatch_type": "name_variance_requires_review", + "severity": "low", + } + ) + + return { + "scan_summary": { + "total_records_checked": total_scanned, + "mismatches_found": len(mismatches), + "status": "action_required" if mismatches else "clean", + }, + "mismatches": mismatches, + } \ No newline at end of file diff --git a/Workday/workday-mcp/server.py b/Workday/workday-mcp/server.py index f33d68b..224750c 100644 --- a/Workday/workday-mcp/server.py +++ b/Workday/workday-mcp/server.py @@ -1,6 +1,12 @@ from mcp.server.fastmcp import FastMCP from typing import Any -from lib.data import MOCK_WORKERS +from lib.data import ( + MOCK_WORKERS, + scan_department_drift, + scan_job_title_mismatches, + scan_name_variance, + scan_status_reconciliation_mismatches, +) mcp = FastMCP("Workday-Sync") #Server name for MCP registration, e.g. "Workday-Sync" @@ -93,5 +99,37 @@ def scan_manager_mismatches() -> dict[str, Any]: "mismatches": mismatches } + +@mcp.tool() +def scan_status_reconciliation() -> dict[str, Any]: + """ + WIS-014: Flag workers terminated in Workday but still enabled in AD. + """ + return scan_status_reconciliation_mismatches() + + +@mcp.tool() +def scan_job_title_drift() -> dict[str, Any]: + """ + Detect workers whose Workday title differs from their AD title. + """ + return scan_job_title_mismatches() + + +@mcp.tool() +def scan_department_mismatches() -> dict[str, Any]: + """ + Detect workers whose Workday department differs from their AD department. + """ + return scan_department_drift() + + +@mcp.tool() +def scan_name_variance_mismatches() -> dict[str, Any]: + """ + Detect AD display names that do not align with Workday legal or preferred names. + """ + return scan_name_variance() + if __name__ == "__main__": mcp.run() \ No newline at end of file diff --git a/Workday/workday-mcp/tests/test_mismatch_scans.py b/Workday/workday-mcp/tests/test_mismatch_scans.py new file mode 100644 index 0000000..ec19027 --- /dev/null +++ b/Workday/workday-mcp/tests/test_mismatch_scans.py @@ -0,0 +1,92 @@ +from lib.data import ( + scan_department_drift, + scan_job_title_mismatches, + scan_name_variance, + scan_status_reconciliation_mismatches, +) +from server import ( + scan_department_mismatches, + scan_job_title_drift, + scan_name_variance_mismatches, + scan_status_reconciliation, +) + + +def test_scan_status_reconciliation_mismatches_returns_expected_record() -> None: + result = scan_status_reconciliation_mismatches() + + assert result["scan_summary"]["total_records_checked"] == 9 + assert result["scan_summary"]["mismatches_found"] == 1 + assert result["mismatches"] == [ + { + "employee_id": "EMP002", + "employee_name": "Terminated User", + "workday_status": "Terminated", + "ad_enabled": True, + "mismatch_type": "terminated_but_enabled", + "severity": "high", + } + ] + + +def test_scan_job_title_mismatches_returns_expected_record() -> None: + result = scan_job_title_mismatches() + + assert result["scan_summary"]["total_records_checked"] == 9 + assert result["scan_summary"]["mismatches_found"] == 1 + assert result["mismatches"] == [ + { + "employee_id": "EMP003", + "employee_name": "Alicia", + "workday_title": "Senior Systems Analyst", + "ad_title": "Systems Analyst", + "mismatch_type": "job_title_mismatch", + "severity": "medium", + } + ] + + +def test_scan_department_drift_returns_expected_record() -> None: + result = scan_department_drift() + + assert result["scan_summary"]["total_records_checked"] == 9 + assert result["scan_summary"]["mismatches_found"] == 1 + assert result["mismatches"] == [ + { + "employee_id": "EMP004", + "employee_name": "Jordan", + "workday_department": "Finance", + "workday_cost_center": "CC300-FIN", + "ad_department": "Accounting", + "mismatch_type": "department_drift", + "severity": "medium", + } + ] + + +def test_scan_name_variance_returns_expected_records() -> None: + result = scan_name_variance() + + assert result["scan_summary"]["total_records_checked"] == 9 + assert result["scan_summary"]["mismatches_found"] == 3 + assert [item["employee_id"] for item in result["mismatches"]] == [ + "EMP010", + "EMP020", + "EMP777", + ] + + +def test_scan_status_reconciliation_tool_matches_detector() -> None: + assert scan_status_reconciliation() == scan_status_reconciliation_mismatches() + + +def test_scan_job_title_drift_tool_matches_detector() -> None: + assert scan_job_title_drift() == scan_job_title_mismatches() + + +def test_scan_department_mismatches_tool_matches_detector() -> None: + assert scan_department_mismatches() == scan_department_drift() + + +def test_scan_name_variance_mismatches_tool_matches_detector() -> None: + assert scan_name_variance_mismatches() == scan_name_variance() \ No newline at end of file