diff --git a/solutions/ess-maker-skills/.github/copilot-instructions.md b/solutions/ess-maker-skills/.github/copilot-instructions.md index d34962891..d9f5f1861 100644 --- a/solutions/ess-maker-skills/.github/copilot-instructions.md +++ b/solutions/ess-maker-skills/.github/copilot-instructions.md @@ -380,9 +380,9 @@ When helping a customer, match their request to one of these patterns: | Customer says... | Pattern | What to create | |-----------------|---------|---------------| -| "I need to look up X from ServiceNow/Workday" | Product extension required | Explain that DA-GA extension setup guidance is not yet available | -| "I need to create a ticket/case/request" | Product extension required | Explain that DA-GA extension setup guidance is not yet available | -| "I need to show the user their X data" | Product extension required | Explain that DA-GA extension setup guidance is not yet available | +| "I need to look up X from ServiceNow/Workday" | Product extension required | Route connection/setup requests through `src/skills/connect/SKILL.md`; create topics only after the supported integration is connected | +| "I need to create a ticket/case/request" | Product extension required | Route connection/setup requests through `src/skills/connect/SKILL.md`; create topics only after the supported integration is connected | +| "I need to show the user their X data" | Product extension required | Route connection/setup requests through `src/skills/connect/SKILL.md`; create topics only after the supported integration is connected | | "I need to call a non-ESS system (Jira, custom API)" | Standalone Topic + Workflow | Topic + new cloud flow (only for connectors without a shared orchestrator) | | "I need to add a step to an existing flow" | Modify topic | Edit the existing topic YAML | | "I need to change how the agent responds to X" | Modify topic | Update trigger phrases, messages, or conditions | @@ -430,7 +430,7 @@ pushed. Run the push pipeline when the maker asks to push local changes. | User intent | Skill to read | |-------------|--------------| | Run common ESS foundation setup (`/setup`) | `src/skills/foundation-setup/SKILL.md` | -| Provision/connect the Workday setup environment (`/connect workday`) | `src/skills/setup/SKILL.md` | +| Provision/connect Workday for the active ESS HR agent (`/connect workday` or `/connect-workday`) | `src/skills/connect/SKILL.md` | | Connect to ServiceNow/Workday | `src/skills/connect/SKILL.md` | | Create a topic | `src/skills/topics/create-eval-driven/SKILL.md` | | Create a workflow | `src/skills/workflows/create/SKILL.md` | diff --git a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md index bffb68f3c..7a45d5e2c 100644 --- a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md +++ b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md @@ -31,6 +31,6 @@ Rules: 4. Do not compose your own messages. If there is no Message block for a situation, stay silent and proceed to the next action. -After reading SKILL.md, your first action is to check for -`.local/connect/steps.md`. If starting fresh, your first message to the user -is the checklist table from the Fresh Start section. +After reading `SKILL.md`, follow its integration-specific state and routing +instructions. Do not assume a shared `.local/connect/steps.md` file or a +generic Fresh Start section. diff --git a/solutions/ess-maker-skills/scripts/agentbuilder_object_model.py b/solutions/ess-maker-skills/scripts/agentbuilder_object_model.py index 6e5c114cb..98f1a6667 100644 --- a/solutions/ess-maker-skills/scripts/agentbuilder_object_model.py +++ b/solutions/ess-maker-skills/scripts/agentbuilder_object_model.py @@ -230,3 +230,52 @@ def object_models_to_yaml( except Exception as exc: results.append(_error_result(key, exc)) return results + + +def yaml_to_object_models( + items: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Convert canonical Copilot Studio YAML to Object Model JSON elements.""" + if not items: + return [] + + types = _load_object_model() + options = types.element_serializer.CreateOptions(False) + results: list[dict[str, Any]] = [] + for item in items: + key = item.get("key") + if not isinstance(key, str) or not key.strip(): + raise ObjectModelConverterError( + "Each item key must be a non-empty string." + ) + yaml_content = item.get("yaml") + if not isinstance(yaml_content, str) or not yaml_content.strip(): + raise ObjectModelConverterError("yaml must be a non-empty string.") + + try: + element = types.yaml_serializer.Deserialize[types.bot_element]( + yaml_content + ) + if element is None: + raise ValueError( + "The Copilot Studio YAML did not contain a BotElement." + ) + serialized = types.json_serializer.Serialize[ + types.bot_element + ](element, options) + object_model = json.loads(str(serialized)) + if not isinstance(object_model, dict): + raise ValueError( + "The converted Object Model JSON was not an object." + ) + results.append( + { + "key": key, + "success": True, + "elementType": element.GetType().Name, + "objectModel": object_model, + } + ) + except Exception as exc: + results.append(_error_result(key, exc)) + return results diff --git a/solutions/ess-maker-skills/scripts/alm/Enable-CosmosDAFlowAuthorization.ps1 b/solutions/ess-maker-skills/scripts/alm/Enable-CosmosDAFlowAuthorization.ps1 index 02d4c05f7..c29e0b693 100644 --- a/solutions/ess-maker-skills/scripts/alm/Enable-CosmosDAFlowAuthorization.ps1 +++ b/solutions/ess-maker-skills/scripts/alm/Enable-CosmosDAFlowAuthorization.ps1 @@ -25,6 +25,9 @@ .PARAMETER WorkflowId One or more workflow GUIDs (the 'workflowid' of each cloud flow the agent calls). +.PARAMETER PreferredUsername + Environment Maker username that must be used for Dataverse authentication. + .PARAMETER TeamName Optional display name for the access team. @@ -57,6 +60,7 @@ param( [Parameter(Mandatory = $true)][string] $OrgUrl, [Parameter(Mandatory = $true)][guid] $BotId, [Parameter(Mandatory = $true)][guid[]] $WorkflowId, + [string] $PreferredUsername, [string] $TeamName, [guid] $AdministratorId, [guid] $BusinessUnitId, @@ -100,25 +104,38 @@ function Test-DataverseToken { } function Get-DataverseToken { - param([string]$Resource) + param( + [string]$Resource, + [string]$PreferredUsername + ) $tok = $null - try { - $tok = az account get-access-token --resource $Resource --query accessToken -o tsv 2>$null - } catch { - $tok = $null - } - if (-not [string]::IsNullOrWhiteSpace($tok) -and (Test-DataverseToken -Resource $Resource -Token $tok)) { - return $tok + if ([string]::IsNullOrWhiteSpace($PreferredUsername)) { + try { + $tok = az account get-access-token --resource $Resource --query accessToken -o tsv 2>$null + } catch { + $tok = $null + } + if (-not [string]::IsNullOrWhiteSpace($tok) -and (Test-DataverseToken -Resource $Resource -Token $tok)) { + return $tok + } } - Write-Host " Azure CLI token was unavailable or rejected; using the kit's Dataverse sign-in." -ForegroundColor Yellow + if ($PreferredUsername) { + Write-Host " Using the kit's Dataverse sign-in for $PreferredUsername." -ForegroundColor Yellow + } else { + Write-Host " Azure CLI token was unavailable or rejected; using the kit's Dataverse sign-in." -ForegroundColor Yellow + } $helper = Join-Path $PSScriptRoot 'get_dataverse_token.py' $python = Get-Command python -ErrorAction SilentlyContinue if (-not $python -or -not (Test-Path $helper)) { throw "Could not acquire a valid Dataverse token. Install Python, then run the kit setup or sign in with an account that can access $Resource." } - $output = @(& $python.Source $helper --environment $Resource 2>&1) + $helperArgs = @('--environment', $Resource) + if ($PreferredUsername) { + $helperArgs += @('--preferred-username', $PreferredUsername) + } + $output = @(& $python.Source $helper @helperArgs 2>&1) if ($LASTEXITCODE -ne 0) { $safeError = ($output | Where-Object { $_ -notmatch '^ESS_DATAVERSE_TOKEN=' }) -join [Environment]::NewLine throw "Kit Dataverse authentication failed: $safeError" @@ -171,7 +188,9 @@ function Invoke-Dv { # -------------------------------------------------------------------------------------------- Write-Step "Connecting to $OrgUrl" -$script:Token = Get-DataverseToken -Resource $OrgUrl +$script:Token = Get-DataverseToken ` + -Resource $OrgUrl ` + -PreferredUsername $PreferredUsername $who = Invoke-Dv -Path 'WhoAmI' Write-Ok "Authenticated. UserId $($who.UserId), OrgId $($who.OrganizationId)" diff --git a/solutions/ess-maker-skills/scripts/alm/get_dataverse_token.py b/solutions/ess-maker-skills/scripts/alm/get_dataverse_token.py index 877292429..d7510cf65 100644 --- a/solutions/ess-maker-skills/scripts/alm/get_dataverse_token.py +++ b/solutions/ess-maker-skills/scripts/alm/get_dataverse_token.py @@ -13,14 +13,30 @@ sys.path.insert(0, str(SCRIPTS_DIR)) import auth # noqa: E402 +from workday_connect_auth import ( # noqa: E402 + WorkdayConnectIdentityError, + require_identity, +) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--environment", required=True) + parser.add_argument("--preferred-username") args = parser.parse_args() - token = auth.authenticate(args.environment.rstrip("/")) + token = auth.authenticate( + args.environment.rstrip("/"), + preferred_username=args.preferred_username, + ) + try: + require_identity( + token, + preferred_username=args.preferred_username, + ) + except WorkdayConnectIdentityError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 print(f"ESS_DATAVERSE_TOKEN={token}") return 0 diff --git a/solutions/ess-maker-skills/scripts/discover.py b/solutions/ess-maker-skills/scripts/discover.py index 0263d51a6..a7fd89bf5 100644 --- a/solutions/ess-maker-skills/scripts/discover.py +++ b/solutions/ess-maker-skills/scripts/discover.py @@ -100,6 +100,10 @@ def main(): "--resolve-environment-url", help="Resolve one environment URL to its Power Platform metadata", ) + parser.add_argument( + "--preferred-username", + help="Account to reuse for environment resolution when available", + ) parser.add_argument("--select", type=int, default=None, help="Select environment by number and output JSON") args = parser.parse_args() @@ -107,7 +111,10 @@ def main(): if args.resolve_environment_url: from list_environments import resolve_environment_for_user - selected = resolve_environment_for_user(args.resolve_environment_url) + selected = resolve_environment_for_user( + args.resolve_environment_url, + preferred_username=args.preferred_username, + ) if selected is None: print( "ERROR: The provided URL did not match a Dataverse-linked " diff --git a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py index 12746ce56..2292f4b79 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py @@ -27,10 +27,9 @@ documented-tier Dataverse ``connectionreferences`` read. * ``WD-REST-001`` (S5.5) — the captured ``restBaseUrl`` is present and **trimmed to** ``/api``. Pure-config check, no client. - * ``WD-REST-002`` (S5.7) — the agent's ``user-context-setup.mcs.yml`` topic - contains a ``BeginDialog`` redirect to the Workday user-context system topic - (``WorkdaySystemGetUserContextV2`` on the simplified pack). Pure local-file - check; SKIPPED on the legacy install path. + * ``WD-REST-002`` (S5.7) — the agent's mapped admin user-context topic + contains a ``BeginDialog`` redirect to the mapped Workday user-context + system topic. Pure local-file check; SKIPPED on the legacy install path. * ``WD-NET-001`` (S5.8) — the Workday REST + SOAP endpoints are allowlisted at the corporate firewall. **Always MANUAL attestation:** the kit has no reliable probe (a local reachability test proves only the dev machine's @@ -52,10 +51,13 @@ from __future__ import annotations +import json import os import re import sys -from pathlib import Path +from pathlib import Path, PureWindowsPath + +import yaml from ..runner import CheckResult, Priority, Role, Status from ..agent_scope import resolve_agent_directory, validate_agent_slug @@ -102,9 +104,9 @@ # ---- Local user-context topic (WD-REST-002) ---- _AGENTS_ROOT = "workspace/agents" -_INSTALLED_AGENTS_ROOT = ".local/agents" -_USER_CONTEXT_FILE = "user-context-setup.mcs.yml" -_SCHEMA_NAME_RE = re.compile(r"(?m)^\s*schemaName:\s*['\"]?([^'\"\s]+)") +_COMPONENT_MAP_FILE = ".component-map.json" +_SETUP_TOPIC_DISPLAY = "[Admin] - User Context - Setup" +_TARGET_TOPIC_DISPLAY = "Workday [System] - 1: Set User Context V2" _CONN_AUTH_DESC = ( "Workday connection authentication type is Microsoft Entra ID Integrated" @@ -121,8 +123,8 @@ _REDIRECT_REMEDIATION = ( "Wire the user-context redirect: save a rollback checkpoint " "(scripts/checkpoint.py), then set the agent's " - "topics/user-context-setup.mcs.yml OnRedirect to a BeginDialog that calls " - "the installed Workday user-context system topic, and push " + "mapped [Admin] - User Context - Setup topic OnRedirect to a BeginDialog " + "that calls the mapped Workday user-context system topic, and push " "(scripts/push.py). See the connect skill step 3 (§3.5d)." ) @@ -142,31 +144,100 @@ def _selected_agent_slug(runner) -> str: return validate_agent_slug(str(slug)) if slug else "" -def _installed_user_context_dialogs(agent_slug: str) -> list[str]: - agent_dir = resolve_agent_directory( - Path(_INSTALLED_AGENTS_ROOT), - agent_slug, - ) - topics_dir = agent_dir / "topics" - if not topics_dir.is_dir(): - return [] +def _safe_mapped_topic_path( + agent_dir: Path, + mapped_path: object, + label: str, +) -> tuple[Path | None, str | None]: + raw_path = str(mapped_path) + relative_path = Path(raw_path) + windows_path = PureWindowsPath(raw_path) + if ( + relative_path.is_absolute() + or windows_path.drive + or windows_path.root + or ".." in relative_path.parts + or ".." in windows_path.parts + ): + return None, f"The mapped {label} topic path is unsafe." + + agent_root = agent_dir.resolve() + candidate = (agent_root / relative_path).resolve() + try: + candidate.relative_to(agent_root) + except ValueError: + return None, f"The mapped {label} topic path is unsafe." + return candidate, None - dialogs: set[str] = set() - for topic_file in sorted(topics_dir.glob("*.mcs.yml")): - try: - text = topic_file.read_text(encoding="utf-8", errors="replace") - except OSError: - continue - match = _SCHEMA_NAME_RE.search(text) - dialog = ( - match.group(1) - if match - else topic_file.name.removesuffix(".mcs.yml") + +def _mapped_user_context_topics( + agent_dir: Path, +) -> tuple[Path | None, Path | None, str | None, str | None]: + component_map_path = agent_dir / _COMPONENT_MAP_FILE + try: + component_map = json.loads( + component_map_path.read_text(encoding="utf-8") + ) + except (OSError, json.JSONDecodeError) as exc: + return None, None, None, ( + f"The selected agent '{agent_dir.name}' {_COMPONENT_MAP_FILE} " + f"could not be read: {exc}" + ) + if not isinstance(component_map, dict): + return None, None, None, ( + f"The selected agent's {_COMPONENT_MAP_FILE} is not a JSON object." ) - normalized = re.sub(r"[^a-z0-9]", "", dialog.casefold()) - if "workday" in normalized and "usercontext" in normalized: - dialogs.add(dialog) - return sorted(dialogs) + + setup_matches = [ + (relative_path, entry) + for relative_path, entry in component_map.items() + if isinstance(entry, dict) + and entry.get("componentKind") == "DialogComponent" + and str(entry.get("displayName") or "").casefold() + == _SETUP_TOPIC_DISPLAY.casefold() + ] + target_matches = [ + (relative_path, entry) + for relative_path, entry in component_map.items() + if isinstance(entry, dict) + and entry.get("componentKind") == "DialogComponent" + and str(entry.get("displayName") or "").casefold() + == _TARGET_TOPIC_DISPLAY.casefold() + ] + if len(setup_matches) != 1 or len(target_matches) != 1: + return None, None, None, ( + f"Expected exactly one mapped admin user-context topic and one " + f"mapped Workday User Context V2 topic for selected agent " + f"'{agent_dir.name}'." + ) + + setup_topic_path, path_error = _safe_mapped_topic_path( + agent_dir, + setup_matches[0][0], + "admin user-context", + ) + if path_error: + return None, None, None, path_error + target_topic_path, path_error = _safe_mapped_topic_path( + agent_dir, + target_matches[0][0], + "Workday User Context V2", + ) + if path_error: + return None, None, None, path_error + target_schema = str( + target_matches[0][1].get("schemaName") or "" + ).strip() + if not target_schema: + return None, None, None, ( + "The mapped Workday User Context V2 topic has no schemaName." + ) + return ( + setup_topic_path, + target_topic_path, + target_schema, + None, + ) def _fmt(config, key: str) -> str: @@ -638,67 +709,88 @@ def _check_user_context_redirect(runner) -> list[CheckResult]: )] agent_dir = resolve_agent_directory(agents_root, agent_slug) - topic_file = agent_dir / "topics" / _USER_CONTEXT_FILE + ( + topic_file, + target_topic_file, + target_dialog, + mapping_error, + ) = _mapped_user_context_topics(agent_dir) + if mapping_error: + return [CheckResult(roles=_MAKER_ROLES, + checkpoint_id="WD-REST-002", category=_CATEGORY, + priority=Priority.HIGH.value, status=Status.FAILED.value, + description=_REDIRECT_DESC, + result=mapping_error, + remediation=_REDIRECT_REMEDIATION, + doc_link=_DOC_SIMPLIFIED, + )] + assert topic_file is not None + assert target_topic_file is not None + assert target_dialog is not None if not topic_file.is_file(): return [CheckResult(roles=_MAKER_ROLES, checkpoint_id="WD-REST-002", category=_CATEGORY, priority=Priority.HIGH.value, status=Status.FAILED.value, description=_REDIRECT_DESC, result=( - f"No {_USER_CONTEXT_FILE} found for selected agent " - f"'{agent_slug}' under {_AGENTS_ROOT}/{agent_slug}/topics/." + f"No mapped admin user-context topic found for selected " + f"agent '{agent_slug}' at {topic_file}." ), remediation=_REDIRECT_REMEDIATION, doc_link=_DOC_SIMPLIFIED, )] - - installed_dialogs = _installed_user_context_dialogs(agent_slug) - if not installed_dialogs: + if not target_topic_file.is_file(): return [CheckResult(roles=_MAKER_ROLES, checkpoint_id="WD-REST-002", category=_CATEGORY, priority=Priority.HIGH.value, status=Status.FAILED.value, description=_REDIRECT_DESC, result=( - "No installed Workday user-context system topic was found for " - f"selected agent '{agent_slug}' under " - f"{_INSTALLED_AGENTS_ROOT}/{agent_slug}/topics/." + f"No mapped Workday User Context V2 topic found for selected " + f"agent '{agent_slug}' at {target_topic_file}." ), remediation=_REDIRECT_REMEDIATION, doc_link=_DOC_SIMPLIFIED, )] try: - text = topic_file.read_text(encoding="utf-8", errors="replace") - except OSError as e: + document = yaml.safe_load( + topic_file.read_text(encoding="utf-8", errors="replace") + ) + except (OSError, yaml.YAMLError) as e: return [CheckResult(roles=_MAKER_ROLES, checkpoint_id="WD-REST-002", category=_CATEGORY, priority=Priority.HIGH.value, status=Status.FAILED.value, description=_REDIRECT_DESC, result=( - f"The selected agent's {_USER_CONTEXT_FILE} could not be read: " + "The selected agent's mapped user-context topic could not be " + "read: " f"{e}" ), remediation=_REDIRECT_REMEDIATION, doc_link=_DOC_SIMPLIFIED, )] - matched_dialog = next( - ( - dialog - for dialog in installed_dialogs - if "BeginDialog" in text and dialog in text - ), - None, - ) - if not matched_dialog: + def has_redirect(value): + if isinstance(value, dict): + if ( + value.get("kind") == "BeginDialog" + and value.get("dialog") == target_dialog + ): + return True + return any(has_redirect(child) for child in value.values()) + if isinstance(value, list): + return any(has_redirect(child) for child in value) + return False + + if not has_redirect(document): return [CheckResult(roles=_MAKER_ROLES, checkpoint_id="WD-REST-002", category=_CATEGORY, priority=Priority.HIGH.value, status=Status.FAILED.value, description=_REDIRECT_DESC, result=( f"The selected agent '{agent_slug}' user-context topic does " - "not redirect to any installed Workday user-context system " - f"topic. Installed candidate(s): {', '.join(installed_dialogs)}." + "not redirect to its mapped Workday user-context system " + f"topic '{target_dialog}'." ), remediation=_REDIRECT_REMEDIATION, doc_link=_DOC_SIMPLIFIED, @@ -710,7 +802,7 @@ def _check_user_context_redirect(runner) -> list[CheckResult]: description=_REDIRECT_DESC, result=( "The user-context topic redirects to the Workday " - f"'{matched_dialog}' system topic for selected agent " + f"'{target_dialog}' system topic for selected agent " f"'{agent_slug}'." ), doc_link=_DOC_SIMPLIFIED, diff --git a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_tenant.py b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_tenant.py index 0eb19efba..50012d9b8 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_tenant.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_tenant.py @@ -15,9 +15,10 @@ ``oauthClientId`` / ``tokenEndpoint``. * ``WD-TENANT-001`` — Tenant Setup - Security is configured (redirect URL set; OAuth 2.0 Clients + SAML enabled; SAML Service Provider ID matches - the Entra Identifier) AND an active authentication rule allows SAML for + the exact Workday SAML entity ID) AND an active authentication rule allows + SAML for the intended employee population. Echoes the captured ``restBaseUrl`` / - ``soapBaseUrl`` / ``tenant`` / ``appIdUri``. + ``soapBaseUrl`` / ``tenant`` / ``workdaySamlEntityId``. Design invariants (per ``scripts/flightcheck/AGENTS.md``): * **Always MANUAL.** Workday exposes no queryable admin API the kit can @@ -158,15 +159,16 @@ def _check_tenant_security(config) -> list[CheckResult]: tenant = _fmt(config, "tenant") rest_base = _fmt(config, "restBaseUrl") soap_base = _fmt(config, "soapBaseUrl") - app_id_uri = _fmt(config, "appIdUri") + saml_entity_id = _fmt(config, "workdaySamlEntityId") result = ( "Workday admin task — verify in the Workday tenant, not " f"programmatically. Captured connection fields: tenant = {tenant}; " - f"REST base = {rest_base}; SOAP base = {soap_base}; Entra Identifier " - f"(App ID URI) = {app_id_uri}. Confirm Tenant Setup - Security has the " + f"REST base = {rest_base}; SOAP base = {soap_base}; Workday SAML " + f"Service Provider ID = {saml_entity_id}. Confirm Tenant Setup - " + "Security has the " "redirection URL set, OAuth 2.0 Clients and SAML enabled, and the " - "SAML Service Provider ID matching the Entra Identifier above — and " + "enabled SAML row uses the exact Service Provider ID above — and " "that an active authentication rule allows SAML for the intended " "employee population. If the existing active policy already provides " "that access, no policy change or activation is required." @@ -180,8 +182,9 @@ def _check_tenant_security(config) -> list[CheckResult]: remediation=( "In Workday: (1) edit 'Tenant Setup - Security' — set the " "redirection URL, enable OAuth 2.0 Clients and SAML, and verify " - "the SAML Service Provider ID equals the Entra Identifier / Entity " - "ID; (2) open 'Manage Authentication Policies' and verify an active " + "the SAML Service Provider ID equals " + "http://www.workday.com/{tenant}, not the api:// Entra application " + "ID URI; (2) open 'Manage Authentication Policies' and verify an active " "rule allows SAML for the intended employees. Do not invent an " "OAuth-client condition when the tenant UI does not expose one, " "and do not use an ISU/integration-system security-group rule for " diff --git a/solutions/ess-maker-skills/scripts/flightcheck/cli.py b/solutions/ess-maker-skills/scripts/flightcheck/cli.py index 85ca8cb1e..dc9619c59 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/cli.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/cli.py @@ -790,6 +790,7 @@ def _resolve_environment_ring( "tokenHost", "vertical", "verticals", + "workdaySamlEntityId", }) @@ -809,6 +810,25 @@ def _merge_connect_config(config: dict, connect_config_path: str | None) -> dict if not isinstance(overlay, dict): raise ValueError(f"{connect_config_path} must contain a JSON object") + if overlay.get("schemaVersion") in {2, 3, 4, 5}: + scope = overlay.get("scope") or {} + identifiers = overlay.get("identifiers") or {} + endpoints = overlay.get("endpoints") or {} + overlay = { + **overlay, + **identifiers, + **endpoints, + "tenant": scope.get("workdayTenant"), + "tenantId": scope.get("entraTenantId"), + "sidecarDataverseEndpoint": scope.get("dataverseUrl"), + "appIdUri": identifiers.get("entraAppIdUri"), + "tokenEndpoint": ( + endpoints.get("tokenEndpoint") + or endpoints.get("oauthTokenUrl") + or overlay.get("tokenEndpoint") + ), + } + for key in _PROVIDER_CONNECT_CONFIG_KEYS: if key in overlay: merged[key] = overlay[key] @@ -1054,7 +1074,14 @@ def _run_single_checkpoint(args): if not quiet_auth: print("Authenticating to Dataverse...") try: - dv_token = authenticate(env_url) + dv_token = authenticate( + env_url, + preferred_username=getattr( + args, + "preferred_username", + None, + ), + ) if not quiet_auth: print(" Dataverse: OK") except Exception as e: @@ -1534,6 +1561,14 @@ def main(): "foundation config." ), ) + parser.add_argument( + "--preferred-username", + default=None, + help=( + "Require Dataverse authentication to use this exact account for " + "a single checkpoint." + ), + ) parser.add_argument( "--agent-slug", default=None, @@ -1760,7 +1795,14 @@ def main(): from auth import authenticate, discover_tenant print("Authenticating to Dataverse (runtime-reachability probe)...") - dv_token = authenticate(env_url) + dv_token = authenticate( + env_url, + preferred_username=getattr( + args, + "preferred_username", + None, + ), + ) tenant_id = discover_tenant(env_url) print("Authenticating to Power Platform Admin API...") @@ -1844,7 +1886,10 @@ def main(): from auth import authenticate, discover_tenant print("Authenticating to Dataverse...") - dv_token = authenticate(env_url) + dv_token = authenticate( + env_url, + preferred_username=getattr(args, "preferred_username", None), + ) tenant_id = discover_tenant(env_url) print(f"Tenant: {tenant_id}") diff --git a/solutions/ess-maker-skills/scripts/flightcheck/registry.py b/solutions/ess-maker-skills/scripts/flightcheck/registry.py index a2b14b377..a17bf5c55 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/registry.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/registry.py @@ -376,6 +376,19 @@ class ProfileSpec: priority=Priority.HIGH.value, roles=(Role.POWER_PLATFORM_ADMIN.value,), ), + # WD-CONN-013 reads the selected agent's connection-reference parameter + # sharing configuration directly from Dataverse. It does not call BAP or + # Power Automate APIs, so do not prompt for those additional audiences. + CheckpointSpec( + key="WD-CONN-013", + category_fn=run_workday_checks, + category_label="Workday", + clients=frozenset({DATAVERSE}), + requires_config=True, + requires_dataverse_endpoint=True, + priority=Priority.HIGH.value, + roles=(Role.POWER_PLATFORM_ADMIN.value,), + ), # ---- Workday dynamic families ---- # WD-CONN-* — the generic connection enumerator (connections.py emits # WD-CONN-001 summary + WD-CONN-{i+2:03d} per connection). Exact-first diff --git a/solutions/ess-maker-skills/scripts/install_workday_da_extension.py b/solutions/ess-maker-skills/scripts/install_workday_da_extension.py index c338aedd3..03182ffad 100644 --- a/solutions/ess-maker-skills/scripts/install_workday_da_extension.py +++ b/solutions/ess-maker-skills/scripts/install_workday_da_extension.py @@ -14,21 +14,16 @@ import subprocess import sys -from flightcheck.checks.workday_da import ( - _DA_HR_WORKDAY_CHILD_SCHEMA, - _MOS_WORKDAY_RUNTIME_SCHEMA, -) +from workday_connect_model import load_catalog +_CATALOG = load_catalog() WORKDAY_PACKAGES = { - "runtime": { - "applicationName": _MOS_WORKDAY_RUNTIME_SCHEMA, - "schemaName": _MOS_WORKDAY_RUNTIME_SCHEMA, - }, - "legacy-da": { - "applicationName": "msdyn_EssDAHRWorkdayHCM", - "schemaName": _DA_HR_WORKDAY_CHILD_SCHEMA, - }, + flavor: { + "applicationName": package["applicationName"], + "schemaName": package["solutionSchemaName"], + } + for flavor, package in _CATALOG["packages"].items() } CLOUD_FOR_RING = { "preprod": "Preprod", @@ -101,10 +96,19 @@ def _parse_profiles(output: str) -> list[dict]: None, ) if cloud: + username = next( + ( + token + for token in re.split(r"\s+", remainder) + if "@" in token and not token.casefold().startswith("http") + ), + None, + ) profiles.append( { "index": match.group(1), "active": bool(match.group(2)), + "username": username, "cloud": cloud, "environment_url": environment_url, } @@ -117,38 +121,47 @@ def ensure_pac_auth( *, ring: str, environment_url: str, + preferred_username: str | None = None, runner=_run, -) -> None: +) -> dict: """Select or create a PAC profile for the requested Power Platform ring.""" cloud = CLOUD_FOR_RING[ring] - listed = runner( - [pac_executable, "auth", "list"], - capture_output=True, - timeout=60, - ) - profiles = ( - _parse_profiles(listed.stdout or "") - if listed.returncode == 0 - else [] + normalized_environment = environment_url.rstrip("/").casefold() + normalized_username = ( + preferred_username.casefold() if preferred_username else None ) - cloud_matching = [ - profile - for profile in profiles - if profile["cloud"].casefold() == cloud.casefold() + + def matches_target(profile: dict) -> bool: + if profile["cloud"].casefold() != cloud.casefold(): + return False + if ring == "preprod" and ( + profile["environment_url"] or "" + ).casefold() != normalized_environment: + return False + if normalized_username and ( + profile["username"] or "" + ).casefold() != normalized_username: + return False + return True + + def list_profiles() -> list[dict]: + listed = runner( + [pac_executable, "auth", "list"], + capture_output=True, + timeout=60, + ) + return ( + _parse_profiles(listed.stdout or "") + if listed.returncode == 0 + else [] + ) + + matching = [ + profile for profile in list_profiles() if matches_target(profile) ] - if ring == "preprod": - normalized_environment = environment_url.rstrip("/").casefold() - matching = [ - profile - for profile in cloud_matching - if (profile["environment_url"] or "").casefold() - == normalized_environment - ] - else: - matching = cloud_matching active = [profile for profile in matching if profile["active"]] if len(active) == 1: - return + return active[0] if len(matching) == 1: selected = runner( [ @@ -163,7 +176,19 @@ def ensure_pac_auth( ) if selected.returncode != 0: raise PacCliError("PAC could not select the required auth profile.") - return + if not preferred_username: + return {**matching[0], "active": True} + verified = [ + profile + for profile in list_profiles() + if profile["active"] and matches_target(profile) + ] + if len(verified) != 1: + raise PacCliError( + "PAC selected a profile, but the active profile could not be " + "verified for the requested environment and account." + ) + return verified[0] if len(matching) > 1: raise PacCliError( f"Multiple PAC profiles exist for {cloud}. Select the correct " @@ -189,6 +214,27 @@ def ensure_pac_auth( raise PacCliError( f"PAC authentication for {cloud} did not complete successfully." ) + if not preferred_username: + return { + "index": None, + "active": True, + "username": None, + "cloud": cloud, + "environment_url": ( + environment_url.rstrip("/") if ring == "preprod" else None + ), + } + verified = [ + profile + for profile in list_profiles() + if profile["active"] and matches_target(profile) + ] + if len(verified) != 1: + raise PacCliError( + "PAC authentication completed, but the active profile does not " + "match the requested environment and maker account." + ) + return verified[0] def install_workday_package( @@ -196,9 +242,10 @@ def install_workday_package( package_flavor: str, *, ring: str, + preferred_username: str | None = None, pac_resolver=resolve_pac_executable, runner=_run, -) -> str: +) -> dict: """Install one Workday AppSource package through the supported PAC flow.""" if package_flavor not in WORKDAY_PACKAGES: raise ValueError(f"Unsupported Workday package flavor: {package_flavor}") @@ -208,10 +255,11 @@ def install_workday_package( package = WORKDAY_PACKAGES[package_flavor] pac_executable = pac_resolver() - ensure_pac_auth( + profile = ensure_pac_auth( pac_executable, ring=ring, environment_url=environment_url, + preferred_username=preferred_username, runner=runner, ) installed = runner( @@ -232,7 +280,13 @@ def install_workday_package( "PAC could not install the Workday package. Review the PAC output " "above, confirm environment access, and retry /connect workday." ) - return package["schemaName"] + return { + "schemaName": package["schemaName"], + "authenticatedAccount": profile.get("username"), + "pacProfileIndex": profile.get("index"), + "cloud": profile.get("cloud"), + "environmentUrl": environment_url, + } def main() -> None: @@ -262,6 +316,10 @@ def main() -> None: default="prod", help="Power Platform ring captured during setup.", ) + parser.add_argument( + "--preferred-username", + help="Environment Maker account that PAC must use.", + ) args = parser.parse_args() package = WORKDAY_PACKAGES[args.package_flavor] @@ -274,10 +332,11 @@ def main() -> None: "applicationName": package["applicationName"], } try: - schema_name = install_workday_package( + result = install_workday_package( args.url, args.package_flavor, ring=args.ring, + preferred_username=args.preferred_username, ) except (OSError, PacCliError, RuntimeError, ValueError) as error: print( @@ -290,7 +349,7 @@ def main() -> None: print( "INSTALLED_WORKDAY_DA_EXTENSION_JSON:" - f"{json.dumps({**base_result, 'schemaName': schema_name})}" + f"{json.dumps({**base_result, **result})}" ) diff --git a/solutions/ess-maker-skills/scripts/list_environments.py b/solutions/ess-maker-skills/scripts/list_environments.py index 701bf0643..b00dc3fa3 100644 --- a/solutions/ess-maker-skills/scripts/list_environments.py +++ b/solutions/ess-maker-skills/scripts/list_environments.py @@ -162,11 +162,11 @@ def find_environment_by_url(environments, env_url): return None -def resolve_environment_for_user(env_url): +def resolve_environment_for_user(env_url, preferred_username=None): """Resolve one Dataverse URL through the user-scoped Power Platform API.""" try: client = PowerPlatformClient(discover_tenant(env_url)) - client.authenticate() + client.authenticate(preferred_username=preferred_username) except (OSError, RuntimeError, ValueError) as exc: print(f"ERROR: Power Platform authentication failed - {exc}") sys.exit(1) diff --git a/solutions/ess-maker-skills/scripts/minimalbot_evaluation.py b/solutions/ess-maker-skills/scripts/minimalbot_evaluation.py index 58914bf6a..79404c997 100644 --- a/solutions/ess-maker-skills/scripts/minimalbot_evaluation.py +++ b/solutions/ess-maker-skills/scripts/minimalbot_evaluation.py @@ -25,11 +25,12 @@ from __future__ import annotations import base64 +import copy from datetime import datetime, timezone import fnmatch import json import os -from pathlib import Path +from pathlib import Path, PurePosixPath import re from typing import Any import uuid @@ -70,6 +71,9 @@ MCS_CONNECTOR = "shared_microsoftcopilotstudio" _TOKEN_CACHE_PATH = os.path.join(".local", ".token_cache.bin") _EVAL_KINDS = {"EvaluationSet", "EvaluationData"} +_EXPECTED_WORKDAY_TOPIC_COUNTS = { + "gptagent_copilotforemployeeselfservicehr": 21, +} # Shared session with bounded retry-with-backoff, mirroring auth.py / # powerplatform_client.py. Unlike those read-only clients this path also issues @@ -219,6 +223,154 @@ def _wire_kinds(value: Any) -> Any: return value +def _without_diagnostics(value: Any) -> Any: + if isinstance(value, dict): + return { + key: _without_diagnostics(child) + for key, child in value.items() + if key != "diagnostics" + } + if isinstance(value, list): + return [_without_diagnostics(child) for child in value] + return value + + +def blocking_diagnostics( + value: Any, + *, + path: str = "$", +) -> list[dict[str, str]]: + """Return only error diagnostics from a component Object Model tree.""" + findings: list[dict[str, str]] = [] + if isinstance(value, dict): + diagnostics = value.get("diagnostics") + if isinstance(diagnostics, list): + for index, diagnostic in enumerate(diagnostics): + if not isinstance(diagnostic, dict): + continue + kind = str(diagnostic.get("$kind") or "") + code = str(diagnostic.get("errorCode") or "") + if not code and not kind.casefold().endswith("error"): + continue + findings.append( + { + "path": f"{path}.diagnostics[{index}]", + "kind": kind, + "errorCode": code, + "message": str( + diagnostic.get("errorMessage") or "" + ), + "referenceType": str( + diagnostic.get("referenceType") or "" + ), + "referenceId": str( + diagnostic.get("referenceId") or "" + ), + } + ) + for key, child in value.items(): + if key != "diagnostics": + findings.extend( + blocking_diagnostics(child, path=f"{path}.{key}") + ) + elif isinstance(value, list): + for index, child in enumerate(value): + findings.extend( + blocking_diagnostics(child, path=f"{path}[{index}]") + ) + return findings + + +def resolve_workday_dialogs( + agent_folder: str | Path, + agent_schema: str, +) -> list[dict[str, str]]: + """Resolve the complete mapped Workday dialog set for one agent.""" + root = Path(agent_folder).resolve() + schema = str(agent_schema or "").strip() + if not schema: + raise MinimalBotEvaluationError( + "The active agent schema name is required for Workday activation." + ) + map_path = root / ".component-map.json" + try: + component_map = json.loads(map_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise MinimalBotEvaluationError( + f"Could not read the active agent component map: {map_path}: {exc}" + ) from exc + if not isinstance(component_map, dict): + raise MinimalBotEvaluationError( + "The active agent component map must contain a JSON object." + ) + + schema_prefix = f"{schema}.topic.Workday".casefold() + entries: list[dict[str, str]] = [] + component_ids: set[str] = set() + schema_names: set[str] = set() + for raw_path, raw_entry in component_map.items(): + if not isinstance(raw_path, str) or not isinstance(raw_entry, dict): + continue + component_schema = str(raw_entry.get("schemaName") or "").strip() + display_name = str(raw_entry.get("displayName") or "").strip() + if ( + raw_entry.get("componentKind") != "DialogComponent" + or not component_schema.casefold().startswith(schema_prefix) + or not display_name.startswith("Workday") + ): + continue + relative_path = raw_path.replace("\\", "/") + safe_path = PurePosixPath(relative_path) + if ( + safe_path.is_absolute() + or ".." in safe_path.parts + or not safe_path.parts + or safe_path.parts[0] != "topics" + or not relative_path.endswith(".mcs.yml") + ): + raise MinimalBotEvaluationError( + f"Unsafe Workday topic path in component map: {raw_path}" + ) + local_path = root.joinpath(*safe_path.parts) + if not local_path.is_file(): + raise MinimalBotEvaluationError( + f"Mapped Workday topic is missing: {relative_path}" + ) + component_id = str(raw_entry.get("componentId") or "").strip() + if not component_id or not component_schema: + raise MinimalBotEvaluationError( + f"Mapped Workday topic has incomplete identity: {relative_path}" + ) + normalized_id = component_id.casefold() + normalized_schema = component_schema.casefold() + if normalized_id in component_ids or normalized_schema in schema_names: + raise MinimalBotEvaluationError( + "The Workday topic map contains duplicate component identity." + ) + component_ids.add(normalized_id) + schema_names.add(normalized_schema) + entries.append( + { + "path": relative_path, + "componentId": component_id, + "schemaName": component_schema, + "displayName": display_name, + } + ) + if not entries: + raise MinimalBotEvaluationError( + "No mapped Workday dialog topics were found for the active agent." + ) + expected_count = _EXPECTED_WORKDAY_TOPIC_COUNTS.get(schema.casefold()) + if expected_count is not None and len(entries) != expected_count: + raise MinimalBotEvaluationError( + "The active ESS HR component map contains " + f"{len(entries)} Workday topics; expected {expected_count}. " + "Refresh the workspace before activation." + ) + return sorted(entries, key=lambda entry: entry["path"]) + + def _folder_matches_globs(folder: Path, root: Path, only_globs: list[str]) -> bool: """True if any ``*.mcs.yml`` in ``folder`` matches one of ``only_globs``. @@ -273,7 +425,9 @@ def _connection_id(connection: dict[str, Any]) -> str: def acquire_test_pp_token( - tenant_id: str, scope: str | None = None + tenant_id: str, + scope: str | None = None, + preferred_username: str | None = None, ) -> tuple[str, str]: """Acquire a Power Platform token, reusing the shared MSAL cache. @@ -295,15 +449,28 @@ def acquire_test_pp_token( CLIENT_ID, authority=authority, token_cache=cache ) accounts = app.get_accounts() - selected_account = accounts[0] if accounts else None + preferred = str(preferred_username or "").casefold() + selected_account = next( + ( + account + for account in accounts + if str(account.get("username") or "").casefold() == preferred + ), + None, + ) + if selected_account is None and not preferred: + selected_account = accounts[0] if accounts else None result = None if selected_account: result = app.acquire_token_silent([scope], account=selected_account) if not result or "access_token" not in result: print("Opening browser for Power Platform sign-in...") - result = app.acquire_token_interactive( - [scope], prompt="select_account" + interactive_options = ( + {"login_hint": preferred_username} + if preferred_username + else {"prompt": "select_account"} ) + result = app.acquire_token_interactive([scope], **interactive_options) if "access_token" not in result: # Don't echo error_description (CWE-209); mirror auth.py. error = result.get("error", "unknown_error") @@ -389,11 +556,21 @@ def from_config(cls, config: dict[str, Any]) -> "MinimalBotEvaluationClient": ) # -- auth --------------------------------------------------------------- - def authenticate(self) -> str: + def authenticate(self, preferred_username: str | None = None) -> str: """Acquire a Power Platform token for this ring, reusing the MSAL cache.""" self._token, self.signed_in_username = acquire_test_pp_token( - self.tenant_id, scope=self.scope + self.tenant_id, + scope=self.scope, + preferred_username=preferred_username, ) + if preferred_username and ( + str(self.signed_in_username or "").casefold() + != preferred_username.casefold() + ): + raise MinimalBotEvaluationError( + "Power Platform authentication used a different account from " + "the selected Environment Maker." + ) return self._token def _require_token(self) -> str: @@ -457,6 +634,251 @@ def read_components(self) -> dict[str, Any]: ) return body + def update_dialog_components( + self, + updates: list[dict[str, Any]], + ) -> dict[str, Any]: + """Update existing dialog components with drift and identity checks.""" + if not updates: + raise MinimalBotEvaluationError( + "No MinimalBot dialog updates were requested." + ) + + before = self.read_components() + change_token = str(before.get("changeToken") or "") + if not change_token: + raise MinimalBotEvaluationError( + "MinimalBot component read did not return a changeToken." + ) + remote_changes = before.get("botComponentChanges") + if not isinstance(remote_changes, list): + raise MinimalBotEvaluationError( + "MinimalBot component read returned no component changes." + ) + + changes: list[dict[str, Any]] = [] + for update in updates: + component_id = str(update.get("componentId") or "") + schema_name = str(update.get("schemaName") or "") + matches = [ + change.get("component") + for change in remote_changes + if isinstance(change, dict) + and isinstance(change.get("component"), dict) + and str(change["component"].get("id") or "").casefold() + == component_id.casefold() + ] + if len(matches) != 1: + raise MinimalBotEvaluationError( + "The selected MinimalBot dialog component is no longer " + "unique." + ) + component = matches[0] + if ( + component.get("$kind") != "DialogComponent" + or str(component.get("schemaName") or "").casefold() + != schema_name.casefold() + ): + raise MinimalBotEvaluationError( + "The selected MinimalBot dialog identity changed after " + "local extraction." + ) + has_dialog_update = "dialog" in update + desired_dialog = update.get("dialog") + expected_dialog = update.get("expectedDialog") + if has_dialog_update and ( + not isinstance(expected_dialog, dict) + or not isinstance(desired_dialog, dict) + ): + raise MinimalBotEvaluationError( + "MinimalBot dialog content updates require expected and " + "desired Object Model payloads." + ) + desired_state = update.get("state") + desired_status = update.get("status") + if ( + not has_dialog_update + and desired_state is None + and desired_status is None + ): + raise MinimalBotEvaluationError( + "MinimalBot dialog update did not request content or state " + "changes." + ) + remote_dialog = _without_diagnostics(component.get("dialog")) + dialog_is_desired = ( + not has_dialog_update + or remote_dialog == _without_diagnostics(desired_dialog) + ) + state_is_desired = ( + desired_state is None + or component.get("state") == desired_state + ) + status_is_desired = ( + desired_status is None + or component.get("status") == desired_status + ) + if dialog_is_desired and state_is_desired and status_is_desired: + continue + if ( + has_dialog_update + and not dialog_is_desired + and remote_dialog != _without_diagnostics(expected_dialog) + ): + raise MinimalBotEvaluationError( + "The selected MinimalBot dialog changed remotely after " + "the workspace baseline was captured." + ) + updated_component = copy.deepcopy(component) + if has_dialog_update: + updated_component["dialog"] = desired_dialog + if desired_state is not None: + updated_component["state"] = desired_state + if desired_status is not None: + updated_component["status"] = desired_status + changes.append( + { + "$kind": "BotComponentUpdate", + "component": updated_component, + } + ) + + if changes: + payload = { + "changeToken": change_token, + "botComponentChanges": changes, + "cloudFlowDefinitionChanges": [], + "connectionReferenceChanges": [], + "connectorDefinitionChanges": [], + "environmentVariableChanges": [], + "aIPluginOperationChanges": [], + "componentCollectionChanges": [], + "dataverseTableSearchChanges": [], + "connectedAgentDefinitionChanges": [], + } + response, _ = self._request( + "PUT", + self._components_url, + body=payload, + operation="dialog update", + ) + if response.status_code != 200: + raise MinimalBotEvaluationError( + "MinimalBot dialog update failed " + f"(HTTP {response.status_code})." + ) + + verified = self.read_components() + verification = self._verify_dialog_components_payload( + updates, + verified, + ) + return { + "updatedComponents": len(changes), + **verification, + } + + def verify_dialog_components( + self, + expectations: list[dict[str, Any]], + ) -> dict[str, Any]: + """Reread and verify existing dialog identity, state, and diagnostics.""" + if not expectations: + raise MinimalBotEvaluationError( + "No MinimalBot dialog verification was requested." + ) + return self._verify_dialog_components_payload( + expectations, + self.read_components(), + ) + + @staticmethod + def _verify_dialog_components_payload( + updates: list[dict[str, Any]], + verified: dict[str, Any], + ) -> dict[str, Any]: + verified_changes = verified.get("botComponentChanges") + if not isinstance(verified_changes, list): + raise MinimalBotEvaluationError( + "MinimalBot dialog verification returned no component changes." + ) + verified_by_id: dict[str, list[dict[str, Any]]] = {} + for change in verified_changes: + if not isinstance(change, dict): + continue + component = change.get("component") + if not isinstance(component, dict): + continue + component_id = str(component.get("id") or "").casefold() + if component_id: + verified_by_id.setdefault(component_id, []).append(component) + all_diagnostics: list[dict[str, str]] = [] + for update in updates: + component_id = str(update["componentId"]) + matches = verified_by_id.get(component_id.casefold()) or [] + if len(matches) != 1: + raise MinimalBotEvaluationError( + "MinimalBot dialog verification found a missing or " + f"duplicate component ID: {component_id}." + ) + component = matches[0] + expected_schema = str(update.get("schemaName") or "") + verified_update = ( + component.get("$kind") == "DialogComponent" + and str(component.get("schemaName") or "").casefold() + == expected_schema.casefold() + ) + if verified_update and "dialog" in update: + verified_update = _without_diagnostics( + component.get("dialog") + ) == _without_diagnostics(update["dialog"]) + if verified_update and update.get("state") is not None: + verified_update = ( + component.get("state") == update["state"] + ) + if verified_update and update.get("status") is not None: + verified_update = ( + component.get("status") == update["status"] + ) + diagnostics = ( + blocking_diagnostics(component) + if component is not None + else [] + ) + for diagnostic in diagnostics: + all_diagnostics.append( + { + **diagnostic, + "componentId": component_id, + "schemaName": str(update.get("schemaName") or ""), + } + ) + if ( + diagnostics + and update.get("requireCleanDiagnostics") is True + ): + first = diagnostics[0] + detail = first["errorCode"] or first["kind"] or "error" + raise MinimalBotEvaluationError( + "MinimalBot dialog has blocking diagnostics after " + f"verification ({update.get('schemaName')}): {detail}." + ) + if not verified_update: + raise MinimalBotEvaluationError( + "MinimalBot dialog verification failed for component " + f"{component_id}." + ) + return { + "verifiedComponents": len(updates), + "activeComponents": sum( + 1 + for update in updates + if update.get("state") == "Active" + and update.get("status") == "Active" + ), + "blockingDiagnostics": all_diagnostics, + } + # -- push --------------------------------------------------------------- def push_agent_evaluations( self, diff --git a/solutions/ess-maker-skills/scripts/push.py b/solutions/ess-maker-skills/scripts/push.py index 8ab0eb42f..072d67263 100644 --- a/solutions/ess-maker-skills/scripts/push.py +++ b/solutions/ess-maker-skills/scripts/push.py @@ -51,10 +51,15 @@ metadata_description, parse_review_metadata, ) +from agentbuilder_object_model import ( + ObjectModelConverterError, + yaml_to_object_models, +) from minimalbot_evaluation import ( MinimalBotEvaluationClient, MinimalBotEvaluationError, is_minimalbot, + resolve_workday_dialogs, ) EXCLUDE_DIRS = {".baseline", ".checkpoints"} @@ -1119,6 +1124,34 @@ def update_baseline_scoped(agent_dir, only_globs): pass +def update_baseline_paths(agent_dir, relative_paths): + """Refresh exact successfully pushed files in the local baseline.""" + import shutil + + baseline_dir = os.path.join(agent_dir, ".baseline") + working = collect_files(agent_dir) + selected = { + str(path).replace("\\", "/") + for path in relative_paths + if isinstance(path, str) and path + } + for rel in list(selected): + if rel.startswith("template-configs/") and rel.endswith(".xml"): + meta = rel[:-4] + ".meta.json" + if meta in working: + selected.add(meta) + + for rel in selected: + if rel not in working: + raise OSError( + f"Successfully pushed baseline path is missing locally: {rel}" + ) + src = os.path.join(agent_dir, *rel.split("/")) + dst = os.path.join(baseline_dir, *rel.split("/")) + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.copy2(src, dst) + + def _warn_minimalbot_non_eval_changes(agent_dir): """Report pending non-evaluation changes the MinimalBot push cannot deploy. @@ -1151,6 +1184,183 @@ def _warn_minimalbot_non_eval_changes(agent_dir): print(f" ... and {len(non_eval) - 20} more") +def _minimalbot_topic_update_plan( + agent_dir, + only_globs, + *, + activate_topics=False, + agent_schema=None, +): + """Build guarded updates for scoped, existing MinimalBot topics.""" + if not only_globs: + return [] + baseline_dir = os.path.join(agent_dir, ".baseline") + if not os.path.isdir(baseline_dir): + return [] + baseline = collect_files(baseline_dir) + working = collect_files(agent_dir) + changed, new, deleted = compute_diff(baseline, working) + selected_changed = sorted( + path + for path in changed + if matches_only(path, only_globs) + and path.replace("\\", "/").startswith("topics/") + and path.endswith(".mcs.yml") + ) + selected_new_or_deleted = sorted( + path + for path in (*new, *deleted) + if matches_only(path, only_globs) + and path.replace("\\", "/").startswith("topics/") + ) + if selected_new_or_deleted: + raise MinimalBotEvaluationError( + "MinimalBot scoped topic push supports updates to existing topics " + "only; create/delete is not allowed: " + + ", ".join(selected_new_or_deleted) + ) + raw_component_map = load_component_map(agent_dir) + component_map = {} + for raw_path, entry in raw_component_map.items(): + normalized_path = str(raw_path).replace("\\", "/") + if normalized_path in component_map: + raise MinimalBotEvaluationError( + "The component map contains duplicate normalized paths." + ) + component_map[normalized_path] = entry + + selected_activation = [] + if activate_topics: + workday_entries = resolve_workday_dialogs( + agent_dir, + agent_schema or "", + ) + workday_by_path = { + entry["path"]: entry + for entry in workday_entries + } + selected_activation = sorted( + path + for path in workday_by_path + if matches_only(path, only_globs) + ) + missing = sorted(set(workday_by_path) - set(selected_activation)) + selected_dialogs = { + str(path).replace("\\", "/") + for path, entry in component_map.items() + if isinstance(path, str) + and isinstance(entry, dict) + and entry.get("componentKind") == "DialogComponent" + and matches_only(path, only_globs) + } + non_workday = sorted(selected_dialogs - set(workday_by_path)) + if missing or non_workday: + details = [] + if missing: + details.append( + f"{len(missing)} mapped Workday topic(s) were omitted" + ) + if non_workday: + details.append( + "the activation scope also matched non-Workday topics: " + + ", ".join(non_workday) + ) + raise MinimalBotEvaluationError( + "Workday activation must target exactly the complete mapped " + "Workday topic set; " + + "; ".join(details) + + "." + ) + changed_workday = sorted( + path for path in selected_changed if path in workday_by_path + ) + if changed_workday: + raise MinimalBotEvaluationError( + "Workday activation cannot publish pending topic content " + "changes. Push or discard these changes separately before " + "activation: " + + ", ".join(changed_workday) + ) + selected_changed = [] + selected_paths = sorted( + set(selected_changed) | set(selected_activation) + ) + if not selected_paths: + return [] + + conversion_items = [] + for path in selected_changed: + conversion_items.extend( + ( + {"key": f"{path}:baseline", "yaml": baseline[path]}, + {"key": f"{path}:working", "yaml": working[path]}, + ) + ) + try: + converted = yaml_to_object_models(conversion_items) + except ObjectModelConverterError as exc: + raise MinimalBotEvaluationError( + f"Could not convert scoped topic YAML: {exc}" + ) from exc + converted_by_key = {entry["key"]: entry for entry in converted} + + updates = [] + for path in selected_paths: + entry = component_map.get(path) + if not isinstance(entry, dict): + raise MinimalBotEvaluationError( + f"Scoped topic is missing from .component-map.json: {path}" + ) + if entry.get("componentKind") != "DialogComponent": + raise MinimalBotEvaluationError( + f"Scoped path is not a dialog component: {path}" + ) + component_id = str(entry.get("componentId") or "").strip() + schema_name = str(entry.get("schemaName") or "").strip() + if not component_id or not schema_name: + raise MinimalBotEvaluationError( + f"Scoped topic identity is incomplete in the component map: " + f"{path}" + ) + update = { + "path": path, + "componentId": component_id, + "schemaName": schema_name, + "displayName": str(entry.get("displayName") or path), + } + if path in selected_changed: + baseline_result = converted_by_key[f"{path}:baseline"] + working_result = converted_by_key[f"{path}:working"] + failed = next( + ( + result + for result in (baseline_result, working_result) + if result.get("success") is not True + ), + None, + ) + if failed: + error = failed.get("error") or {} + raise MinimalBotEvaluationError( + f"Could not convert scoped topic {path}: " + f"{error.get('message') or 'unknown conversion error'}" + ) + if ( + baseline_result.get("elementType") != "AdaptiveDialog" + or working_result.get("elementType") != "AdaptiveDialog" + ): + raise MinimalBotEvaluationError( + f"Scoped topic is not an AdaptiveDialog: {path}" + ) + update["expectedDialog"] = baseline_result["objectModel"] + update["dialog"] = working_result["objectModel"] + if path in selected_activation: + update["state"] = "Active" + update["status"] = "Active" + updates.append(update) + return updates + + def _minimalbot_push( config, *, @@ -1159,16 +1369,15 @@ def _minimalbot_push( repair_mode=False, only_globs=None, auto_yes=False, + preferred_username=None, + activate_topics=False, ): - """Push evaluation sets to a Dataverse-free MinimalBot agent. + """Push supported changes to a Dataverse-free MinimalBot agent. Uses the Power Platform MinimalBot components API on the agent's ring (see - :mod:`minimalbot_evaluation`). Only evaluation components are supported for - MinimalBot agents today; other component types (topics, workflows) still - require a Dataverse-backed environment. Destructive/scoped/repair flags are - rejected or honoured rather than silently ignored, so a ``--force-delete`` - never turns into a duplicate insert and a scoped ``--only`` never expands - into an every-set push. + :mod:`minimalbot_evaluation`). Evaluation inserts and scoped updates to + existing dialog topics are supported. Other component types still require + a different authoring path. The script-level confirmation gate (a second safety layer the ``/push`` prompt relies on) is enforced here before any component insert, mirroring @@ -1194,13 +1403,65 @@ def _minimalbot_push( ) sys.exit(1) - _warn_minimalbot_non_eval_changes(agent_dir) - print("MinimalBot agent detected (Dataverse-free).") if only_globs: print(f"(Scoped push — {len(only_globs)} filter(s) active)") client = MinimalBotEvaluationClient.from_config(config) + try: + topic_updates = _minimalbot_topic_update_plan( + agent_dir, + only_globs, + activate_topics=activate_topics, + agent_schema=(config.get("agent") or {}).get("schemaName"), + ) + except MinimalBotEvaluationError as exc: + print(f"ERROR: {exc}") + sys.exit(1) + + if topic_updates: + print(f"\nWould update {len(topic_updates)} existing topic(s):") + for entry in topic_updates: + print(f" • {entry['displayName']} ({entry['path']})") + if dry_run: + print("\n(Dry run — no changes pushed)") + return + if not auto_yes: + response = input( + "\nPush these changes to Copilot Studio? (yes/no): " + ).strip().lower() + if response not in ("yes", "y"): + print("Push cancelled.") + return + try: + client.authenticate(preferred_username=preferred_username) + result = client.update_dialog_components(topic_updates) + except MinimalBotEvaluationError as exc: + print(f"ERROR: {exc}") + sys.exit(1) + pushed_content_paths = [ + entry["path"] + for entry in topic_updates + if "dialog" in entry + ] + if pushed_content_paths: + update_baseline_paths(agent_dir, pushed_content_paths) + if client.signed_in_username: + print(f"Signed in as: {client.signed_in_username}") + print( + f"\n✅ Updated and verified " + f"{result['verifiedComponents']} topic component(s)." + ) + diagnostics = result.get("blockingDiagnostics") or [] + if diagnostics: + print( + "WARNING: The topics are enabled, but " + f"{len(diagnostics)} dependency diagnostic(s) remain. " + "Continue with Workday connection verification." + ) + return + + _warn_minimalbot_non_eval_changes(agent_dir) # Build the plan offline first (no auth, no mutation) so the change set can # be shown and confirmed BEFORE any component insert. Honouring only_globs @@ -1263,6 +1524,14 @@ def main(): if _idx + 1 < len(sys.argv) and not sys.argv[_idx + 1].startswith("-"): repair_name = sys.argv[_idx + 1] only_globs = parse_only_globs(sys.argv[1:]) + preferred_username = None + if "--preferred-username" in sys.argv: + index = sys.argv.index("--preferred-username") + if index + 1 >= len(sys.argv) or sys.argv[index + 1].startswith("-"): + print("ERROR: --preferred-username requires a value.") + sys.exit(1) + preferred_username = sys.argv[index + 1] + activate_topics = "--activate" in sys.argv config = load_config() @@ -1277,8 +1546,17 @@ def main(): repair_mode=repair_mode, only_globs=only_globs, auto_yes=auto_yes, + preferred_username=preferred_username, + activate_topics=activate_topics, ) + if activate_topics: + print( + "ERROR: --activate is supported only for Dataverse-free " + "(MinimalBot) agents." + ) + sys.exit(1) + agent_dir = config["agent"]["folder"] env_url = config["dataverseEndpoint"] bot_id = config["agent"]["botId"] diff --git a/solutions/ess-maker-skills/scripts/workday_connect.py b/solutions/ess-maker-skills/scripts/workday_connect.py new file mode 100644 index 000000000..e162916cf --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_connect.py @@ -0,0 +1,649 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Deterministic controller for the six-phase Workday connect lifecycle.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys +from typing import Any, Callable + +from workday_connect_agent import ( + WorkdayConnectAgentError, + verify_agent_binding, + verify_topic_activation, +) +from workday_connect_model import ( + CONTROLLER_CONTRACT_VERSION, + WorkdayConnectModelError, + workday_saml_entity_id, +) +from workday_connect_contracts import ( + WorkdayConnectContractError, + build_entra_handoff, + build_workday_admin_packet, + validate_agent_binding_evidence, + validate_employee_evidence, + validate_employee_failure_evidence, + validate_entra_verification, + validate_workday_admin_response, +) +from workday_connect_preflight import ( + WorkdayConnectPreflightError, + run_preflight, +) +from workday_connect_runtime import ( + WorkdayConnectRuntimeError, + run_runtime_operation, + verify_physical_connections, +) +from workday_connect_store import ( + WorkdayConnectPlanChangedError, + WorkdayConnectStore, + WorkdayConnectStoreError, +) + + +RESULT_MARKER = "WORKDAY_CONNECT_RESULT_JSON:" +ERROR_MARKER = "WORKDAY_CONNECT_ERROR_JSON:" + + +def _json_object(value: str, label: str) -> dict[str, Any]: + try: + document = json.loads(value) + except json.JSONDecodeError as exc: + raise WorkdayConnectStoreError(f"{label} must be valid JSON: {exc}") from exc + if not isinstance(document, dict): + raise WorkdayConnectStoreError(f"{label} must be a JSON object.") + return document + + +def _add_json_input( + parser: argparse.ArgumentParser, + name: str, + *, + allow_legacy_inline: bool = True, +) -> None: + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( + f"--{name}-file", + type=Path, + help=f"Path to the {name.replace('-', ' ')} JSON object.", + ) + if allow_legacy_inline: + group.add_argument( + f"--{name}-json", + help=argparse.SUPPRESS, + ) + + +def _json_input( + args: argparse.Namespace, + name: str, + label: str, +) -> dict[str, Any]: + file_path = getattr(args, f"{name.replace('-', '_')}_file", None) + if file_path is not None: + try: + value = file_path.read_text(encoding="utf-8") + except OSError as exc: + raise WorkdayConnectStoreError( + f"{label} file could not be read: {file_path}: {exc}" + ) from exc + return _json_object(value, label) + value = getattr(args, f"{name.replace('-', '_')}_json", None) + if value is None: + raise WorkdayConnectStoreError( + f"{label} requires a JSON input file." + ) + return _json_object(value, label) + + +def _emit(operation: str, result: dict[str, Any]) -> None: + print( + RESULT_MARKER + + json.dumps( + { + "contractVersion": CONTROLLER_CONTRACT_VERSION, + "operation": operation, + **result, + }, + sort_keys=True, + ) + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Manage the Workday connect lifecycle." + ) + parser.add_argument( + "--root", + default=".", + help="Workspace root containing .local state.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("status") + + tenant = subparsers.add_parser("set-workday-tenant") + tenant.add_argument("--tenant", required=True) + + entra_handoff = subparsers.add_parser("entra-handoff") + _add_json_input(entra_handoff, "discovery") + record_entra = subparsers.add_parser("record-entra") + _add_json_input(record_entra, "verification") + subparsers.add_parser("workday-admin-packet") + record_admin = subparsers.add_parser("record-workday-admin") + _add_json_input(record_admin, "response") + + runtime_plan = subparsers.add_parser("runtime-plan") + runtime_plan.add_argument("--workday-connection-id") + runtime_plan.add_argument("--dataverse-connection-id") + + runtime_apply = subparsers.add_parser("runtime-apply") + runtime_apply.add_argument("--plan-hash", required=True) + runtime_apply.add_argument("--workday-connection-id") + runtime_apply.add_argument("--dataverse-connection-id") + runtime_approve = subparsers.add_parser("runtime-approve") + _add_json_input(runtime_approve, "plan") + record_connections = subparsers.add_parser("record-connections") + record_connections.add_argument("--workday-connection-id") + record_connections.add_argument("--dataverse-connection-id") + record_connections.add_argument( + "--confirm-workday-target", + action="store_true", + ) + record_connections.add_argument( + "--evidence-json", + help=argparse.SUPPRESS, + ) + subparsers.add_parser("record-topic-activation") + record_binding = subparsers.add_parser("record-agent-binding") + _add_json_input( + record_binding, + "attachment", + allow_legacy_inline=False, + ) + record_validation = subparsers.add_parser("record-validation") + _add_json_input(record_validation, "evidence") + record_validation_failure = subparsers.add_parser( + "record-validation-failure" + ) + _add_json_input( + record_validation_failure, + "evidence", + allow_legacy_inline=False, + ) + + preflight = subparsers.add_parser("preflight") + preflight.add_argument("--dataverse-url") + preflight.add_argument("--maker-username") + preflight.add_argument("--install-plan-hash") + preflight_approve = subparsers.add_parser("preflight-approve") + _add_json_input( + preflight_approve, + "plan", + allow_legacy_inline=False, + ) + + return parser + + +def _status( + _args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + return store.status() + + +def _set_workday_tenant( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + tenant = args.tenant.strip() + workday_saml_entity_id(tenant) + state = store.merge_section("scope", {"workdayTenant": tenant}) + return { + "workdayTenant": state["scope"]["workdayTenant"], + "status": store.status(), + } + + +def _entra_handoff( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + return { + "packet": build_entra_handoff( + store.load(), + _json_input(args, "discovery", "Entra discovery"), + ) + } + + +def _record_entra( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + result = validate_entra_verification( + store.load(), + _json_input(args, "verification", "Entra verification"), + ) + store.merge_section("identifiers", result["identifiers"]) + store.complete_action( + "entra", + "exact-application-discovered", + evidence={ + "outcome": "verified", + "tenantId": result["evidence"]["tenantId"], + "applicationDisplayName": result["evidence"]["applicationDisplayName"], + }, + ) + store.complete_action( + "entra", + "administrator-configuration-verified", + evidence={ + "outcome": "verified", + "checks": result["evidence"]["checks"], + }, + ) + store.set_phase_status("entra", "complete") + _, reused = store.restore_workday_foundation() + return { + "verified": True, + "tenantFoundationReused": reused, + "status": store.status(), + } + + +def _workday_admin_packet( + _args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + return {"packet": build_workday_admin_packet(store.load())} + + +def _record_workday_admin( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + result = validate_workday_admin_response( + store.load(), + _json_input(args, "response", "Workday administrator response"), + ) + store.merge_section("identifiers", result["identifiers"]) + store.merge_section("endpoints", result["endpoints"]) + store.complete_action( + "workday-admin", + "administrator-response-validated", + evidence={"outcome": "verified", **result["evidence"]}, + ) + store.set_phase_status("workday-admin", "complete") + store.capture_tenant_foundation() + return { + "verified": True, + "status": store.status(), + } + + +def _runtime_plan( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + return run_runtime_operation( + store.load(), + apply=False, + workday_connection_id=args.workday_connection_id, + dataverse_connection_id=args.dataverse_connection_id, + ) + + +def _runtime_apply( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + result = run_runtime_operation( + store.load(), + apply=True, + approved_hash=args.plan_hash, + verifier=lambda plan, approved_hash: store.verify_plan( + "runtime", + plan, + approved_hash, + ), + workday_connection_id=args.workday_connection_id, + dataverse_connection_id=args.dataverse_connection_id, + stage_recorder=lambda action, evidence: store.complete_action( + "runtime", + action, + evidence=evidence, + ), + ) + return {**result, "status": store.status()} + + +def _runtime_approve( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + _, approved_hash = store.approve_plan( + "runtime", + _json_input(args, "plan", "runtime plan"), + ) + return {"planHash": approved_hash, "status": store.status()} + + +def _record_connections( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + if getattr(args, "evidence_json", None) is not None: + raise WorkdayConnectStoreError( + "Manual connection evidence is no longer accepted. Run " + "record-connections without --evidence-json so the controller " + "can verify the live connections." + ) + state = store.load() + evidence = verify_physical_connections( + state, + workday_connection_id=args.workday_connection_id, + dataverse_connection_id=args.dataverse_connection_id, + ) + if not getattr(args, "confirm_workday_target", False): + identifiers = state.get("identifiers") or {} + endpoints = state.get("endpoints") or {} + return { + "requiresConfirmation": True, + "connections": evidence["connections"], + "workdayTarget": { + "resourceUrl": identifiers.get("workdaySamlEntityId"), + "oauthTokenUrl": endpoints.get("oauthTokenUrl"), + "oauthClientId": identifiers.get("oauthClientId"), + }, + "status": store.status(), + } + store.complete_action( + "connections", + "physical-connections-verified", + evidence={ + "outcome": "verified", + "source": "live-power-platform-discovery", + "makerUsername": evidence["makerUsername"], + "connections": evidence["connections"], + "connectionIds": evidence["connectionIds"], + "workdayTargetOutcome": "maker-confirmed-against-workday-packet", + }, + ) + store.set_phase_status("connections", "complete") + return {"verified": True, "status": store.status()} + + +def _record_agent_binding( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + flow_attachment = _json_input( + args, + "attachment", + "Workday flow attachment confirmation", + ) + state = store.load() + live_evidence = verify_agent_binding(store.workspace_root, state) + live_evidence["flowAttachment"] = flow_attachment + evidence = validate_agent_binding_evidence( + state, + live_evidence, + ) + store.complete_action( + "runtime", + "user-context-v2-configured", + evidence={ + "outcome": "verified", + "checkpoint": "WD-REST-002", + "result": evidence["checkpoints"]["WD-REST-002"], + }, + ) + store.complete_action( + "runtime", + "agent-parameter-sharing-verified", + evidence={ + "outcome": "verified", + "checkpoint": "WD-CONN-013", + "result": evidence["checkpoints"]["WD-CONN-013"], + }, + ) + store.complete_action( + "runtime", + "flow-attachment-confirmed", + evidence={ + "outcome": "verified", + "environmentId": evidence["environmentId"], + "botId": evidence["botId"], + "makerUsername": evidence["makerUsername"], + "flowNames": evidence["flowAttachment"]["flowNames"], + "parameterSharingOutcome": ( + evidence["flowAttachment"]["parameterSharingOutcome"] + ), + "provenance": "maker-confirmed-selected-agent-settings", + }, + ) + store.complete_action( + "runtime", + "workday-topics-activated", + evidence={ + "outcome": "verified", + "expected": evidence["workdayTopics"]["expected"], + "verified": evidence["workdayTopics"]["verified"], + "active": evidence["workdayTopics"]["active"], + "blockingDiagnostics": ( + evidence["workdayTopics"]["blockingDiagnostics"] + ), + }, + ) + store.set_phase_status("runtime", "complete") + return {"verified": True, "status": store.status()} + + +def _record_topic_activation( + _args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + evidence = verify_topic_activation( + store.workspace_root, + store.load(), + ) + topics = evidence["workdayTopics"] + store.complete_action( + "runtime", + "workday-topics-activated", + evidence={ + "outcome": "verified", + "environmentId": evidence["environmentId"], + "botId": evidence["botId"], + "makerUsername": evidence["makerUsername"], + "expected": topics["expected"], + "verified": topics["verified"], + "active": topics["active"], + "blockingDiagnostics": topics["blockingDiagnostics"], + }, + ) + diagnostics = topics["blockingDiagnostics"] + return { + "verified": True, + "diagnosticsObserved": len(diagnostics), + "status": store.status(), + } + + +def _record_validation( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + evidence = validate_employee_evidence( + _json_input(args, "evidence", "employee validation evidence") + ) + store.complete_action( + "employee-validation", + "signed-in-scenario", + evidence=evidence, + ) + store.set_phase_status("employee-validation", "complete") + return {"verified": True, "status": store.status()} + + +def _record_validation_failure( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + evidence = validate_employee_failure_evidence( + _json_input( + args, + "evidence", + "employee validation failure evidence", + ) + ) + store.set_phase_status( + "employee-validation", + "blocked", + blocker={ + "operation": "record-validation-failure", + "errorType": evidence["failureCategory"], + "message": evidence["remediation"], + "capturedAt": evidence["timestamp"], + }, + ) + return {"recorded": True, "status": store.status()} + + +def _preflight( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + return run_preflight( + Path(args.root), + dataverse_url=args.dataverse_url, + maker_username=args.maker_username, + store=store, + approved_install_hash=args.install_plan_hash, + plan_verifier=lambda plan, approved_hash: store.verify_plan( + "preflight", + plan, + approved_hash, + ), + ) + + +def _preflight_approve( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + _, approved_hash = store.approve_plan( + "preflight", + _json_input(args, "plan", "preflight installation plan"), + ) + return {"planHash": approved_hash, "status": store.status()} + + +_COMMAND_HANDLERS: dict[ + str, + Callable[[argparse.Namespace, WorkdayConnectStore], dict[str, Any]], +] = { + "status": _status, + "set-workday-tenant": _set_workday_tenant, + "entra-handoff": _entra_handoff, + "record-entra": _record_entra, + "workday-admin-packet": _workday_admin_packet, + "record-workday-admin": _record_workday_admin, + "runtime-plan": _runtime_plan, + "runtime-apply": _runtime_apply, + "runtime-approve": _runtime_approve, + "record-connections": _record_connections, + "record-topic-activation": _record_topic_activation, + "record-agent-binding": _record_agent_binding, + "record-validation": _record_validation, + "record-validation-failure": _record_validation_failure, + "preflight": _preflight, + "preflight-approve": _preflight_approve, +} + +_COMMAND_PHASES = { + "preflight": "preflight", + "set-workday-tenant": "entra", + "entra-handoff": "entra", + "record-entra": "entra", + "workday-admin-packet": "workday-admin", + "record-workday-admin": "workday-admin", + "record-connections": "connections", + "runtime-plan": "runtime", + "runtime-approve": "runtime", + "runtime-apply": "runtime", + "record-topic-activation": "runtime", + "record-agent-binding": "runtime", + "record-validation": "employee-validation", + "record-validation-failure": "employee-validation", + "preflight-approve": "preflight", +} + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + store = WorkdayConnectStore(Path(args.root)) + try: + handler = _COMMAND_HANDLERS.get(args.command) + if handler is None: + parser.error(f"Unsupported command: {args.command}") + _emit(args.command, handler(args, store)) + except ( + OSError, + WorkdayConnectModelError, + WorkdayConnectAgentError, + WorkdayConnectContractError, + WorkdayConnectPlanChangedError, + WorkdayConnectPreflightError, + WorkdayConnectRuntimeError, + WorkdayConnectStoreError, + ) as exc: + phase_id = _COMMAND_PHASES.get(args.command) + blocker_persistence_error = None + if phase_id: + try: + store.set_phase_status( + phase_id, + "blocked", + blocker={ + "operation": args.command, + "errorType": type(exc).__name__, + "message": str(exc), + }, + ) + except ( + OSError, + WorkdayConnectModelError, + WorkdayConnectStoreError, + ) as persistence_exc: + blocker_persistence_error = str(persistence_exc) + error_payload = { + "contractVersion": CONTROLLER_CONTRACT_VERSION, + "operation": args.command, + "error": str(exc), + "errorType": type(exc).__name__, + } + details = getattr(exc, "details", None) + if isinstance(details, dict) and details: + error_payload["details"] = details + if blocker_persistence_error: + error_payload["blockerPersistenceError"] = blocker_persistence_error + print( + ERROR_MARKER + + json.dumps(error_payload, sort_keys=True), + file=sys.stderr, + ) + raise SystemExit(1) from exc + + +if __name__ == "__main__": + main() diff --git a/solutions/ess-maker-skills/scripts/workday_connect_agent.py b/solutions/ess-maker-skills/scripts/workday_connect_agent.py new file mode 100644 index 000000000..0dc0a4408 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_connect_agent.py @@ -0,0 +1,344 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Live native-agent verification for Workday lifecycle completion.""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys +import tempfile +from typing import Any, Callable, Mapping + +from minimalbot_evaluation import ( + MinimalBotEvaluationClient, + MinimalBotEvaluationError, + resolve_workday_dialogs, +) + + +class WorkdayConnectAgentError(RuntimeError): + """Raised when native Workday agent readiness cannot be proven.""" + + +def _read_json(path: Path) -> dict[str, Any]: + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise WorkdayConnectAgentError( + f"Required workspace state could not be read: {path}: {exc}" + ) from exc + if not isinstance(document, dict): + raise WorkdayConnectAgentError( + f"Required workspace state must contain an object: {path}" + ) + return document + + +def _required_text( + values: Mapping[str, Any], + key: str, + label: str, +) -> str: + value = str(values.get(key) or "").strip() + if not value: + raise WorkdayConnectAgentError(f"{label} is missing.") + return value + + +def _active_agent(config: Mapping[str, Any]) -> dict[str, Any]: + active_slug = str(config.get("activeAgent") or "").strip() + agents = config.get("agents") + if isinstance(agents, list) and active_slug: + matches = [ + dict(agent) + for agent in agents + if isinstance(agent, Mapping) + and str(agent.get("slug") or "") == active_slug + ] + if len(matches) == 1: + return matches[0] + agent = config.get("agent") + if isinstance(agent, Mapping) and str(agent.get("slug") or "") == active_slug: + return dict(agent) + raise WorkdayConnectAgentError( + "The active agent could not be resolved from foundation setup state." + ) + + +def _agent_folder( + workspace_root: Path, + agent: Mapping[str, Any], +) -> Path: + configured = str(agent.get("folder") or "").strip() + if configured: + candidate = Path(configured) + if not candidate.is_absolute(): + candidate = workspace_root / candidate + else: + candidate = ( + workspace_root + / "workspace" + / "agents" + / _required_text(agent, "slug", "Active agent slug") + ) + candidate = candidate.resolve() + agents_root = (workspace_root / "workspace" / "agents").resolve() + try: + candidate.relative_to(agents_root) + except ValueError as exc: + raise WorkdayConnectAgentError( + "The active agent folder is outside workspace/agents." + ) from exc + if not candidate.is_dir(): + raise WorkdayConnectAgentError( + f"The active agent workspace folder does not exist: {candidate}" + ) + return candidate + + +def run_flightcheck_checkpoint( + workspace_root: Path, + state: Mapping[str, Any], + checkpoint_id: str, + *, + preferred_username: str, + runner: Callable[..., subprocess.CompletedProcess] = subprocess.run, +) -> str: + """Run one checkpoint and require an explicit Passed result row.""" + scope = state.get("scope") or {} + agent = scope.get("agent") or {} + command = [ + sys.executable, + str(workspace_root / "scripts" / "flightcheck" / "cli.py"), + "--checkpoint", + checkpoint_id, + "--connect-config", + str(workspace_root / ".local" / "connect" / "workday-da" / "config.json"), + "--agent-slug", + _required_text(agent, "slug", "Active agent slug"), + "--environment-id", + _required_text(scope, "environmentId", "Environment ID"), + "--environment-url", + _required_text(scope, "dataverseUrl", "Dataverse URL"), + "--preferred-username", + preferred_username, + "--quiet-auth", + "--no-open", + "--no-telemetry", + ] + with tempfile.TemporaryDirectory(prefix="workday-checkpoint-") as output: + command.extend(["--output", output]) + try: + completed = runner( + command, + cwd=workspace_root, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=300, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise WorkdayConnectAgentError( + f"FlightCheck {checkpoint_id} did not finish within five minutes." + ) from exc + results_path = Path(output) / "results.json" + try: + report = json.loads(results_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + detail = (completed.stderr or completed.stdout or "").strip()[:500] + raise WorkdayConnectAgentError( + f"FlightCheck {checkpoint_id} produced no readable result" + + (f": {detail}" if detail else ".") + ) from exc + results = report.get("results") + if not isinstance(results, list): + raise WorkdayConnectAgentError( + f"FlightCheck {checkpoint_id} returned an invalid result document." + ) + matching = [ + result + for result in results + if isinstance(result, dict) and result.get("checkpoint_id") == checkpoint_id + ] + statuses = {str(result.get("status") or "") for result in matching} + if completed.returncode != 0 or not matching or statuses != {"Passed"}: + details = "; ".join( + str(result.get("result") or result.get("status") or "") + for result in matching + ) + raise WorkdayConnectAgentError( + f"FlightCheck {checkpoint_id} did not pass" + + (f": {details}" if details else ".") + ) + return "Passed" + + +def verify_agent_binding( + workspace_root: Path, + state: Mapping[str, Any], + *, + checkpoint_verifier: Callable[..., str] = run_flightcheck_checkpoint, + client_factory: Callable[ + [dict[str, Any]], MinimalBotEvaluationClient + ] = MinimalBotEvaluationClient.from_config, +) -> dict[str, Any]: + """Verify Workday binding without using topic diagnostics as runtime proof.""" + context = _agent_verification_context(workspace_root, state) + checkpoints = { + checkpoint_id: checkpoint_verifier( + workspace_root, + state, + checkpoint_id, + preferred_username=context["makerUsername"], + ) + for checkpoint_id in ("WD-REST-002", "WD-CONN-013") + } + evidence = _verify_workday_topics( + context, + client_factory=client_factory, + require_clean_diagnostics=False, + ) + evidence["checkpoints"] = checkpoints + return evidence + + +def _agent_verification_context( + workspace_root: Path, + state: Mapping[str, Any], +) -> dict[str, Any]: + foundation = _read_json(workspace_root / ".local" / "config.json") + scope = state.get("scope") or {} + recorded_agent = scope.get("agent") or {} + active_agent = _active_agent(foundation) + environment_id = _required_text( + scope, + "environmentId", + "Recorded environment ID", + ) + foundation_environment = _required_text( + foundation, + "environmentId", + "Foundation environment ID", + ) + if foundation_environment.casefold() != environment_id.casefold(): + raise WorkdayConnectAgentError( + "Foundation setup and Workday state target different environments." + ) + recorded_slug = _required_text( + recorded_agent, + "slug", + "Recorded agent slug", + ) + if ( + _required_text(active_agent, "slug", "Active agent slug").casefold() + != recorded_slug.casefold() + ): + raise WorkdayConnectAgentError( + "Foundation setup and Workday state target different agents." + ) + bot_id = _required_text(recorded_agent, "botId", "Recorded agent bot ID") + if ( + _required_text(active_agent, "botId", "Active agent bot ID").casefold() + != bot_id.casefold() + ): + raise WorkdayConnectAgentError( + "Foundation setup and Workday state target different agents." + ) + agent_schema = _required_text( + active_agent, + "schemaName", + "Active agent schema name", + ) + if ( + _required_text( + recorded_agent, + "schemaName", + "Recorded agent schema name", + ).casefold() + != agent_schema.casefold() + ): + raise WorkdayConnectAgentError( + "Foundation setup and Workday state contain different agent schemas." + ) + maker = _required_text( + (state.get("operators") or {}).get("powerPlatformMaker") or {}, + "username", + "Power Platform maker account", + ) + return { + "foundation": foundation, + "environmentId": environment_id, + "botId": bot_id, + "makerUsername": maker, + "agentSchema": agent_schema, + "agentFolder": _agent_folder(workspace_root, active_agent), + } + + +def _verify_workday_topics( + context: Mapping[str, Any], + *, + client_factory: Callable[[dict[str, Any]], MinimalBotEvaluationClient], + require_clean_diagnostics: bool, +) -> dict[str, Any]: + topics = resolve_workday_dialogs( + context["agentFolder"], + str(context["agentSchema"]), + ) + expectations = [ + { + **topic, + "state": "Active", + "status": "Active", + "requireCleanDiagnostics": require_clean_diagnostics, + } + for topic in topics + ] + try: + client = client_factory(dict(context["foundation"])) + client.authenticate( + preferred_username=str(context["makerUsername"]), + ) + verification = client.verify_dialog_components(expectations) + except MinimalBotEvaluationError as exc: + customer_safe = ( + str(exc) + .replace("MinimalBot dialog", "Copilot Studio topic") + .replace("MinimalBot", "Copilot Studio") + .replace("component map", "topic inventory") + ) + raise WorkdayConnectAgentError(customer_safe) from exc + + return { + "environmentId": context["environmentId"], + "botId": context["botId"], + "makerUsername": str(client.signed_in_username or context["makerUsername"]), + "checkpoints": {}, + "workdayTopics": { + "expected": len(topics), + "verified": verification["verifiedComponents"], + "active": verification["activeComponents"], + "blockingDiagnostics": verification["blockingDiagnostics"], + }, + } + + +def verify_topic_activation( + workspace_root: Path, + state: Mapping[str, Any], + *, + client_factory: Callable[ + [dict[str, Any]], MinimalBotEvaluationClient + ] = MinimalBotEvaluationClient.from_config, +) -> dict[str, Any]: + """Verify live topic activation without treating dependency health as state.""" + return _verify_workday_topics( + _agent_verification_context(workspace_root, state), + client_factory=client_factory, + require_clean_diagnostics=False, + ) diff --git a/solutions/ess-maker-skills/scripts/workday_connect_auth.py b/solutions/ess-maker-skills/scripts/workday_connect_auth.py new file mode 100644 index 000000000..e3102c8b8 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_connect_auth.py @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Identity continuity and authentication planning for Workday connect.""" + +from __future__ import annotations + +import base64 +import json +from typing import Any + + +class WorkdayConnectIdentityError(RuntimeError): + """Raised when an authenticated account does not match the intended user.""" + + +def authentication_plan() -> list[dict[str, Any]]: + """Describe credential stores without pretending one token serves all.""" + return [ + { + "store": "azure-cli-graph", + "role": "Microsoft Entra administrator", + "purpose": "Discover, configure, and verify the Workday application", + }, + { + "store": "pac", + "role": "Power Platform Environment Maker", + "purpose": "Install the Workday package and inspect connections", + }, + { + "store": "dataverse-msal", + "role": "Power Platform Environment Maker", + "purpose": "Verify and configure Workday runtime resources", + }, + { + "store": "workday-connector", + "role": "Workday employee", + "purpose": "Create the signed-in employee Workday connection", + }, + ] + + +def token_identity(access_token: str) -> dict[str, str]: + """Read safe identity claims from an access token without storing it.""" + try: + payload = access_token.split(".")[1] + payload += "=" * (-len(payload) % 4) + claims = json.loads(base64.urlsafe_b64decode(payload)) + except (IndexError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise WorkdayConnectIdentityError( + "The authenticated Dataverse token did not contain readable " + "identity claims." + ) from exc + username = str( + claims.get("preferred_username") + or claims.get("upn") + or claims.get("unique_name") + or "" + ).strip() + tenant_id = str(claims.get("tid") or "").strip() + if not username or not tenant_id: + raise WorkdayConnectIdentityError( + "The authenticated Dataverse token did not identify both the " + "account and Microsoft Entra tenant." + ) + return {"username": username, "tenantId": tenant_id} + + +def require_identity( + access_token: str, + *, + preferred_username: str | None, +) -> dict[str, str]: + identity = token_identity(access_token) + if preferred_username and ( + identity["username"].casefold() != preferred_username.casefold() + ): + raise WorkdayConnectIdentityError( + "Dataverse authentication used a different account from the " + "selected Environment Maker." + ) + return identity diff --git a/solutions/ess-maker-skills/scripts/workday_connect_catalog.json b/solutions/ess-maker-skills/scripts/workday_connect_catalog.json new file mode 100644 index 000000000..010bbc81c --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_connect_catalog.json @@ -0,0 +1,43 @@ +{ + "catalogVersion": 1, + "provider": "workday", + "supportedAgents": { + "gptagent_copilotforemployeeselfservicehr": { + "architecture": "native-da", + "packageFlavor": "runtime" + } + }, + "unsupportedAgents": [ + "msdyn_copilotforemployeeselfservicedahr", + "gptagent_copilotforemployeeselfserviceit", + "msdyn_copilotforemployeeselfservicedait" + ], + "packages": { + "runtime": { + "applicationName": "msdyn_EssWorkdayRuntime", + "solutionSchemaName": "msdyn_EssWorkdayRuntime", + "flowNames": [ + "ESS Workday Runtime References", + "ESS Workday Runtime REST Execution", + "ESS Workday Runtime" + ], + "agentConnectionFlowNames": [ + "ESS Workday Runtime REST Execution" + ] + }, + "legacy-da": { + "applicationName": "msdyn_EssDAHRWorkdayHCM", + "solutionSchemaName": "msdyn_EssDAHRWorkday" + } + }, + "connectionReferences": { + "workday": { + "logicalName": "msdyn_sharedworkdaysoap_workdayruntime", + "connectorName": "shared_workdaysoap" + }, + "dataverse": { + "logicalName": "msdyn_sharedcommondataserviceforapps_workdayruntime", + "connectorName": "shared_commondataserviceforapps" + } + } +} diff --git a/solutions/ess-maker-skills/scripts/workday_connect_contracts.py b/solutions/ess-maker-skills/scripts/workday_connect_contracts.py new file mode 100644 index 000000000..89d36c3f6 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_connect_contracts.py @@ -0,0 +1,1128 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Exact Entra and Workday administrator contracts for Workday connect.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import ipaddress +from typing import Any, Mapping +from urllib.parse import urlparse + +from workday_connect_model import ( + PhaseStatus, + WorkdayConnectModelError, + load_catalog, + workday_saml_entity_id, +) + + +WORKDAY_CONNECTOR_APP_ID = "4e4707ca-5f53-46a6-a819-f7765446e6ff" +GRAPH_DELEGATED_PERMISSIONS = ("openid", "profile", "User.Read") +WORKDAY_AUTHENTICATION_POLICY_OUTCOMES = { + "existing-active-policy", + "reviewed-policy-activated", +} +WORKDAY_NETWORK_READINESS_OUTCOMES = { + "confirmed-hosts-allowed", + "no-customer-firewall-change-required", +} + + +class WorkdayConnectContractError(WorkdayConnectModelError): + """Raised when an exact Entra or Workday contract cannot be produced.""" + + +def _required_text( + document: Mapping[str, Any], + key: str, + label: str, +) -> str: + value = str(document.get(key) or "").strip() + if not value: + raise WorkdayConnectContractError(f"{label} is required.") + return value + + +def _normalized_uri(value: Any) -> str: + return str(value or "").strip().rstrip("/").casefold() + + +def _candidate(candidate: Any) -> dict[str, Any]: + if not isinstance(candidate, Mapping): + raise WorkdayConnectContractError( + "Every discovered Entra application must be an object." + ) + identifier_uris = candidate.get("identifierUris") or [] + if not isinstance(identifier_uris, list): + raise WorkdayConnectContractError( + "Discovered Entra identifierUris must be an array." + ) + return { + "displayName": _required_text( + candidate, "displayName", "Entra app display name" + ), + "appId": _required_text(candidate, "appId", "Entra app ID"), + "objectId": _required_text(candidate, "objectId", "Entra app object ID"), + "servicePrincipalId": _required_text( + candidate, + "servicePrincipalId", + "Entra service principal ID", + ), + "identifierUris": [ + str(value).strip() for value in identifier_uris if str(value).strip() + ], + } + + +def _require_preflight(state: Mapping[str, Any]) -> None: + phases = state.get("phases") + if not isinstance(phases, Mapping): + raise WorkdayConnectContractError( + "Initialize Workday connect before building an Entra plan." + ) + preflight = phases.get("preflight") + if ( + not isinstance(preflight, Mapping) + or preflight.get("status") != PhaseStatus.COMPLETE.value + ): + raise WorkdayConnectContractError( + "Complete Workday preflight before planning Entra changes." + ) + + +def build_entra_handoff( + state: Mapping[str, Any], + discovery: Mapping[str, Any], +) -> dict[str, Any]: + """Build one exact Entra administrator handoff after discovery.""" + _require_preflight(state) + if not isinstance(discovery, Mapping): + raise WorkdayConnectContractError("Entra discovery must contain a JSON object.") + scope = state.get("scope") or {} + tenant = _required_text(scope, "workdayTenant", "Workday tenant") + entity_id = workday_saml_entity_id(tenant) + entra_tenant_id = _required_text( + scope, "entraTenantId", "Microsoft Entra tenant ID" + ) + candidates = [_candidate(value) for value in (discovery.get("applications") or [])] + matches = [ + value + for value in candidates + if _normalized_uri(entity_id) + in {_normalized_uri(uri) for uri in value["identifierUris"]} + ] + if len(matches) > 1: + raise WorkdayConnectContractError( + "More than one Entra application has the exact Workday SAML " + "Service Provider ID. Resolve the duplicate before continuing." + ) + allow_create = discovery.get("allowCreate") is True + if not matches and not allow_create: + raise WorkdayConnectContractError( + "No exact Entra application was found and application creation " + "was not authorized for planning." + ) + + app = matches[0] if matches else None + target = ( + { + "mode": "reuse", + **app, + } + if app + else { + "mode": "create", + "displayName": str( + discovery.get("newDisplayName") or "Workday (ESS Copilot)" + ).strip(), + "galleryTemplate": "Workday", + } + ) + app_id_uri = f"api://{app['appId']}" if app else None + foundation = state.get("tenantFoundation") + foundation_scope = ( + foundation.get("scope") if isinstance(foundation, Mapping) else {} + ) + foundation_identifiers = ( + foundation.get("identifiers") if isinstance(foundation, Mapping) else {} + ) + reusable = bool( + app + and isinstance(foundation_scope, Mapping) + and isinstance(foundation_identifiers, Mapping) + and str(foundation_scope.get("entraTenantId") or "").casefold() + == entra_tenant_id.casefold() + and str(foundation_scope.get("workdayTenant") or "").casefold() + == tenant.casefold() + and str(foundation_identifiers.get("entraAppId") or "").casefold() + == app["appId"].casefold() + ) + if app is None: + actions = [ + "Instantiate the Workday gallery application in the selected " + "Microsoft Entra tenant", + "Rerun exact application discovery after Entra assigns the " + "application and service-principal identifiers", + ] + elif reusable: + actions = [ + "Reread the exact Workday application and service principal " + "through Microsoft Graph", + "Reuse the stored tenant configuration when every required " + "setting still verifies; involve an administrator only for " + "missing or changed settings", + ] + else: + actions = [ + "Reuse the exact Workday SAML application", + "Configure SAML mode, the signing certificate, and the exact " + f"Service Provider ID {entity_id}", + "Expose the user_impersonation scope at the Entra application ID " + "URI and pre-authorize the Workday connector", + "Add openid, profile, and User.Read delegated permissions", + "Grant administrator consent", + "Configure enterprise-application user assignment", + "Map NameID to the attribute that equals the Workday User Name", + "Set the SAML signing option to Sign SAML response and assertion", + ] + return { + "phase": "entra", + "scope": { + "entraTenantId": entra_tenant_id, + "workdayTenant": tenant, + "workdaySamlEntityId": entity_id, + }, + "target": target, + "identifiers": { + "workdaySamlEntityId": entity_id, + "entraAppIdUri": app_id_uri, + }, + "permissions": { + "connectorAppId": WORKDAY_CONNECTOR_APP_ID, + "graphDelegated": list(GRAPH_DELEGATED_PERMISSIONS), + "scope": "user_impersonation", + }, + "foundationReuse": { + "eligible": reusable, + }, + "requiresRediscovery": app is None, + "actions": actions, + } + + +_ENTRA_CHECKS = { + "samlMode", + "signingCertificate", + "connectorPreauthorized", + "graphDelegatedPermissions", + "adminConsent", + "userAssignment", + "nameId", + "samlSigningOption", +} +_GRAPH_ONLY_ENTRA_CHECKS = _ENTRA_CHECKS - { + "nameId", + "samlSigningOption", +} + + +def _normalize_entra_check(name: str, value: Any) -> dict[str, Any]: + if not isinstance(value, Mapping): + raise WorkdayConnectContractError( + f"Entra verification check '{name}' must contain evidence." + ) + allowed = {"outcome", "provenance"} + if name in {"nameId", "samlSigningOption"}: + allowed.add("observedValue") + unexpected = sorted(set(value) - allowed) + if unexpected: + raise WorkdayConnectContractError( + f"Entra verification check '{name}' contains unsupported fields: " + + ", ".join(unexpected) + ) + outcome = str(value.get("outcome") or "").strip().casefold() + if outcome not in {"verified", "confirmed"}: + raise WorkdayConnectContractError( + f"Entra verification check '{name}' is incomplete." + ) + provenance = str(value.get("provenance") or "").strip() + if not provenance: + raise WorkdayConnectContractError( + f"Entra verification check '{name}' lacks provenance." + ) + normalized_provenance = provenance.casefold() + if name in _GRAPH_ONLY_ENTRA_CHECKS and normalized_provenance != "microsoft-graph": + raise WorkdayConnectContractError( + f"Entra verification check '{name}' must be proven by Microsoft Graph." + ) + if name in _GRAPH_ONLY_ENTRA_CHECKS and outcome != "verified": + raise WorkdayConnectContractError( + f"Entra verification check '{name}' must have outcome 'verified'." + ) + if ( + name == "samlSigningOption" + and normalized_provenance != "administrator-attestation" + ): + raise WorkdayConnectContractError( + "Entra verification check 'samlSigningOption' must be confirmed " + "by administrator attestation." + ) + if name == "samlSigningOption" and outcome != "confirmed": + raise WorkdayConnectContractError( + "Entra verification check 'samlSigningOption' must have outcome " + "'confirmed'." + ) + if name == "nameId" and normalized_provenance not in { + "microsoft-graph", + "administrator-attestation", + }: + raise WorkdayConnectContractError( + "Entra verification check 'nameId' must be proven by Microsoft " + "Graph or administrator attestation." + ) + if name == "nameId": + expected_outcome = ( + "verified" if normalized_provenance == "microsoft-graph" else "confirmed" + ) + if outcome != expected_outcome: + raise WorkdayConnectContractError( + "Entra verification check 'nameId' has an outcome that does " + "not match its provenance." + ) + result = { + "outcome": outcome, + "provenance": provenance, + } + if name in {"nameId", "samlSigningOption"}: + observed_value = _required_text( + value, + "observedValue", + f"Entra verification check '{name}' observed value", + ) + if ( + name == "samlSigningOption" + and observed_value.casefold() + != "sign saml response and assertion".casefold() + ): + raise WorkdayConnectContractError( + "Entra verification check 'samlSigningOption' must record " + "'Sign SAML response and assertion'." + ) + result["observedValue"] = observed_value + return result + + +def validate_entra_verification( + state: Mapping[str, Any], + verification: Mapping[str, Any], +) -> dict[str, Any]: + """Validate safe Graph reread evidence after the administrator handoff.""" + if not isinstance(verification, Mapping): + raise WorkdayConnectContractError( + "Entra verification must contain a JSON object." + ) + application = _candidate(verification.get("application")) + scope = state.get("scope") or {} + expected_tenant_id = _required_text( + scope, + "entraTenantId", + "Microsoft Entra tenant ID", + ) + observed_tenant_id = _required_text( + verification, + "tenantId", + "Verified Microsoft Entra tenant ID", + ) + if observed_tenant_id.casefold() != expected_tenant_id.casefold(): + raise WorkdayConnectContractError( + "The verified Microsoft Entra tenant does not match the tenant " + "recorded during Workday preflight." + ) + tenant = _required_text(scope, "workdayTenant", "Workday tenant") + expected_entity_id = workday_saml_entity_id(tenant) + expected_app_uri = f"api://{application['appId']}" + observed_uris = {_normalized_uri(value) for value in application["identifierUris"]} + missing_uris = [ + value + for value in (expected_entity_id, expected_app_uri) + if _normalized_uri(value) not in observed_uris + ] + if missing_uris: + raise WorkdayConnectContractError( + "The verified Entra application is missing required identifier " + "URIs: " + ", ".join(missing_uris) + ) + checks = verification.get("checks") + if not isinstance(checks, Mapping): + raise WorkdayConnectContractError( + "Entra verification checks must contain an object." + ) + supplied_checks = dict(checks) + unexpected_checks = sorted(supplied_checks.keys() - _ENTRA_CHECKS) + if unexpected_checks: + raise WorkdayConnectContractError( + "Entra verification contains unsupported checks: " + + ", ".join(unexpected_checks) + ) + missing_checks = sorted(_ENTRA_CHECKS - supplied_checks.keys()) + if missing_checks: + raise WorkdayConnectContractError( + "Entra verification is incomplete: " + ", ".join(missing_checks) + ) + normalized_checks = { + name: _normalize_entra_check(name, supplied_checks[name]) + for name in sorted(_ENTRA_CHECKS) + } + scope_guid = _required_text( + verification, + "scopeGuid", + "Entra user_impersonation scope ID", + ) + certificate = verification.get("certificate") + if not isinstance(certificate, Mapping): + raise WorkdayConnectContractError( + "Entra certificate metadata must contain an object." + ) + safe_certificate = { + key: _required_text( + certificate, + key, + f"Entra signing certificate {key}", + ) + for key in ("thumbprint", "validFrom", "validTo") + } + return { + "identifiers": { + "entraAppId": application["appId"], + "entraAppObjectId": application["objectId"], + "entraServicePrincipalId": application["servicePrincipalId"], + "entraAppIdUri": expected_app_uri, + "workdaySamlEntityId": expected_entity_id, + "scopeGuid": scope_guid, + "signingCertificate": safe_certificate, + }, + "evidence": { + "tenantId": observed_tenant_id, + "applicationDisplayName": application["displayName"], + "checks": normalized_checks, + }, + } + + +def build_workday_admin_packet( + state: Mapping[str, Any], +) -> dict[str, Any]: + """Build one compact handoff packet for the Workday administrator.""" + scope = state.get("scope") or {} + identifiers = state.get("identifiers") or {} + tenant = _required_text(scope, "workdayTenant", "Workday tenant") + expected_entity_id = workday_saml_entity_id(tenant) + entity_id = _required_text( + identifiers, + "workdaySamlEntityId", + "Workday SAML Service Provider ID", + ) + if _normalized_uri(entity_id) != _normalized_uri(expected_entity_id): + raise WorkdayConnectContractError( + "The Workday SAML Service Provider ID does not match the selected " + "Workday tenant." + ) + entra_app_id_uri = _required_text( + identifiers, + "entraAppIdUri", + "Entra application ID URI", + ) + entra_tenant_id = _required_text( + scope, + "entraTenantId", + "Microsoft Entra tenant ID", + ) + expected_issuer = f"https://sts.windows.net/{entra_tenant_id}/" + signing_certificate = identifiers.get("signingCertificate") + if not isinstance(signing_certificate, Mapping): + raise WorkdayConnectContractError( + "Verified Entra signing certificate metadata is required before " + "building the Workday administrator packet." + ) + certificate_valid_from = _date_only( + _required_text( + signing_certificate, + "validFrom", + "Entra signing certificate Valid From", + ), + "Entra signing certificate Valid From", + ) + certificate_valid_to = _date_only( + _required_text( + signing_certificate, + "validTo", + "Entra signing certificate Valid To", + ), + "Entra signing certificate Valid To", + ) + if _normalized_uri(entity_id) == _normalized_uri(entra_app_id_uri): + raise WorkdayConnectContractError( + "The Workday SAML Service Provider ID and Entra application ID URI " + "must remain distinct." + ) + + packet = { + "phase": "workday-admin", + "scope": { + "workdayTenant": tenant, + "workdaySamlEntityId": entity_id, + }, + "referenceValues": { + "serviceProviderId": entity_id, + "entraApplicationIdUri": entra_app_id_uri, + "expectedIdentityProviderIssuer": expected_issuer, + "certificateValidFrom": certificate_valid_from, + "certificateValidTo": certificate_valid_to, + }, + "identityProviderQuestion": { + "question": ( + "Which sign-in provider does the enabled Workday SAML row " + "appear to use?" + ), + "options": [ + ( + "Microsoft Entra ID - the Issuer often contains " + "login.microsoftonline.com or sts.windows.net" + ), + "Okta - the Issuer often contains okta.com", + ( + "Ping Identity - the Issuer often contains pingone.com, " + "pingidentity.com, or an organization-specific Ping host" + ), + "Another sign-in provider", + "No enabled SAML row", + "I'm not sure", + ], + }, + "certificateSelectionQuestion": { + "question": ( + "Which certificate is selected on the enabled Microsoft " + "Entra SAML row in Workday?" + ), + "options": [ + "The new certificate created from the Entra Base64 file", + "A different existing Workday certificate", + "No certificate is selected", + "I'm not sure", + ], + }, + "issuerConfirmationQuestion": { + "question": ( + "Does the Issuer in the enabled Microsoft Entra SAML row " + f"exactly match {expected_issuer}?" + ), + "options": [ + "Yes, it matches exactly", + "No, the displayed Issuer is different", + "I'm not sure", + ], + }, + "certificateValidityQuestion": { + "question": ( + "Do the selected Workday certificate dates exactly match " + f"{certificate_valid_from} through {certificate_valid_to}?" + ), + "options": [ + "Yes, both dates match exactly", + "No, one or both dates are different", + "I'm not sure", + ], + }, + "actions": [ + "Identify which sign-in provider the enabled Workday SAML row " + "uses before changing it", + "Create a Workday X.509 Public Key from the active Entra SAML " + "signing certificate, select it on the Microsoft Entra SAML row, " + "and compare its validity dates", + f"Set the Workday Service Provider ID to {entity_id}", + "Enable OAuth 2.0 Clients and SAML in Tenant Setup - Security", + "Register the signed-in employee API client with Client Grant " + "Type SAML Bearer, the required functional areas, and Include " + "Workday Owned Scope", + "Verify an active authentication policy allows SAML for the " + "intended employee population", + "Confirm the returned Workday REST and SOAP hosts are reachable " + "or approved by the organization network policy", + ], + "responseForm": { + "required": [ + "identityProviderOutcome", + "enabledServiceProviderId", + "certificateSelectionOutcome", + "certificateValidityOutcome", + "oauthClientId", + "oauthTokenUrl", + "restBaseUrl", + "soapBaseUrl", + "authenticationPolicyOutcome", + "networkReadinessOutcome", + ], + "note": ( + "Return configuration evidence only. Do not paste passwords, " + "client secrets, tokens, cookies, or certificate private keys. " + "The Workday certificate display name is optional." + ), + }, + } + return packet + + +def _https_url(value: Any, label: str) -> str: + text = str(value or "").strip().rstrip("/") + parsed = urlparse(text) + try: + port = parsed.port + except ValueError as exc: + raise WorkdayConnectContractError( + f"{label} must be an HTTPS URL." + ) from exc + if ( + parsed.scheme.casefold() != "https" + or not parsed.netloc + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or port not in {None, 443} + ): + raise WorkdayConnectContractError(f"{label} must be an HTTPS URL.") + hostname = parsed.hostname.casefold().rstrip(".") + try: + ipaddress.ip_address(hostname) + except ValueError: + pass + else: + raise WorkdayConnectContractError( + f"{label} must use a Workday service hostname, not an IP address." + ) + if not hostname.endswith((".workday.com", ".myworkday.com")): + raise WorkdayConnectContractError( + f"{label} must use a Workday-owned service hostname." + ) + return text + + +def _require_endpoint_path( + url: str, + expected_path: str, + label: str, +) -> None: + observed_path = urlparse(url).path.rstrip("/") + if observed_path.casefold() != expected_path.casefold(): + raise WorkdayConnectContractError( + f"{label} must end exactly at {expected_path}." + ) + + +def _date_only(value: str, label: str) -> str: + normalized = value.strip().replace("Z", "+00:00") + try: + return datetime.fromisoformat(normalized).date().isoformat() + except ValueError as exc: + raise WorkdayConnectContractError( + f"{label} must be an ISO-8601 date or timestamp." + ) from exc + + +def validate_workday_admin_response( + state: Mapping[str, Any], + response: Mapping[str, Any], +) -> dict[str, Any]: + if not isinstance(response, Mapping): + raise WorkdayConnectContractError( + "Workday administrator response must contain a JSON object." + ) + allowed = { + "activeIdentityProviderIssuer", + "identityProviderOutcome", + "enabledServiceProviderId", + "certificateName", + "certificateSelectionOutcome", + "certificateValidityOutcome", + "certificateValidFrom", + "certificateValidTo", + "oauthClientId", + "oauthTokenUrl", + "restBaseUrl", + "soapBaseUrl", + "authenticationPolicyOutcome", + "networkReadinessOutcome", + } + unexpected = sorted(set(response) - allowed) + if unexpected: + raise WorkdayConnectContractError( + "Workday administrator response contains unsupported fields: " + + ", ".join(unexpected) + ) + scope = state.get("scope") or {} + tenant = _required_text(scope, "workdayTenant", "Workday tenant") + entra_tenant_id = _required_text( + scope, + "entraTenantId", + "Microsoft Entra tenant ID", + ) + expected_issuer = f"https://sts.windows.net/{entra_tenant_id}/" + identity_provider_outcome = str( + response.get("identityProviderOutcome") or "" + ).strip() + supplied_identity_provider_issuer = str( + response.get("activeIdentityProviderIssuer") or "" + ).strip() + if identity_provider_outcome != "verified-entra-issuer": + raise WorkdayConnectContractError( + "identityProviderOutcome must confirm that the enabled Workday " + "Issuer exactly matches the verified Microsoft Entra tenant." + ) + if supplied_identity_provider_issuer and ( + _normalized_uri(supplied_identity_provider_issuer) + != _normalized_uri(expected_issuer) + ): + raise WorkdayConnectContractError( + "The supplied Workday Issuer conflicts with the verified " + "Microsoft Entra issuer confirmation." + ) + active_identity_provider_issuer = expected_issuer + expected_entity_id = workday_saml_entity_id(tenant) + observed_entity_id = _required_text( + response, + "enabledServiceProviderId", + "Enabled Workday Service Provider ID", + ) + if _normalized_uri(observed_entity_id) != _normalized_uri(expected_entity_id): + raise WorkdayConnectContractError( + "The enabled Workday Service Provider ID does not match the " + "selected Workday tenant." + ) + oauth_token_url = _https_url( + response.get("oauthTokenUrl"), + "Workday OAuth token URL", + ) + _require_endpoint_path( + oauth_token_url, + f"/ccx/oauth2/{tenant}/token", + "Workday OAuth token URL", + ) + rest_base_url = _https_url( + response.get("restBaseUrl"), + "Workday REST base URL", + ) + _require_endpoint_path( + rest_base_url, + "/ccx/api", + "Workday REST base URL", + ) + soap_base_url = _https_url( + response.get("soapBaseUrl"), + "Workday SOAP base URL", + ) + _require_endpoint_path( + soap_base_url, + f"/ccx/service/{tenant}", + "Workday SOAP base URL", + ) + endpoint_hosts = { + str(urlparse(value).hostname or "").casefold() + for value in (oauth_token_url, rest_base_url, soap_base_url) + } + if len(endpoint_hosts) != 1: + raise WorkdayConnectContractError( + "Workday OAuth, REST, and SOAP endpoints must use the same " + "verified Workday service hostname." + ) + required = { + "oauthClientId", + "authenticationPolicyOutcome", + "networkReadinessOutcome", + } + values = {key: _required_text(response, key, key) for key in required} + if ( + values["authenticationPolicyOutcome"] + not in WORKDAY_AUTHENTICATION_POLICY_OUTCOMES + ): + raise WorkdayConnectContractError( + "authenticationPolicyOutcome must confirm either an existing " + "active employee SAML policy or an activated reviewed change." + ) + if values["networkReadinessOutcome"] not in WORKDAY_NETWORK_READINESS_OUTCOMES: + raise WorkdayConnectContractError( + "networkReadinessOutcome must confirm the Workday hosts are " + "allowed or that no customer firewall change is required." + ) + signing_certificate = (state.get("identifiers") or {}).get("signingCertificate") + if not isinstance(signing_certificate, Mapping): + raise WorkdayConnectContractError( + "Verified Entra signing certificate metadata is required before " + "recording Workday administrator evidence." + ) + entra_valid_from = _date_only( + _required_text( + signing_certificate, + "validFrom", + "Entra signing certificate Valid From", + ), + "Entra signing certificate Valid From", + ) + entra_valid_to = _date_only( + _required_text( + signing_certificate, + "validTo", + "Entra signing certificate Valid To", + ), + "Entra signing certificate Valid To", + ) + certificate_selection_outcome = str( + response.get("certificateSelectionOutcome") or "" + ).strip() + if certificate_selection_outcome != "entra-signing-certificate-selected": + raise WorkdayConnectContractError( + "certificateSelectionOutcome must confirm that the Workday row " + "uses the certificate created from the verified Entra signing " + "certificate." + ) + certificate_validity_outcome = str( + response.get("certificateValidityOutcome") or "" + ).strip() + if certificate_validity_outcome != "matches-verified-entra-certificate": + raise WorkdayConnectContractError( + "certificateValidityOutcome must confirm that both Workday " + "certificate dates exactly match the verified Entra certificate." + ) + workday_valid_from = entra_valid_from + workday_valid_to = entra_valid_to + supplied_valid_from = str( + response.get("certificateValidFrom") or "" + ).strip() + supplied_valid_to = str(response.get("certificateValidTo") or "").strip() + if supplied_valid_from and ( + _date_only( + supplied_valid_from, + "Workday certificate Valid From", + ) + != entra_valid_from + ): + raise WorkdayConnectContractError( + "The supplied Workday certificate Valid From date conflicts " + "with the verified certificate-date confirmation." + ) + if supplied_valid_to and ( + _date_only( + supplied_valid_to, + "Workday certificate Valid To", + ) + != entra_valid_to + ): + raise WorkdayConnectContractError( + "The supplied Workday certificate Valid To date conflicts " + "with the verified certificate-date confirmation." + ) + certificate_name = str(response.get("certificateName") or "").strip() + certificate_evidence = { + "certificateSelectionOutcome": certificate_selection_outcome, + "certificateValidityOutcome": certificate_validity_outcome, + "certificateValidFrom": workday_valid_from, + "certificateValidTo": workday_valid_to, + } + if certificate_name: + certificate_evidence["certificateName"] = certificate_name + return { + "identifiers": { + "workdaySamlEntityId": expected_entity_id, + "oauthClientId": values["oauthClientId"], + }, + "endpoints": { + "oauthTokenUrl": oauth_token_url, + "restBaseUrl": rest_base_url, + "soapBaseUrl": soap_base_url, + }, + "evidence": { + "activeIdentityProviderIssuer": active_identity_provider_issuer, + "identityProviderOutcome": identity_provider_outcome, + "serviceProviderId": expected_entity_id, + **certificate_evidence, + "authenticationPolicyOutcome": values["authenticationPolicyOutcome"], + "networkReadinessOutcome": values["networkReadinessOutcome"], + }, + } + + +def validate_agent_binding_evidence( + state: Mapping[str, Any], + evidence: Mapping[str, Any], +) -> dict[str, Any]: + if not isinstance(evidence, Mapping): + raise WorkdayConnectContractError( + "Agent binding evidence must contain a JSON object." + ) + allowed = { + "environmentId", + "botId", + "makerUsername", + "checkpoints", + "flowAttachment", + "workdayTopics", + } + unexpected = sorted(set(evidence) - allowed) + if unexpected: + raise WorkdayConnectContractError( + "Agent binding evidence contains unsupported fields: " + + ", ".join(unexpected) + ) + scope = state.get("scope") or {} + agent = scope.get("agent") or {} + expected_environment = _required_text( + scope, + "environmentId", + "Workday environment ID", + ) + expected_bot = _required_text(agent, "botId", "Workday agent bot ID") + observed_environment = _required_text( + evidence, + "environmentId", + "Verified environment ID", + ) + observed_bot = _required_text( + evidence, + "botId", + "Verified agent bot ID", + ) + if observed_environment.casefold() != expected_environment.casefold(): + raise WorkdayConnectContractError( + "Agent binding verification targeted a different environment." + ) + if observed_bot.casefold() != expected_bot.casefold(): + raise WorkdayConnectContractError( + "Agent binding verification targeted a different agent." + ) + expected_maker = _required_text( + (state.get("operators") or {}).get("powerPlatformMaker") or {}, + "username", + "Recorded Power Platform maker", + ) + observed_maker = _required_text( + evidence, + "makerUsername", + "Verified Power Platform maker", + ) + if observed_maker.casefold() != expected_maker.casefold(): + raise WorkdayConnectContractError( + "Agent binding verification used a different Power Platform maker." + ) + checkpoints = evidence.get("checkpoints") + if not isinstance(checkpoints, Mapping): + raise WorkdayConnectContractError( + "Agent binding evidence must contain checkpoint results." + ) + required_checkpoints = {"WD-REST-002", "WD-CONN-013"} + failed_checkpoints = sorted( + checkpoint + for checkpoint in required_checkpoints + if checkpoints.get(checkpoint) != "Passed" + ) + if failed_checkpoints: + raise WorkdayConnectContractError( + "Agent binding verification did not pass: " + ", ".join(failed_checkpoints) + ) + flow_attachment = evidence.get("flowAttachment") + if not isinstance(flow_attachment, Mapping): + raise WorkdayConnectContractError( + "Agent binding evidence must contain the maker-confirmed Workday " + "flow attachment." + ) + attachment_allowed = { + "outcome", + "botId", + "flowNames", + "parameterSharingOutcome", + } + attachment_unexpected = sorted( + set(flow_attachment) - attachment_allowed + ) + if attachment_unexpected: + raise WorkdayConnectContractError( + "Workday flow attachment evidence contains unsupported fields: " + + ", ".join(attachment_unexpected) + ) + if flow_attachment.get("outcome") != "maker-confirmed": + raise WorkdayConnectContractError( + "Workday flow attachment must be explicitly confirmed by the maker." + ) + attachment_bot = _required_text( + flow_attachment, + "botId", + "Workday flow attachment agent bot ID", + ) + if attachment_bot.casefold() != expected_bot.casefold(): + raise WorkdayConnectContractError( + "Workday flow attachment confirmation targeted a different agent." + ) + if ( + flow_attachment.get("parameterSharingOutcome") + != "enabled-for-exposed-connections" + ): + raise WorkdayConnectContractError( + "Workday flow attachment must confirm parameter sharing for every " + "connection exposed by the agent." + ) + package_flavor = _required_text( + scope, + "packageFlavor", + "Workday package flavor", + ) + package = (load_catalog().get("packages") or {}).get(package_flavor) + if not isinstance(package, Mapping): + raise WorkdayConnectContractError( + f"Unsupported Workday package flavor: {package_flavor}." + ) + expected_flow_names = { + str(name) + for name in package.get("agentConnectionFlowNames") or [] + } + if not expected_flow_names: + raise WorkdayConnectContractError( + "The selected Workday package does not define agent-facing flows." + ) + supplied_flow_names = flow_attachment.get("flowNames") + if ( + not isinstance(supplied_flow_names, list) + or any(not isinstance(name, str) or not name for name in supplied_flow_names) + or len(supplied_flow_names) != len(expected_flow_names) + or set(supplied_flow_names) != expected_flow_names + ): + raise WorkdayConnectContractError( + "Workday flow attachment confirmation must name exactly the " + "reviewed agent-facing Workday flows." + ) + topics = evidence.get("workdayTopics") + if not isinstance(topics, Mapping): + raise WorkdayConnectContractError( + "Agent binding evidence must contain Workday topic verification." + ) + expected_count = topics.get("expected") + verified_count = topics.get("verified") + active_count = topics.get("active") + diagnostics = topics.get("blockingDiagnostics") + if ( + not isinstance(expected_count, int) + or isinstance(expected_count, bool) + or expected_count <= 0 + or not isinstance(verified_count, int) + or isinstance(verified_count, bool) + or not isinstance(active_count, int) + or isinstance(active_count, bool) + or verified_count != expected_count + or active_count != expected_count + or not isinstance(diagnostics, list) + or any(not isinstance(diagnostic, Mapping) for diagnostic in diagnostics) + ): + raise WorkdayConnectContractError( + "Every mapped Workday topic must be active and verified, and " + "reported topic diagnostics must be structured." + ) + return { + "environmentId": observed_environment, + "botId": observed_bot, + "makerUsername": observed_maker, + "checkpoints": { + checkpoint: "Passed" for checkpoint in sorted(required_checkpoints) + }, + "flowAttachment": { + "outcome": "maker-confirmed", + "botId": attachment_bot, + "flowNames": sorted(expected_flow_names), + "parameterSharingOutcome": "enabled-for-exposed-connections", + }, + "workdayTopics": { + "expected": expected_count, + "verified": verified_count, + "active": active_count, + "blockingDiagnostics": [ + dict(diagnostic) for diagnostic in diagnostics + ], + }, + } + + +def validate_employee_evidence( + evidence: Mapping[str, Any], +) -> dict[str, Any]: + if not isinstance(evidence, Mapping): + raise WorkdayConnectContractError( + "Employee validation evidence must contain a JSON object." + ) + allowed = {"scenarioName", "testUserCategory", "timestamp", "outcome"} + unexpected = sorted(set(evidence) - allowed) + if unexpected: + raise WorkdayConnectContractError( + "Employee validation evidence contains unsupported fields: " + + ", ".join(unexpected) + ) + result = {key: _required_text(evidence, key, key) for key in allowed} + if result["outcome"].casefold() not in {"passed", "verified"}: + raise WorkdayConnectContractError( + "Employee validation outcome must be passed or verified." + ) + category = result["testUserCategory"].casefold() + explicitly_non_maker = ( + "non-maker" in category or "non maker" in category + ) + if ( + "employee" not in category + or "admin" in category + or ("maker" in category and not explicitly_non_maker) + ): + raise WorkdayConnectContractError( + "Employee validation must use a signed-in non-maker employee." + ) + result["timestamp"] = _normalized_timestamp( + result["timestamp"], + "Employee validation timestamp", + ) + return result + + +def _normalized_timestamp(value: str, label: str) -> str: + normalized = value.replace("Z", "+00:00") + try: + observed_at = datetime.fromisoformat(normalized) + except ValueError as exc: + raise WorkdayConnectContractError( + f"{label} must be ISO-8601." + ) from exc + if observed_at.tzinfo is None: + raise WorkdayConnectContractError( + f"{label} must include a timezone." + ) + return observed_at.astimezone(timezone.utc).isoformat().replace( + "+00:00", + "Z", + ) + + +def validate_employee_failure_evidence( + evidence: Mapping[str, Any], +) -> dict[str, str]: + if not isinstance(evidence, Mapping): + raise WorkdayConnectContractError( + "Employee validation failure must contain a JSON object." + ) + allowed = {"failureCategory", "timestamp", "remediation"} + unexpected = sorted(set(evidence) - allowed) + if unexpected: + raise WorkdayConnectContractError( + "Employee validation failure contains unsupported fields: " + + ", ".join(unexpected) + ) + result = {key: _required_text(evidence, key, key) for key in allowed} + result["timestamp"] = _normalized_timestamp( + result["timestamp"], + "Employee validation failure timestamp", + ) + return result diff --git a/solutions/ess-maker-skills/scripts/workday_connect_model.py b/solutions/ess-maker-skills/scripts/workday_connect_model.py new file mode 100644 index 000000000..49f1d527b --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_connect_model.py @@ -0,0 +1,619 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Typed lifecycle contracts for the Workday connect skill.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from enum import Enum +import hashlib +import json +from pathlib import Path +import re +from typing import Any, Mapping + + +STATE_SCHEMA_VERSION = 5 +CONTROLLER_CONTRACT_VERSION = 1 +CATALOG_PATH = Path(__file__).with_name("workday_connect_catalog.json") + + +class WorkdayConnectModelError(ValueError): + """Raised when lifecycle data violates the Workday connect contract.""" + + +class Phase(str, Enum): + PREFLIGHT = "preflight" + ENTRA = "entra" + WORKDAY_ADMIN = "workday-admin" + CONNECTIONS = "connections" + RUNTIME = "runtime" + EMPLOYEE_VALIDATION = "employee-validation" + + +class PhaseStatus(str, Enum): + PENDING = "pending" + ACTIVE = "active" + BLOCKED = "blocked" + COMPLETE = "complete" + + +@dataclass(frozen=True) +class PhaseDefinition: + identifier: Phase + title: str + what_happens: tuple[str, ...] + prerequisite: Phase | None + + +PHASE_DEFINITIONS = ( + PhaseDefinition( + identifier=Phase.PREFLIGHT, + title="Preflight", + what_happens=( + "Confirm the selected ESS HR agent, Power Platform environment, " + "and maker account.", + "Verify Dataverse is available in the selected environment.", + "Install or verify the supported Workday package.", + ), + prerequisite=None, + ), + PhaseDefinition( + identifier=Phase.ENTRA, + title="Microsoft Entra", + what_happens=( + "Find the exact Workday enterprise application in the selected " + "Microsoft Entra tenant.", + "Guide an Entra administrator through the required SAML, " + "permission, consent, assignment, and employee sign-in settings.", + "Verify the application and signing-certificate configuration.", + ), + prerequisite=Phase.PREFLIGHT, + ), + PhaseDefinition( + identifier=Phase.WORKDAY_ADMIN, + title="Workday administrator", + what_happens=( + "Identify the existing Workday sign-in provider without replacing " + "another federation.", + "Configure certificate trust, OAuth, the employee API client, and " + "the employee authentication policy.", + "Validate the non-secret connection values needed by Power " + "Platform.", + ), + prerequisite=Phase.ENTRA, + ), + PhaseDefinition( + identifier=Phase.CONNECTIONS, + title="Connections", + what_happens=( + "Find or guide creation of the Workday and Microsoft Dataverse " + "connections in the selected environment.", + "Use the verified Workday resource URL, token URL, and OAuth " + "client ID.", + "Verify both connections are live before runtime configuration.", + ), + prerequisite=Phase.WORKDAY_ADMIN, + ), + PhaseDefinition( + identifier=Phase.RUNTIME, + title="Runtime configuration", + what_happens=( + "Preview and approve the exact Workday runtime changes.", + "Bind connections, activate required flows, configure permissions " + "and employee context, and enable the Workday topics.", + "Reread the agent and preserve any remaining blocker for safe " + "resume.", + ), + prerequisite=Phase.CONNECTIONS, + ), + PhaseDefinition( + identifier=Phase.EMPLOYEE_VALIDATION, + title="Employee validation", + what_happens=( + "Publish the configured agent.", + "Run a real Workday scenario as a signed-in non-maker employee.", + "Confirm employee context and Workday data work without an " + "unexpected repeated sign-in.", + ), + prerequisite=Phase.RUNTIME, + ), +) +PHASE_BY_ID = { + definition.identifier.value: definition for definition in PHASE_DEFINITIONS +} + +PHASE_REQUIRED_ACTIONS = { + Phase.PREFLIGHT.value: frozenset({"verify-target", "verify-package"}), + Phase.ENTRA.value: frozenset( + { + "exact-application-discovered", + "administrator-configuration-verified", + } + ), + Phase.WORKDAY_ADMIN.value: frozenset({"administrator-response-validated"}), + Phase.CONNECTIONS.value: frozenset({"physical-connections-verified"}), + Phase.RUNTIME.value: frozenset( + { + "connection-references-bound", + "runtime-flows-active", + "delegated-authorization-configured", + "user-context-v2-configured", + "agent-parameter-sharing-verified", + "flow-attachment-confirmed", + "workday-topics-activated", + } + ), + Phase.EMPLOYEE_VALIDATION.value: frozenset({"signed-in-scenario"}), +} +TENANT_FOUNDATION_REQUIRED_IDENTIFIER_KEYS = frozenset( + { + "entraAppId", + "entraAppObjectId", + "entraServicePrincipalId", + "entraAppIdUri", + "workdaySamlEntityId", + "scopeGuid", + "signingCertificate", + "oauthClientId", + } +) +TENANT_FOUNDATION_REQUIRED_ENDPOINT_KEYS = frozenset( + { + "oauthTokenUrl", + "restBaseUrl", + "soapBaseUrl", + } +) + +LEGACY_PHASE_ROWS = { + Phase.PREFLIGHT: ("DA1.1",), + Phase.ENTRA: ( + "DA2.1", + "DA2.2", + "DA2.3", + "DA2.4", + "DA2.5", + "DA2.6", + "DA2.7", + ), + Phase.WORKDAY_ADMIN: ("DA3.1", "DA3.2", "DA3.3", "DA3.4"), + Phase.CONNECTIONS: ("DA4.1", "DA4.2"), + Phase.RUNTIME: ( + "DA4.3", + "DA4.4", + "DA4.5", + "DA4.6", + "DA4.7", + "DA4.8", + ), + Phase.EMPLOYEE_VALIDATION: ("DA5.1",), +} + +_TENANT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") +_SECRET_KEYS = { + "password", + "clientsecret", + "access_token", + "accesstoken", + "refresh_token", + "refreshtoken", + "cookie", + "certificatebody", + "privatekey", +} + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def load_catalog(path: Path = CATALOG_PATH) -> dict[str, Any]: + try: + catalog = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise WorkdayConnectModelError( + f"Workday connect catalog could not be loaded: {exc}" + ) from exc + if not isinstance(catalog, dict): + raise WorkdayConnectModelError( + "Workday connect catalog must contain a JSON object." + ) + required = { + "catalogVersion", + "provider", + "supportedAgents", + "unsupportedAgents", + "packages", + "connectionReferences", + } + missing = sorted(required - catalog.keys()) + if missing: + raise WorkdayConnectModelError( + "Workday connect catalog is missing: " + ", ".join(missing) + ) + if catalog["provider"] != "workday": + raise WorkdayConnectModelError( + "Workday connect catalog provider must be 'workday'." + ) + return catalog + + +def workday_saml_entity_id(tenant: str) -> str: + normalized = str(tenant or "").strip() + if not _TENANT_RE.fullmatch(normalized): + raise WorkdayConnectModelError( + "Workday tenant must contain only letters, numbers, hyphens, " + "and underscores." + ) + return f"http://www.workday.com/{normalized}" + + +def canonical_plan(plan: Mapping[str, Any]) -> dict[str, Any]: + if not isinstance(plan, Mapping): + raise WorkdayConnectModelError("A Workday change plan must be an object.") + document = dict(plan) + document.pop("planHash", None) + reject_sensitive_data(document) + phase = str(document.get("phase") or "") + if phase not in PHASE_BY_ID: + raise WorkdayConnectModelError(f"Unknown Workday phase: {phase}.") + actions = document.get("actions") + if not isinstance(actions, list) or not actions: + raise WorkdayConnectModelError( + "A Workday change plan must contain at least one action." + ) + if any(not isinstance(action, str) or not action.strip() for action in actions): + raise WorkdayConnectModelError( + "Workday change-plan actions must be non-empty strings." + ) + scope = document.get("scope") + if not isinstance(scope, dict) or not scope: + raise WorkdayConnectModelError( + "A Workday change plan must contain an exact scope." + ) + return document + + +def plan_hash(plan: Mapping[str, Any]) -> str: + encoded = json.dumps( + canonical_plan(plan), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def reject_sensitive_data(document: Any, path: str = "state") -> None: + if isinstance(document, dict): + for key, value in document.items(): + normalized = str(key).replace("-", "").replace("_", "").casefold() + if normalized in _SECRET_KEYS: + raise WorkdayConnectModelError( + f"Sensitive field '{path}.{key}' must not be persisted." + ) + reject_sensitive_data(value, f"{path}.{key}") + elif isinstance(document, list): + for index, value in enumerate(document): + reject_sensitive_data(value, f"{path}[{index}]") + + +def default_phase_state() -> dict[str, Any]: + return { + "status": PhaseStatus.PENDING.value, + "completedActions": [], + "approvedPlanHash": None, + "approvedPlan": None, + "evidence": [], + "blocker": None, + "updatedAt": None, + } + + +def default_state() -> dict[str, Any]: + return { + "schemaVersion": STATE_SCHEMA_VERSION, + "provider": "workday", + "status": "in-progress", + "scope": {}, + "identifiers": {}, + "endpoints": {}, + "operators": {}, + "tenantFoundation": None, + "phases": { + definition.identifier.value: default_phase_state() + for definition in PHASE_DEFINITIONS + }, + "migration": None, + "updatedAt": utc_now(), + } + + +def _validate_phase_state(phase_id: str, value: Any) -> None: + if not isinstance(value, dict): + raise WorkdayConnectModelError(f"Phase '{phase_id}' state must be an object.") + required = { + "status", + "completedActions", + "approvedPlanHash", + "approvedPlan", + "evidence", + "blocker", + "updatedAt", + } + missing = sorted(required - value.keys()) + if missing: + raise WorkdayConnectModelError( + f"Phase '{phase_id}' is missing: " + ", ".join(missing) + ) + if value["status"] not in {status.value for status in PhaseStatus}: + raise WorkdayConnectModelError( + f"Phase '{phase_id}' has invalid status '{value['status']}'." + ) + blocker = value["blocker"] + if value["status"] == PhaseStatus.BLOCKED.value: + if not isinstance(blocker, dict) or any( + not str(blocker.get(key) or "").strip() + for key in ("operation", "errorType", "message") + ): + raise WorkdayConnectModelError( + f"Phase '{phase_id}' must include a complete blocker." + ) + elif blocker is not None: + raise WorkdayConnectModelError( + f"Phase '{phase_id}' can contain a blocker only while blocked." + ) + if not isinstance(value["completedActions"], list) or any( + not isinstance(action, str) or not action + for action in value["completedActions"] + ): + raise WorkdayConnectModelError( + f"Phase '{phase_id}' completedActions must contain strings." + ) + if len(value["completedActions"]) != len(set(value["completedActions"])): + raise WorkdayConnectModelError( + f"Phase '{phase_id}' completedActions contains duplicates." + ) + if not isinstance(value["evidence"], list) or any( + not isinstance(record, dict) + or not isinstance(record.get("action"), str) + or not record.get("action") + for record in value["evidence"] + ): + raise WorkdayConnectModelError( + f"Phase '{phase_id}' evidence must contain action records." + ) + approved_plan = value["approvedPlan"] + approved_hash = value["approvedPlanHash"] + if approved_plan is not None: + observed_hash = plan_hash(approved_plan) + if approved_hash != observed_hash: + raise WorkdayConnectModelError( + f"Phase '{phase_id}' approved plan hash does not match." + ) + if value["status"] == PhaseStatus.COMPLETE.value: + required_actions = PHASE_REQUIRED_ACTIONS[phase_id] + completed_actions = set(value["completedActions"]) + missing_actions = sorted(required_actions - completed_actions) + evidence_actions = { + str(record.get("action") or "") for record in value["evidence"] + } + missing_evidence = sorted(required_actions - evidence_actions) + if missing_actions or missing_evidence: + details = [] + if missing_actions: + details.append("actions=" + ", ".join(missing_actions)) + if missing_evidence: + details.append("evidence=" + ", ".join(missing_evidence)) + raise WorkdayConnectModelError( + f"Phase '{phase_id}' cannot be complete without required " + + " and ".join(details) + + "." + ) + + +def _validate_tenant_foundation(value: Any) -> None: + if value is None: + return + if not isinstance(value, dict): + raise WorkdayConnectModelError( + "Workday tenantFoundation must be an object or null." + ) + required = { + "scope", + "identifiers", + "endpoints", + "phases", + "capturedAt", + } + missing = sorted(required - value.keys()) + if missing: + raise WorkdayConnectModelError( + "Workday tenantFoundation is missing: " + ", ".join(missing) + ) + for field in ("scope", "identifiers", "endpoints", "phases"): + if not isinstance(value[field], dict): + raise WorkdayConnectModelError( + f"Workday tenantFoundation.{field} must be an object." + ) + for key in ("entraTenantId", "workdayTenant"): + if not str(value["scope"].get(key) or "").strip(): + raise WorkdayConnectModelError( + f"Workday tenantFoundation.scope.{key} is required." + ) + missing_identifiers = sorted( + key + for key in TENANT_FOUNDATION_REQUIRED_IDENTIFIER_KEYS + if value["identifiers"].get(key) is None or value["identifiers"].get(key) == "" + ) + if missing_identifiers: + raise WorkdayConnectModelError( + "Workday tenantFoundation identifiers are missing: " + + ", ".join(missing_identifiers) + ) + certificate = value["identifiers"].get("signingCertificate") + if not isinstance(certificate, dict) or any( + not str(certificate.get(key) or "").strip() + for key in ("thumbprint", "validFrom", "validTo") + ): + raise WorkdayConnectModelError( + "Workday tenantFoundation signingCertificate must contain " + "thumbprint, validFrom, and validTo." + ) + missing_endpoints = sorted( + key + for key in TENANT_FOUNDATION_REQUIRED_ENDPOINT_KEYS + if not str(value["endpoints"].get(key) or "").strip() + ) + if missing_endpoints: + raise WorkdayConnectModelError( + "Workday tenantFoundation endpoints are missing: " + + ", ".join(missing_endpoints) + ) + if set(value["phases"]) != { + Phase.ENTRA.value, + Phase.WORKDAY_ADMIN.value, + }: + raise WorkdayConnectModelError( + "Workday tenantFoundation phases must contain exactly Entra and " + "Workday administrator evidence." + ) + for phase_id, snapshot in value["phases"].items(): + if not isinstance(snapshot, dict): + raise WorkdayConnectModelError( + f"Workday tenantFoundation phase '{phase_id}' must be an object." + ) + actions = snapshot.get("completedActions") + evidence = snapshot.get("evidence") + if not isinstance(actions, list) or any( + not isinstance(action, str) or not action for action in actions + ): + raise WorkdayConnectModelError( + f"Workday tenantFoundation phase '{phase_id}' actions are invalid." + ) + if not isinstance(evidence, list) or any( + not isinstance(record, dict) + or not isinstance(record.get("action"), str) + or not record.get("action") + for record in evidence + ): + raise WorkdayConnectModelError( + f"Workday tenantFoundation phase '{phase_id}' evidence is invalid." + ) + required_actions = PHASE_REQUIRED_ACTIONS[phase_id] + if not required_actions <= set(actions): + raise WorkdayConnectModelError( + f"Workday tenantFoundation phase '{phase_id}' is incomplete." + ) + evidence_actions = {str(record.get("action") or "") for record in evidence} + if not required_actions <= evidence_actions: + raise WorkdayConnectModelError( + f"Workday tenantFoundation phase '{phase_id}' lacks evidence." + ) + if not isinstance(value["capturedAt"], str) or not value["capturedAt"]: + raise WorkdayConnectModelError( + "Workday tenantFoundation.capturedAt is required." + ) + + +def validate_state(state: Any) -> dict[str, Any]: + if not isinstance(state, dict): + raise WorkdayConnectModelError( + "Workday connect state must contain a JSON object." + ) + reject_sensitive_data(state) + if state.get("schemaVersion") != STATE_SCHEMA_VERSION: + raise WorkdayConnectModelError( + "Unsupported Workday connect state schema version: " + f"{state.get('schemaVersion')!r}." + ) + if state.get("provider") != "workday": + raise WorkdayConnectModelError( + "Workday connect state provider must be 'workday'." + ) + for field in ("scope", "identifiers", "endpoints", "operators", "phases"): + if not isinstance(state.get(field), dict): + raise WorkdayConnectModelError( + f"Workday connect state '{field}' must be an object." + ) + _validate_tenant_foundation(state.get("tenantFoundation")) + phases = state["phases"] + if set(phases) != set(PHASE_BY_ID): + raise WorkdayConnectModelError( + "Workday connect state must contain exactly the six phases." + ) + for phase_id, value in phases.items(): + _validate_phase_state(phase_id, value) + for definition in PHASE_DEFINITIONS: + if definition.prerequisite is None: + continue + phase = phases[definition.identifier.value] + prerequisite = phases[definition.prerequisite.value] + if ( + phase["status"] == PhaseStatus.COMPLETE.value + and prerequisite["status"] != PhaseStatus.COMPLETE.value + ): + raise WorkdayConnectModelError( + f"Phase '{definition.identifier.value}' cannot be complete " + f"before '{definition.prerequisite.value}'." + ) + expected_status = ( + "ready" + if all( + phase["status"] == PhaseStatus.COMPLETE.value for phase in phases.values() + ) + else "in-progress" + ) + if state.get("status") != expected_status: + raise WorkdayConnectModelError( + f"Workday connect status must be '{expected_status}'." + ) + return state + + +def next_phase_id(state: Mapping[str, Any]) -> str | None: + phases = state["phases"] + for definition in PHASE_DEFINITIONS: + if phases[definition.identifier.value]["status"] != PhaseStatus.COMPLETE.value: + return definition.identifier.value + return None + + +def progress_text(state: Mapping[str, Any]) -> str: + labels = { + PhaseStatus.PENDING.value: "Pending", + PhaseStatus.ACTIVE.value: "In progress", + PhaseStatus.BLOCKED.value: "Needs attention", + PhaseStatus.COMPLETE.value: "Complete", + } + next_phase = next_phase_id(state) + rows = [ + "### Workday connection progress", + "", + "| # | Phase | Status |", + "|---:|---|---|", + ] + for index, definition in enumerate(PHASE_DEFINITIONS, start=1): + status = state["phases"][definition.identifier.value]["status"] + label = labels[status] + if status == PhaseStatus.PENDING.value and ( + definition.identifier.value == next_phase + ): + label = "Next" + rows.append(f"| {index} | {definition.title} | {label} |") + return "\n".join(rows) + + +def next_phase_summary(state: Mapping[str, Any]) -> dict[str, Any] | None: + phase_id = next_phase_id(state) + if phase_id is None: + return None + definition = PHASE_BY_ID[phase_id] + return { + "id": phase_id, + "title": definition.title, + "whatHappens": list(definition.what_happens), + } diff --git a/solutions/ess-maker-skills/scripts/workday_connect_preflight.py b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py new file mode 100644 index 000000000..0f4ca4086 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py @@ -0,0 +1,638 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Identity-aware preflight for the Workday connect lifecycle.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +import subprocess +from typing import Any, Callable + +from auth import authenticate, query_all +from install_workday_da_extension import ( + PacCliError, + install_workday_package, + resolve_pac_executable, +) +from workday_connect_auth import ( + WorkdayConnectIdentityError, + authentication_plan, + require_identity, +) +from workday_connect_model import load_catalog, plan_hash +from workday_connect_store import WorkdayConnectStore + + +class WorkdayConnectPreflightError(RuntimeError): + """Raised when the Workday target cannot be proven safely.""" + + +@dataclass(frozen=True) +class PreflightTarget: + agent: dict[str, Any] + environment_id: str | None + architecture: str + package_flavor: str + dataverse_url: str + foundation_ring: str + pac_ring: str + + +def _read_json(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise WorkdayConnectPreflightError( + f"Required workspace state could not be read: {path}: {exc}" + ) from exc + if not isinstance(document, dict): + raise WorkdayConnectPreflightError( + f"Required workspace state must contain an object: {path}" + ) + return document + + +def _active_agent(config: dict[str, Any]) -> dict[str, Any]: + active_slug = str(config.get("activeAgent") or "").strip() + candidates = config.get("agents") + if isinstance(candidates, list) and active_slug: + matches = [ + agent + for agent in candidates + if isinstance(agent, dict) + and str(agent.get("slug") or "") == active_slug + ] + if len(matches) == 1: + return matches[0] + legacy = config.get("agent") + if ( + isinstance(legacy, dict) + and str(legacy.get("slug") or "") == active_slug + ): + return legacy + raise WorkdayConnectPreflightError( + "Select one setup-complete ESS HR agent before connecting Workday." + ) + + +def _require_materialized_workspace( + setup_state: dict[str, Any], + agent: dict[str, Any], +) -> None: + if setup_state.get("schema_version") != 4: + raise WorkdayConnectPreflightError( + "The selected agent workspace is not on the supported setup schema." + ) + bot_id = str(agent.get("botId") or "").casefold() + slug = str(agent.get("slug") or "") + agents = setup_state.get("agents") + if not bot_id or not slug or not isinstance(agents, dict): + raise WorkdayConnectPreflightError( + "The selected agent is missing canonical workspace identity." + ) + candidate = next( + ( + value + for key, value in agents.items() + if isinstance(value, dict) + and ( + str(key).casefold() == bot_id + or str((value.get("agent") or {}).get("id") or "").casefold() + == bot_id + ) + and str( + (value.get("agent") or {}).get("workspace_slug") or "" + ) + == slug + ), + None, + ) + if not isinstance(candidate, dict): + raise WorkdayConnectPreflightError( + "The selected agent does not have canonical workspace evidence." + ) + steps = candidate.get("steps") + setup_07 = steps.get("SETUP-07") if isinstance(steps, dict) else None + if not isinstance(setup_07, dict) or setup_07.get("state") != "done": + raise WorkdayConnectPreflightError( + "Finish the selected agent's workspace setup before connecting " + "Workday." + ) + + +def _pac_ring(foundation_ring: str) -> str: + normalized = str(foundation_ring or "prod").casefold() + if normalized in {"test", "preprod"}: + return "preprod" + if normalized == "prod": + return "prod" + raise WorkdayConnectPreflightError( + f"Unsupported Power Platform ring: {foundation_ring!r}." + ) + + +def _cached_dataverse_url( + workspace_root: Path, + *, + environment_id: str, + ring: str, +) -> str: + if not environment_id: + return "" + inventory = _read_json( + workspace_root + / ".local" + / "setup" + / f"environment-list-{ring}.json" + ) + environments = inventory.get("environments") + if not isinstance(environments, list): + return "" + matches = [ + environment + for environment in environments + if isinstance(environment, dict) + and str(environment.get("id") or "").casefold() + == environment_id.casefold() + ] + if len(matches) > 1: + raise WorkdayConnectPreflightError( + "Setup environment inventory contains duplicate records for " + f"environment {environment_id}." + ) + if not matches: + return "" + match = matches[0] + properties = match.get("properties") + if not isinstance(properties, dict): + properties = {} + linked = properties.get("linkedEnvironmentMetadata") + if not isinstance(linked, dict): + linked = {} + return str( + match.get("url") + or match.get("instanceUrl") + or linked.get("instanceApiUrl") + or linked.get("instanceUrl") + or "" + ).strip() + + +def _pac_dataverse_url( + environment_id: str, + *, + pac_resolver: Callable[[], Path], + runner: Callable[..., subprocess.CompletedProcess], +) -> str: + if not environment_id: + return "" + try: + pac = pac_resolver() + except PacCliError: + return "" + try: + result = runner( + [ + str(pac), + "org", + "who", + "--environment", + environment_id, + "--json", + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise WorkdayConnectPreflightError( + "PAC did not resolve the setup environment within one minute." + ) from exc + if result.returncode != 0: + return "" + try: + identity = json.loads(result.stdout or "") + except json.JSONDecodeError as exc: + raise WorkdayConnectPreflightError( + "PAC returned invalid environment identity JSON." + ) from exc + if not isinstance(identity, dict): + raise WorkdayConnectPreflightError( + "PAC returned an invalid environment identity result." + ) + observed_id = str(identity.get("EnvironmentId") or "").strip() + if observed_id.casefold() != environment_id.casefold(): + raise WorkdayConnectPreflightError( + "PAC resolved a different environment than setup recorded." + ) + return str(identity.get("OrgUrl") or "").strip() + + +def _normalize_dataverse_url(value: str | None) -> str: + return str(value or "").strip().rstrip("/").casefold() + + +def resolve_target( + workspace_root: Path, + *, + dataverse_url: str | None, + state: dict[str, Any], + catalog: dict[str, Any] | None = None, + pac_resolver: Callable[[], Path] = resolve_pac_executable, + pac_runner: Callable[..., subprocess.CompletedProcess] = subprocess.run, +) -> PreflightTarget: + active_catalog = catalog or load_catalog() + foundation = _read_json(workspace_root / ".local" / "config.json") + setup_state = _read_json( + workspace_root / ".local" / "setup" / "config.json" + ) + agent = _active_agent(foundation) + schema = str(agent.get("schemaName") or "").casefold() + supported = active_catalog["supportedAgents"].get(schema) + if supported is None: + if schema in set(active_catalog["unsupportedAgents"]): + raise WorkdayConnectPreflightError( + "This Workday lifecycle supports the native ESS HR agent " + "only. Classic DA and ESS IT agents are not supported." + ) + raise WorkdayConnectPreflightError( + "The selected agent is not a supported ESS HR architecture." + ) + _require_materialized_workspace(setup_state, agent) + + foundation_ring = str(foundation.get("ring") or "prod").casefold() + foundation_environment_id = str( + foundation.get("environmentId") or "" + ).strip() + setup_environment_id = str( + (setup_state.get("environment") or {}).get("id") or "" + ).strip() + if ( + foundation_environment_id + and setup_environment_id + and foundation_environment_id.casefold() + != setup_environment_id.casefold() + ): + raise WorkdayConnectPreflightError( + "Foundation and setup state record different Power Platform " + "environment IDs. Refresh setup before continuing." + ) + environment_id = foundation_environment_id or setup_environment_id + if not environment_id: + raise WorkdayConnectPreflightError( + "The setup state does not contain an exact Power Platform " + "environment ID." + ) + + foundation_url = str( + foundation.get("dataverseEndpoint") or "" + ).strip() + inventory_url = _cached_dataverse_url( + workspace_root, + environment_id=environment_id, + ring=foundation_ring, + ) + state_scope = state.get("scope") or {} + stored_environment_id = str( + state_scope.get("environmentId") or "" + ).strip() + stored_url = ( + str(state_scope.get("dataverseUrl") or "").strip() + if stored_environment_id.casefold() == environment_id.casefold() + else "" + ) + supplied_url = str(dataverse_url or "").strip() + + authoritative_urls = [ + value for value in (foundation_url, inventory_url) if value + ] + if len( + {_normalize_dataverse_url(value) for value in authoritative_urls} + ) > 1: + raise WorkdayConnectPreflightError( + "Foundation setup and environment inventory disagree on the " + "Dataverse URL for the recorded environment ID. Refresh setup " + "before continuing." + ) + exact_url = foundation_url or inventory_url + if not exact_url: + pac_url = _pac_dataverse_url( + environment_id, + pac_resolver=pac_resolver, + runner=pac_runner, + ) + if not pac_url: + raise WorkdayConnectPreflightError( + "PAC could not prove the Dataverse URL for the recorded " + "environment ID. Refresh Power Platform authentication and " + "try again." + ) + exact_url = pac_url + for candidate, source in ( + (stored_url, "stored Workday state"), + (supplied_url, "supplied Dataverse URL"), + ): + if candidate and ( + _normalize_dataverse_url(candidate) + != _normalize_dataverse_url(exact_url) + ): + raise WorkdayConnectPreflightError( + f"The {source} does not match the URL proven for the setup " + "environment ID." + ) + exact_url = exact_url.rstrip("/") + if not exact_url: + raise WorkdayConnectPreflightError( + "The setup environment does not have a resolved Dataverse URL. " + "Refresh environment inventory for the recorded environment ID " + "or provide that environment's exact Dataverse URL." + ) + if not exact_url.casefold().startswith("https://"): + raise WorkdayConnectPreflightError( + "The Workday Dataverse environment URL must use HTTPS." + ) + return PreflightTarget( + agent={ + key: agent[key] + for key in ("slug", "botId", "schemaName", "name") + if agent.get(key) + }, + environment_id=environment_id or None, + architecture=supported["architecture"], + package_flavor=supported["packageFlavor"], + dataverse_url=exact_url, + foundation_ring=foundation_ring, + pac_ring=_pac_ring(foundation_ring), + ) + + +def _installed_solutions( + environment_url: str, + token: str, + *, + query: Callable[..., list[dict[str, Any]]], + catalog: dict[str, Any], +) -> dict[str, dict[str, Any]]: + schemas = [ + package["solutionSchemaName"] + for package in catalog["packages"].values() + ] + filter_expression = " or ".join( + f"uniquename eq '{schema}'" for schema in schemas + ) + rows = query( + environment_url, + token, + "solutions", + "solutionid,uniquename,friendlyname,ismanaged,version", + filter_expression, + ) + return { + str(row.get("uniquename") or "").casefold(): row + for row in rows + if isinstance(row, dict) + } + + +def run_preflight( + workspace_root: Path, + *, + dataverse_url: str | None, + maker_username: str | None, + store: WorkdayConnectStore | None = None, + token_provider: Callable[..., str] = authenticate, + query: Callable[..., list[dict[str, Any]]] = query_all, + installer: Callable[..., dict[str, Any]] = install_workday_package, + identity_provider: Callable[..., dict[str, str]] = require_identity, + catalog: dict[str, Any] | None = None, + approved_install_hash: str | None = None, + plan_verifier: Callable[[dict[str, Any], str], Any] | None = None, + pac_resolver: Callable[[], Path] = resolve_pac_executable, + pac_runner: Callable[..., subprocess.CompletedProcess] = subprocess.run, +) -> dict[str, Any]: + active_catalog = catalog or load_catalog() + state_store = store or WorkdayConnectStore(workspace_root) + state = state_store.initialize() + stored_maker = str( + ( + state.get("operators", {}).get("powerPlatformMaker") or {} + ).get("username") + or "" + ).strip() + preflight_phase = (state.get("phases") or {}).get("preflight") or {} + approved_plan = preflight_phase.get("approvedPlan") or {} + approved_scope = ( + approved_plan.get("scope") + if isinstance(approved_plan, dict) + else {} + ) + approved_maker = ( + str((approved_scope or {}).get("makerUsername") or "").strip() + if approved_install_hash + else "" + ) + intended_maker = str( + maker_username or stored_maker or approved_maker or "" + ).strip() or None + target = resolve_target( + workspace_root, + dataverse_url=dataverse_url, + state=state, + catalog=active_catalog, + pac_resolver=pac_resolver, + pac_runner=pac_runner, + ) + try: + token = token_provider( + target.dataverse_url, + preferred_username=intended_maker, + ) + except SystemExit as exc: + raise WorkdayConnectPreflightError( + "Dataverse authentication did not complete." + ) from exc + except (OSError, RuntimeError, ValueError) as exc: + raise WorkdayConnectPreflightError( + f"Dataverse authentication failed: {exc}" + ) from exc + try: + identity = identity_provider( + token, + preferred_username=intended_maker, + ) + except WorkdayConnectIdentityError as exc: + raise WorkdayConnectPreflightError(str(exc)) from exc + try: + installed = _installed_solutions( + target.dataverse_url, + token, + query=query, + catalog=active_catalog, + ) + except (OSError, RuntimeError, ValueError) as exc: + raise WorkdayConnectPreflightError( + f"Dataverse package discovery failed: {exc}" + ) from exc + package = active_catalog["packages"][target.package_flavor] + required_schema = package["solutionSchemaName"] + package_action = "unchanged" + pac_identity = None + if required_schema.casefold() not in installed: + install_plan = { + "phase": "preflight", + "scope": { + "environmentId": target.environment_id, + "dataverseUrl": target.dataverse_url, + "agent": { + key: target.agent.get(key) + for key in ("slug", "botId", "schemaName") + }, + "makerUsername": identity["username"], + }, + "package": { + "flavor": target.package_flavor, + "schemaName": required_schema, + }, + "actions": [ + "Install the supported Workday runtime package", + "Reread Dataverse to verify the package installation", + ], + } + if not approved_install_hash: + return { + "requiresApproval": True, + "plan": { + **install_plan, + "planHash": plan_hash(install_plan), + }, + "approvalSummary": { + "environmentUrl": target.dataverse_url, + "agent": target.agent.get("name") or target.agent.get("slug"), + "makerAccount": identity["username"], + "packageSchema": required_schema, + "actions": install_plan["actions"], + }, + "authenticationPlan": authentication_plan(), + "status": state_store.status(), + } + if plan_verifier is None: + raise WorkdayConnectPreflightError( + "Package installation requires a stored approved plan." + ) + plan_verifier(install_plan, approved_install_hash) + try: + install_result = installer( + target.dataverse_url, + target.package_flavor, + ring=target.pac_ring, + preferred_username=identity["username"], + ) + except (OSError, PacCliError, RuntimeError) as exc: + raise WorkdayConnectPreflightError( + f"Workday package installation failed: {exc}" + ) from exc + pac_identity = install_result.get("authenticatedAccount") + if not pac_identity or ( + pac_identity.casefold() != identity["username"].casefold() + ): + raise WorkdayConnectPreflightError( + "PAC package installation did not prove the intended " + "Environment Maker account." + ) + try: + installed = _installed_solutions( + target.dataverse_url, + token, + query=query, + catalog=active_catalog, + ) + except (OSError, RuntimeError, ValueError) as exc: + raise WorkdayConnectPreflightError( + f"Post-install package verification failed: {exc}" + ) from exc + if required_schema.casefold() not in installed: + raise WorkdayConnectPreflightError( + "PAC completed, but the required Workday package was not " + "found during post-install verification." + ) + package_action = "installed" + + existing_operator = ( + state_store.load().get("operators", {}).get("powerPlatformMaker") or {} + ) + existing_credential_stores = ( + existing_operator.get("credentialStores") or {} + if isinstance(existing_operator, dict) + else {} + ) + pac_status = ( + "verified" + if pac_identity + or existing_credential_stores.get("pac") == "verified" + else "not-required" + ) + state_store.merge_section( + "scope", + { + "agent": target.agent, + "dataverseUrl": target.dataverse_url, + "environmentId": target.environment_id, + "architecture": target.architecture, + "packageFlavor": target.package_flavor, + "ring": target.foundation_ring, + "vertical": "hr", + "entraTenantId": identity["tenantId"], + }, + ) + state_store.merge_section( + "operators", + { + "powerPlatformMaker": { + "username": identity["username"], + "tenantId": identity["tenantId"], + "credentialStores": { + "dataverse-msal": "verified", + "pac": pac_status, + }, + } + }, + ) + state_store.complete_action( + "preflight", + "verify-target", + evidence={ + "outcome": "passed", + "environmentUrl": target.dataverse_url, + "account": identity["username"], + }, + ) + state_store.complete_action( + "preflight", + "verify-package", + evidence={ + "outcome": "passed", + "packageSchema": required_schema, + "packageAction": package_action, + "pacAccount": pac_identity, + }, + ) + final_state = state_store.set_phase_status("preflight", "complete") + return { + "scope": final_state["scope"], + "operator": final_state["operators"]["powerPlatformMaker"], + "package": { + "flavor": target.package_flavor, + "schemaName": required_schema, + "action": package_action, + }, + "authenticationPlan": authentication_plan(), + "status": state_store.status(), + } diff --git a/solutions/ess-maker-skills/scripts/workday_connect_runtime.py b/solutions/ess-maker-skills/scripts/workday_connect_runtime.py new file mode 100644 index 000000000..b536b731c --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_connect_runtime.py @@ -0,0 +1,1009 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Consolidated preview, apply, and verification for Workday runtime wiring.""" + +from __future__ import annotations + +import json +from pathlib import Path +import shutil +import subprocess +from typing import Any, Callable, Mapping + +from auth import authenticate, query_all, update_record +from install_workday_da_extension import ( + ensure_pac_auth, + resolve_pac_executable, +) +from workday_connect_model import load_catalog, plan_hash +from workday_connect_auth import ( + WorkdayConnectIdentityError, + require_identity, +) + + +ACTIVE_FLOW_STATE = 1 +ACTIVE_FLOW_STATUS = 2 +CLOUD_FLOW_CATEGORY = 5 +AUTHORIZATION_SCRIPT = "alm/Enable-CosmosDAFlowAuthorization.ps1" + + +class WorkdayConnectRuntimeError(RuntimeError): + """Raised when runtime configuration cannot proceed safely.""" + + def __init__( + self, + message: str, + *, + details: Mapping[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.details = dict(details or {}) + + +def _required_text( + document: Mapping[str, Any], + key: str, + label: str, +) -> str: + value = str(document.get(key) or "").strip() + if not value: + raise WorkdayConnectRuntimeError(f"{label} is required.") + return value + + +def _odata_literal(value: str) -> str: + return value.replace("'", "''") + + +def _connector_name(connection: Mapping[str, Any]) -> str: + api_id = str((connection.get("properties") or {}).get("apiId") or "") + return api_id.rstrip("/").rsplit("/", 1)[-1].casefold() + + +def _connected(connection: Mapping[str, Any]) -> bool: + statuses = (connection.get("properties") or {}).get("statuses") or [] + return any( + isinstance(status, Mapping) + and str(status.get("status") or "").casefold() == "connected" + for status in statuses + ) + + +def _select_connection( + connections: list[dict[str, Any]], + connector_name: str, + *, + explicit_id: str | None, +) -> dict[str, Any]: + connected_matches = [ + value + for value in connections + if _connector_name(value) == connector_name.casefold() + and _connected(value) + ] + matches = [ + value + for value in connected_matches + if not explicit_id + or str(value.get("name") or "").casefold() == explicit_id.casefold() + ] + if len(matches) != 1: + candidates = sorted( + ( + { + "connectionId": str(value.get("name") or ""), + "displayName": str( + (value.get("properties") or {}).get("displayName") + or connector_name + ), + } + for value in connected_matches + ), + key=lambda value: ( + value["displayName"].casefold(), + value["connectionId"].casefold(), + ), + ) + safe = sorted({value["displayName"] for value in candidates}) + raise WorkdayConnectRuntimeError( + f"Expected exactly one connected {connector_name} connection; " + f"found {len(matches)}. Connected display names: " + f"{json.dumps(safe, sort_keys=True)}", + details={ + "connector": connector_name, + "candidateConnections": candidates, + }, + ) + return matches[0] + + +def _list_connections( + pac_executable: Path, + environment_url: str, + *, + runner: Callable[..., subprocess.CompletedProcess], +) -> list[dict[str, Any]]: + try: + result = runner( + [ + str(pac_executable), + "connectivity", + "list-connections", + "--environment", + environment_url, + "--json", + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise WorkdayConnectRuntimeError( + "PAC did not finish listing environment connections within 2 minutes." + ) from exc + if result.returncode != 0: + raise WorkdayConnectRuntimeError( + "PAC could not list the target environment's connections." + ) + try: + payload = json.loads(result.stdout or "") + except json.JSONDecodeError as exc: + raise WorkdayConnectRuntimeError( + "PAC returned invalid connection inventory JSON." + ) from exc + values = payload.get("value") + if not isinstance(values, list): + raise WorkdayConnectRuntimeError( + "PAC connection inventory did not contain a value array." + ) + return [value for value in values if isinstance(value, dict)] + + +def _single_rows( + rows: list[dict[str, Any]], + names: list[str], + key: str, + label: str, +) -> dict[str, dict[str, Any]]: + grouped = {name: [] for name in names} + for row in rows: + observed = str(row.get(key) or "").casefold() + for name in names: + if observed == name.casefold(): + grouped[name].append(row) + invalid = { + name: len(matches) for name, matches in grouped.items() if len(matches) != 1 + } + if invalid: + raise WorkdayConnectRuntimeError( + f"Expected exactly one installed {label} for each reviewed name; " + f"observed {json.dumps(invalid, sort_keys=True)}." + ) + return {name: grouped[name][0] for name in names} + + +def _runtime_references( + environment_url: str, + token: str, + logical_names: list[str], + *, + query: Callable[..., list[dict[str, Any]]], +) -> dict[str, dict[str, Any]]: + filters = " or ".join( + f"connectionreferencelogicalname eq '{_odata_literal(name)}'" + for name in logical_names + ) + rows = query( + environment_url, + token, + "connectionreferences", + "connectionreferenceid,connectionreferencelogicalname," + "connectionreferencedisplayname,connectionid", + filters, + ) + return _single_rows( + rows, + logical_names, + "connectionreferencelogicalname", + "connection reference", + ) + + +def _runtime_flows( + environment_url: str, + token: str, + flow_names: list[str], + allowed_workflow_ids: set[str], + *, + query: Callable[..., list[dict[str, Any]]], +) -> dict[str, dict[str, Any]]: + filters = " or ".join(f"name eq '{_odata_literal(name)}'" for name in flow_names) + rows = query( + environment_url, + token, + "workflows", + "workflowid,name,statecode,statuscode,category", + filters, + ) + flows = _single_rows(rows, flow_names, "name", "Workday flow") + outside_package = [ + name + for name, row in flows.items() + if str(row.get("workflowid") or "").casefold() not in allowed_workflow_ids + ] + if outside_package: + raise WorkdayConnectRuntimeError( + "A reviewed Workday flow name resolved outside the selected " + "installed package: " + ", ".join(sorted(outside_package)) + ) + non_cloud = [ + name + for name, row in flows.items() + if row.get("category") != CLOUD_FLOW_CATEGORY + ] + if non_cloud: + raise WorkdayConnectRuntimeError( + "Refusing to configure non-cloud workflow records: " + + ", ".join(sorted(non_cloud)) + ) + return flows + + +def _solution_component_ids( + environment_url: str, + token: str, + solution_schema: str, + *, + query: Callable[..., list[dict[str, Any]]], +) -> set[str]: + solutions = query( + environment_url, + token, + "solutions", + "solutionid,uniquename", + f"uniquename eq '{_odata_literal(solution_schema)}'", + ) + if len(solutions) != 1: + raise WorkdayConnectRuntimeError( + "Expected exactly one installed Workday package solution " + f"'{solution_schema}'; found {len(solutions)}." + ) + solution_id = _required_text( + solutions[0], + "solutionid", + f"Solution ID for {solution_schema}", + ) + components = query( + environment_url, + token, + "solutioncomponents", + "objectid,componenttype", + f"_solutionid_value eq {solution_id} and componenttype eq 29", + ) + component_ids = { + str(value.get("objectid") or "").casefold() + for value in components + if str(value.get("objectid") or "").strip() + } + if not component_ids: + raise WorkdayConnectRuntimeError( + "The installed Workday package did not expose solution-component " + "membership for its reviewed flows." + ) + return component_ids + + +def _default_runner(command: list[str], **kwargs) -> subprocess.CompletedProcess: + kwargs.setdefault("text", True) + kwargs.setdefault("encoding", "utf-8") + kwargs.setdefault("errors", "replace") + return subprocess.run(command, **kwargs) + + +def _runtime_discovery_context( + state: Mapping[str, Any], + catalog: Mapping[str, Any] | None, +) -> dict[str, Any]: + scope = state.get("scope") or {} + operators = state.get("operators") or {} + agent = scope.get("agent") or {} + package_flavor = _required_text(scope, "packageFlavor", "Workday package flavor") + active_catalog = catalog or load_catalog() + package = (active_catalog.get("packages") or {}).get(package_flavor) + if not isinstance(package, Mapping): + raise WorkdayConnectRuntimeError( + f"Unknown Workday package flavor: {package_flavor}." + ) + flow_names = package.get("flowNames") + if not isinstance(flow_names, list) or not flow_names: + raise WorkdayConnectRuntimeError( + "This Workday architecture requires a manual runtime handoff; " + "no reviewed flow catalog is available." + ) + ring = str(scope.get("ring") or "prod").casefold() + return { + "agent": agent, + "environmentUrl": _required_text( + scope, "dataverseUrl", "Dataverse environment URL" + ).rstrip("/"), + "packageFlavor": package_flavor, + "botId": _required_text(agent, "botId", "Workday agent bot ID"), + "maker": _required_text( + operators.get("powerPlatformMaker") or {}, + "username", + "Power Platform maker account", + ), + "pacRing": "preprod" if ring in {"test", "preprod"} else "prod", + "package": package, + "flowNames": [str(name) for name in flow_names], + "referencesCatalog": active_catalog["connectionReferences"], + "selectedConnectionIds": _selected_connection_ids(state), + } + + +def _selected_connection_ids( + state: Mapping[str, Any], +) -> dict[str, str]: + phase = (state.get("phases") or {}).get("connections") or {} + for record in reversed(phase.get("evidence") or []): + if ( + isinstance(record, Mapping) + and record.get("action") == "physical-connections-verified" + and isinstance(record.get("connectionIds"), Mapping) + ): + values = record["connectionIds"] + return { + "workday": str(values.get("workday") or "").strip(), + "dataverse": str(values.get("dataverse") or "").strip(), + } + return {"workday": "", "dataverse": ""} + + +def _effective_connection_id( + connector: str, + requested: str | None, + recorded: str, +) -> str | None: + requested_value = str(requested or "").strip() + recorded_value = str(recorded or "").strip() + if ( + requested_value + and recorded_value + and requested_value.casefold() != recorded_value.casefold() + ): + raise WorkdayConnectRuntimeError( + f"The requested {connector} connection ID differs from the " + "connection verified during the Connections phase." + ) + return requested_value or recorded_value or None + + +def _build_runtime_discovery( + context: Mapping[str, Any], + *, + workday: Mapping[str, Any], + dataverse: Mapping[str, Any], + references: Mapping[str, Mapping[str, Any]], + flows: Mapping[str, Mapping[str, Any]], +) -> dict[str, Any]: + references_catalog = context["referencesCatalog"] + logical_names = [ + references_catalog["workday"]["logicalName"], + references_catalog["dataverse"]["logicalName"], + ] + target_connections = { + logical_names[0]: { + "connectionId": str(workday.get("name") or ""), + "displayName": str( + (workday.get("properties") or {}).get("displayName") or "Workday" + ), + "connector": references_catalog["workday"]["connectorName"], + }, + logical_names[1]: { + "connectionId": str(dataverse.get("name") or ""), + "displayName": str( + (dataverse.get("properties") or {}).get("displayName") + or "Microsoft Dataverse" + ), + "connector": references_catalog["dataverse"]["connectorName"], + }, + } + flow_targets = [ + { + "name": name, + "workflowId": _required_text( + flows[name], "workflowid", f"Workflow ID for {name}" + ), + } + for name in context["flowNames"] + ] + plan = { + "phase": "runtime", + "scope": { + "dataverseUrl": context["environmentUrl"], + "botId": context["botId"], + "packageFlavor": context["packageFlavor"], + "makerUsername": context["maker"], + }, + "connectionBindings": target_connections, + "flows": flow_targets, + "delegatedAuthorization": { + "botId": context["botId"], + "workflowIds": [target["workflowId"] for target in flow_targets], + "script": AUTHORIZATION_SCRIPT, + }, + "actions": [ + "Bind the reviewed Workday and Dataverse connection references", + "Activate the reviewed Workday runtime cloud flows", + "Authorize the selected agent to invoke each reviewed flow", + "Reread and verify every changed Dataverse record", + ], + } + observed = { + "connectionBindings": { + name: { + "selectedDisplayName": target_connections[name]["displayName"], + "alreadyBoundToSelection": str( + references[name].get("connectionid") or "" + ).casefold() + == target_connections[name]["connectionId"].casefold(), + } + for name in logical_names + }, + "flowStates": { + name: { + "statecode": flows[name].get("statecode"), + "statuscode": flows[name].get("statuscode"), + } + for name in context["flowNames"] + }, + } + return { + "plan": {**plan, "planHash": plan_hash(plan)}, + "approvalSummary": { + "environmentUrl": context["environmentUrl"], + "agentName": str(context["agent"].get("name") or "ESS HR agent"), + "connections": [ + value["displayName"] for value in target_connections.values() + ], + "flows": [target["name"] for target in flow_targets], + }, + "observed": observed, + } + + +def discover_runtime_plan( + state: Mapping[str, Any], + *, + workday_connection_id: str | None = None, + dataverse_connection_id: str | None = None, + catalog: Mapping[str, Any] | None = None, + token: str | None = None, + token_provider: Callable[..., str] = authenticate, + query: Callable[..., list[dict[str, Any]]] = query_all, + pac_resolver: Callable[[], Path] = resolve_pac_executable, + pac_auth: Callable[..., Any] = ensure_pac_auth, + runner: Callable[..., subprocess.CompletedProcess] = _default_runner, +) -> dict[str, Any]: + """Discover exact runtime targets and return a stable approval plan.""" + context = _runtime_discovery_context(state, catalog) + selected_ids = context["selectedConnectionIds"] + workday, dataverse = _discover_physical_connections( + context, + workday_connection_id=_effective_connection_id( + "Workday", + workday_connection_id, + selected_ids["workday"], + ), + dataverse_connection_id=_effective_connection_id( + "Dataverse", + dataverse_connection_id, + selected_ids["dataverse"], + ), + pac_resolver=pac_resolver, + pac_auth=pac_auth, + runner=runner, + ) + + active_token = token or token_provider( + context["environmentUrl"], + preferred_username=context["maker"], + ) + references_catalog = context["referencesCatalog"] + logical_names = [ + references_catalog["workday"]["logicalName"], + references_catalog["dataverse"]["logicalName"], + ] + references = _runtime_references( + context["environmentUrl"], + active_token, + logical_names, + query=query, + ) + solution_component_ids = _solution_component_ids( + context["environmentUrl"], + active_token, + _required_text( + context["package"], + "solutionSchemaName", + "Workday package solution schema", + ), + query=query, + ) + flows = _runtime_flows( + context["environmentUrl"], + active_token, + context["flowNames"], + solution_component_ids, + query=query, + ) + return _build_runtime_discovery( + context, + workday=workday, + dataverse=dataverse, + references=references, + flows=flows, + ) + + +def _discover_physical_connections( + context: Mapping[str, Any], + *, + workday_connection_id: str | None, + dataverse_connection_id: str | None, + pac_resolver: Callable[[], Path], + pac_auth: Callable[..., Any], + runner: Callable[..., subprocess.CompletedProcess], +) -> tuple[dict[str, Any], dict[str, Any]]: + pac = pac_resolver() + pac_auth( + pac, + ring=context["pacRing"], + environment_url=context["environmentUrl"], + preferred_username=context["maker"], + runner=runner, + ) + connections = _list_connections( + pac, + context["environmentUrl"], + runner=runner, + ) + references_catalog = context["referencesCatalog"] + workday = _select_connection( + connections, + references_catalog["workday"]["connectorName"], + explicit_id=workday_connection_id, + ) + dataverse = _select_connection( + connections, + references_catalog["dataverse"]["connectorName"], + explicit_id=dataverse_connection_id, + ) + return workday, dataverse + + +def verify_physical_connections( + state: Mapping[str, Any], + *, + workday_connection_id: str | None = None, + dataverse_connection_id: str | None = None, + catalog: Mapping[str, Any] | None = None, + pac_resolver: Callable[[], Path] = resolve_pac_executable, + pac_auth: Callable[..., Any] = ensure_pac_auth, + runner: Callable[..., subprocess.CompletedProcess] = _default_runner, +) -> dict[str, Any]: + """Verify selected Workday and Dataverse connections from live state.""" + try: + context = _runtime_discovery_context(state, catalog) + selected_ids = context["selectedConnectionIds"] + workday, dataverse = _discover_physical_connections( + context, + workday_connection_id=_effective_connection_id( + "Workday", + workday_connection_id, + selected_ids["workday"], + ), + dataverse_connection_id=_effective_connection_id( + "Dataverse", + dataverse_connection_id, + selected_ids["dataverse"], + ), + pac_resolver=pac_resolver, + pac_auth=pac_auth, + runner=runner, + ) + except WorkdayConnectRuntimeError: + raise + except (OSError, RuntimeError, ValueError, SystemExit) as exc: + raise WorkdayConnectRuntimeError( + f"Power Platform connection discovery failed: {exc}" + ) from exc + return { + "makerUsername": context["maker"], + "connectionIds": { + "workday": str(workday.get("name") or ""), + "dataverse": str(dataverse.get("name") or ""), + }, + "connections": [ + { + "connector": context["referencesCatalog"]["workday"]["connectorName"], + "displayName": str( + (workday.get("properties") or {}).get("displayName") or "Workday" + ), + }, + { + "connector": context["referencesCatalog"]["dataverse"]["connectorName"], + "displayName": str( + (dataverse.get("properties") or {}).get("displayName") + or "Microsoft Dataverse" + ), + }, + ], + } + + +def run_runtime_operation( + state: Mapping[str, Any], + *, + apply: bool, + approved_hash: str | None = None, + verifier: Callable[[Mapping[str, Any], str], Any] | None = None, + workday_connection_id: str | None = None, + dataverse_connection_id: str | None = None, + token_provider: Callable[..., str] = authenticate, + identity_provider: Callable[..., dict[str, str]] = require_identity, + query: Callable[..., list[dict[str, Any]]] = query_all, + updater: Callable[..., bool] = update_record, + authorization_runner: Callable[..., subprocess.CompletedProcess] = _default_runner, + stage_recorder: Callable[[str, Mapping[str, Any]], Any] | None = None, + **discovery_dependencies: Any, +) -> dict[str, Any]: + """Run runtime preview or apply while reusing one Dataverse token.""" + phases = state.get("phases") or {} + if (phases.get("connections") or {}).get("status") != "complete": + raise WorkdayConnectRuntimeError( + "Complete the Workday connection sign-ins before runtime wiring." + ) + scope = state.get("scope") or {} + operators = state.get("operators") or {} + environment_url = _required_text( + scope, "dataverseUrl", "Dataverse environment URL" + ).rstrip("/") + maker = _required_text( + operators.get("powerPlatformMaker") or {}, + "username", + "Power Platform maker account", + ) + try: + token = token_provider( + environment_url, + preferred_username=maker, + ) + except SystemExit as exc: + raise WorkdayConnectRuntimeError( + "Dataverse authentication did not complete." + ) from exc + except (OSError, RuntimeError, ValueError) as exc: + raise WorkdayConnectRuntimeError( + f"Dataverse authentication failed: {exc}" + ) from exc + try: + identity = identity_provider( + token, + preferred_username=maker, + ) + except WorkdayConnectIdentityError as exc: + raise WorkdayConnectRuntimeError(str(exc)) from exc + try: + discovery = discover_runtime_plan( + state, + workday_connection_id=workday_connection_id, + dataverse_connection_id=dataverse_connection_id, + token=token, + token_provider=token_provider, + query=query, + **discovery_dependencies, + ) + except WorkdayConnectRuntimeError: + raise + except (OSError, RuntimeError, ValueError, SystemExit) as exc: + raise WorkdayConnectRuntimeError( + f"Runtime target discovery failed: {exc}" + ) from exc + if not apply: + return {**discovery, "authenticatedAccount": identity["username"]} + if not approved_hash or verifier is None: + raise WorkdayConnectRuntimeError( + "Runtime apply requires an approved plan hash." + ) + verifier(discovery["plan"], approved_hash) + try: + applied = apply_runtime_plan( + discovery["plan"], + token=token, + query=query, + updater=updater, + authorization_runner=authorization_runner, + stage_recorder=stage_recorder, + ) + except WorkdayConnectRuntimeError: + raise + except (OSError, RuntimeError, ValueError, SystemExit) as exc: + raise WorkdayConnectRuntimeError( + f"Runtime apply failed: {exc}" + ) from exc + return { + "observedBeforeApply": discovery["observed"], + "applied": applied, + "authenticatedAccount": identity["username"], + } + + +def _run_authorization( + plan: Mapping[str, Any], + *, + runner: Callable[..., subprocess.CompletedProcess], +) -> None: + shell = shutil.which("pwsh") or shutil.which("powershell") + if not shell: + raise WorkdayConnectRuntimeError( + "PowerShell is required for delegated flow authorization." + ) + authorization = plan["delegatedAuthorization"] + for workflow_id in authorization["workflowIds"]: + try: + result = runner( + [ + shell, + "-NoProfile", + "-File", + str(Path(__file__).parent / authorization["script"]), + "-OrgUrl", + plan["scope"]["dataverseUrl"], + "-BotId", + authorization["botId"], + "-PreferredUsername", + plan["scope"]["makerUsername"], + "-WorkflowId", + workflow_id, + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=600, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise WorkdayConnectRuntimeError( + "Delegated flow authorization did not finish within " + f"10 minutes for workflow {workflow_id}." + ) from exc + output = (result.stdout or "") + "\n" + (result.stderr or "") + if ( + result.returncode != 0 + or "[FAIL]" in output + or "Dataverse authorization is in place." not in output + ): + normalized = output.casefold() + if "403" in normalized or "forbidden" in normalized: + evidence = ( + "The authorization script received an explicit forbidden " + "response from Dataverse." + ) + elif "multiple" in normalized: + evidence = ( + "The authorization script found ambiguous existing " + "authorization records." + ) + elif "[fail]" in normalized: + evidence = "The authorization script emitted an explicit [FAIL] result." + else: + evidence = ( + f"The authorization script exited with code " + f"{result.returncode} without its success marker." + ) + raise WorkdayConnectRuntimeError( + "Delegated flow authorization failed for workflow " + f"{workflow_id}. {evidence}" + ) + + +def _require_approved_flow_targets( + flows: Mapping[str, Mapping[str, Any]], + targets: list[Mapping[str, Any]], +) -> None: + changed = [ + str(target["name"]) + for target in targets + if str(flows[str(target["name"])].get("workflowid") or "").casefold() + != str(target["workflowId"]).casefold() + ] + if changed: + raise WorkdayConnectRuntimeError( + "Reviewed Workday flow identity changed after approval: " + + ", ".join(sorted(changed)) + ) + + +def _record_runtime_stage( + stages: list[str], + action: str, + evidence: Mapping[str, Any], + recorder: Callable[[str, Mapping[str, Any]], Any] | None, +) -> None: + if recorder is not None: + recorder(action, evidence) + stages.append(action) + + +def _apply_connection_binding_stage( + plan: Mapping[str, Any], + *, + token: str, + query: Callable[..., list[dict[str, Any]]], + updater: Callable[..., bool], +) -> dict[str, str]: + environment_url = plan["scope"]["dataverseUrl"] + targets = plan["connectionBindings"] + bindings = { + logical_name: target["connectionId"] for logical_name, target in targets.items() + } + references = _runtime_references( + environment_url, + token, + list(bindings), + query=query, + ) + for logical_name, target_id in bindings.items(): + if ( + str(references[logical_name].get("connectionid") or "").casefold() + == str(target_id).casefold() + ): + continue + updater( + environment_url, + token, + "connectionreferences", + _required_text( + references[logical_name], + "connectionreferenceid", + f"Connection reference ID for {logical_name}", + ), + {"connectionid": target_id}, + ) + verified = _runtime_references( + environment_url, + token, + list(bindings), + query=query, + ) + wrong = [ + name + for name, target_id in bindings.items() + if str(verified[name].get("connectionid") or "").casefold() + != str(target_id).casefold() + ] + if wrong: + raise WorkdayConnectRuntimeError( + "Connection-reference verification failed: " + ", ".join(sorted(wrong)) + ) + return { + logical_name: target["displayName"] for logical_name, target in targets.items() + } + + +def _apply_flow_activation_stage( + plan: Mapping[str, Any], + *, + token: str, + query: Callable[..., list[dict[str, Any]]], + updater: Callable[..., bool], +) -> list[str]: + environment_url = plan["scope"]["dataverseUrl"] + targets = plan["flows"] + flow_names = [value["name"] for value in targets] + approved_ids = {value["workflowId"].casefold() for value in targets} + flows = _runtime_flows( + environment_url, + token, + flow_names, + approved_ids, + query=query, + ) + _require_approved_flow_targets(flows, targets) + for target in targets: + flow = flows[target["name"]] + if ( + flow.get("statecode") == ACTIVE_FLOW_STATE + and flow.get("statuscode") == ACTIVE_FLOW_STATUS + ): + continue + updater( + environment_url, + token, + "workflows", + target["workflowId"], + { + "statecode": ACTIVE_FLOW_STATE, + "statuscode": ACTIVE_FLOW_STATUS, + }, + ) + verified = _runtime_flows( + environment_url, + token, + flow_names, + approved_ids, + query=query, + ) + _require_approved_flow_targets(verified, targets) + inactive = [ + name + for name, row in verified.items() + if row.get("statecode") != ACTIVE_FLOW_STATE + or row.get("statuscode") != ACTIVE_FLOW_STATUS + ] + if inactive: + raise WorkdayConnectRuntimeError( + "Runtime flow verification failed: " + ", ".join(sorted(inactive)) + ) + return flow_names + + +def apply_runtime_plan( + plan: Mapping[str, Any], + *, + token: str, + query: Callable[..., list[dict[str, Any]]] = query_all, + updater: Callable[..., bool] = update_record, + authorization_runner: Callable[..., subprocess.CompletedProcess] = _default_runner, + stage_recorder: Callable[[str, Mapping[str, Any]], Any] | None = None, +) -> dict[str, Any]: + """Apply and verify ordered idempotent stages with one Dataverse token.""" + verified_stages: list[str] = [] + connection_bindings = _apply_connection_binding_stage( + plan, + token=token, + query=query, + updater=updater, + ) + _record_runtime_stage( + verified_stages, + "connection-references-bound", + {"outcome": "verified", "provenance": "Dataverse reread"}, + stage_recorder, + ) + + flow_names = _apply_flow_activation_stage( + plan, + token=token, + query=query, + updater=updater, + ) + _record_runtime_stage( + verified_stages, + "runtime-flows-active", + {"outcome": "verified", "provenance": "Dataverse reread"}, + stage_recorder, + ) + + _run_authorization(plan, runner=authorization_runner) + _record_runtime_stage( + verified_stages, + "delegated-authorization-configured", + {"outcome": "verified", "provenance": "authorization script"}, + stage_recorder, + ) + + return { + "verified": True, + "verifiedStages": verified_stages, + "connectionBindings": connection_bindings, + "flows": flow_names, + "delegatedAuthorization": "verified-by-script", + } diff --git a/solutions/ess-maker-skills/scripts/workday_connect_store.py b/solutions/ess-maker-skills/scripts/workday_connect_store.py new file mode 100644 index 000000000..a381eadd3 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_connect_store.py @@ -0,0 +1,916 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Atomic persisted-state operations for the Workday connect lifecycle.""" + +from __future__ import annotations + +from contextlib import contextmanager +import copy +import json +import os +from pathlib import Path +import shutil +import tempfile +import time +from typing import Any, Iterator, Mapping + +from workday_connect_model import ( + LEGACY_PHASE_ROWS, + PHASE_BY_ID, + PHASE_DEFINITIONS, + PHASE_REQUIRED_ACTIONS, + STATE_SCHEMA_VERSION, + TENANT_FOUNDATION_REQUIRED_ENDPOINT_KEYS, + TENANT_FOUNDATION_REQUIRED_IDENTIFIER_KEYS, + PhaseStatus, + WorkdayConnectModelError, + default_state, + next_phase_summary, + plan_hash, + progress_text, + utc_now, + validate_state, + workday_saml_entity_id, +) + + +CONFIG_PATH = Path(".local/connect/workday-da/config.json") + +_PREFLIGHT_SCOPE_KEYS = { + "agent", + "architecture", + "dataverseUrl", + "environmentId", + "packageFlavor", + "ring", + "vertical", +} +_ENTRA_SCOPE_KEYS = {"entraTenantId", "workdayTenant"} +_ENTRA_IDENTIFIER_KEYS = { + "entraAppId", + "entraAppObjectId", + "entraServicePrincipalId", + "entraAppIdUri", + "workdaySamlEntityId", + "scopeGuid", + "signingCertificate", +} +_WORKDAY_IDENTIFIER_KEYS = {"oauthClientId"} +_FOUNDATION_SCOPE_KEYS = ("entraTenantId", "workdayTenant") +_FOUNDATION_ENTRA_IDENTIFIER_KEYS = ( + "entraAppId", + "entraAppObjectId", + "entraServicePrincipalId", + "entraAppIdUri", + "workdaySamlEntityId", + "scopeGuid", + "signingCertificate", +) +_OPERATOR_PHASES = { + "powerPlatformMaker": "preflight", + "entraAdmin": "entra", + "workdayAdmin": "workday-admin", +} + + +class WorkdayConnectStoreError(RuntimeError): + """Raised when Workday connect state cannot be persisted safely.""" + + +class WorkdayConnectPlanChangedError(WorkdayConnectStoreError): + """Raised when an approved plan no longer matches the current plan.""" + + +def _read_json(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise WorkdayConnectStoreError( + f"Workday connect state could not be read: {path}: {exc}" + ) from exc + if not isinstance(document, dict): + raise WorkdayConnectStoreError( + f"Workday connect state must contain an object: {path}" + ) + return document + + +def _atomic_write_json(path: Path, document: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", + suffix=".tmp", + dir=path.parent, + ) + temporary_path = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream: + json.dump(document, stream, indent=2, sort_keys=True) + stream.write("\n") + stream.flush() + try: + os.fsync(stream.fileno()) + except OSError: + pass + os.replace(temporary_path, path) + finally: + temporary_path.unlink(missing_ok=True) + + +@contextmanager +def _file_lock(path: Path, timeout: float) -> Iterator[None]: + path.parent.mkdir(parents=True, exist_ok=True) + stream = path.open("a+b") + stream.seek(0, os.SEEK_END) + if stream.tell() == 0: + stream.write(b"\0") + stream.flush() + deadline = time.monotonic() + timeout + locked = False + try: + while not locked: + try: + stream.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + locked = True + except (OSError, BlockingIOError): + if time.monotonic() >= deadline: + raise WorkdayConnectStoreError( + "Another /connect workday session is updating state. " + "Wait for it to finish, then retry." + ) + time.sleep(0.05) + yield + finally: + if locked: + stream.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(stream.fileno(), fcntl.LOCK_UN) + stream.close() + + +def _legacy_phase_status( + setup_status: Mapping[str, Any], + rows: tuple[str, ...], +) -> str: + values = [ + setup_status.get(row) for row in rows if isinstance(setup_status.get(row), dict) + ] + statuses = {str(value.get("state") or "pending") for value in values} + if values and len(values) == len(rows) and statuses == {"done"}: + return PhaseStatus.COMPLETE.value + if "blocked" in statuses: + return PhaseStatus.BLOCKED.value + if statuses & {"done", "in-progress"}: + return PhaseStatus.ACTIVE.value + return PhaseStatus.PENDING.value + + +def _legacy_evidence( + setup_status: Mapping[str, Any], + rows: tuple[str, ...], +) -> list[dict[str, Any]]: + evidence = [] + for row in rows: + value = setup_status.get(row) + if not isinstance(value, dict) or value.get("state") != "done": + continue + record = { + "source": "legacy-state", + "action": f"legacy:{row}", + "verifiedBy": value.get("verifiedBy"), + } + legacy_evidence = value.get("evidence") + if isinstance(legacy_evidence, dict): + for key in ("outcome", "provenance", "capturedAt"): + if legacy_evidence.get(key) is not None: + record[key] = legacy_evidence[key] + evidence.append(record) + return evidence + + +def migrate_legacy_state(document: Mapping[str, Any]) -> dict[str, Any]: + state = default_state() + setup_status = document.get("setupStatus") + if not isinstance(setup_status, dict): + setup_status = {} + + scope = state["scope"] + if document.get("sidecarDataverseEndpoint"): + scope["dataverseUrl"] = document["sidecarDataverseEndpoint"] + if document.get("tenantId"): + scope["entraTenantId"] = document["tenantId"] + if document.get("tenant"): + scope["workdayTenant"] = document["tenant"] + if document.get("vertical"): + scope["vertical"] = document["vertical"] + if document.get("packageFlavor"): + scope["packageFlavor"] = document["packageFlavor"] + active_agent = document.get("activeAgent") + if isinstance(active_agent, dict): + scope["agent"] = { + key: active_agent[key] + for key in ("slug", "botId", "schemaName") + if active_agent.get(key) + } + + identifiers = state["identifiers"] + field_map = { + "entraAppId": "entraAppId", + "entraAppObjectId": "entraAppObjectId", + "scopeGuid": "scopeGuid", + "oauthClientId": "oauthClientId", + "entraSSO": "entraSSO", + } + for legacy, current in field_map.items(): + if document.get(legacy) is not None: + identifiers[current] = document[legacy] + app_id_uri = document.get("entraAppIdUri") or document.get("appIdUri") + if app_id_uri: + identifiers["entraAppIdUri"] = app_id_uri + if scope.get("workdayTenant"): + try: + identifiers["workdaySamlEntityId"] = workday_saml_entity_id( + scope["workdayTenant"] + ) + except WorkdayConnectModelError: + pass + + endpoints = state["endpoints"] + endpoint_map = { + "baseUrl": "workdayBaseUrl", + "tokenHost": "tokenHost", + "oauthTokenUrl": "oauthTokenUrl", + "tokenEndpoint": "oauthTokenUrl", + "restBaseUrl": "restBaseUrl", + "soapBaseUrl": "soapBaseUrl", + "domainName": "domainName", + } + for legacy, current in endpoint_map.items(): + if document.get(legacy) and current not in endpoints: + endpoints[current] = document[legacy] + + operator_map = { + "entraAdminUsername": ("entraAdmin", "username"), + "entraAdminAccount": ("entraAdmin", "username"), + "makerUsername": ("powerPlatformMaker", "username"), + } + for legacy, (operator, field) in operator_map.items(): + if document.get(legacy): + state["operators"].setdefault(operator, {})[field] = document[legacy] + + for phase, rows in LEGACY_PHASE_ROWS.items(): + phase_state = state["phases"][phase.value] + phase_state["status"] = _legacy_phase_status(setup_status, rows) + phase_state["completedActions"] = [ + f"legacy:{row}" + for row in rows + if isinstance(setup_status.get(row), dict) + and setup_status[row].get("state") == "done" + ] + phase_state["evidence"] = _legacy_evidence(setup_status, rows) + if phase_state["status"] == PhaseStatus.COMPLETE.value: + for action in PHASE_REQUIRED_ACTIONS[phase.value]: + if phase.value == "runtime" and action == "workday-topics-activated": + continue + if action not in phase_state["completedActions"]: + phase_state["completedActions"].append(action) + phase_state["evidence"].append( + { + "source": "legacy-state", + "action": action, + "outcome": "verified", + "capturedAt": utc_now(), + } + ) + if phase_state["status"] != PhaseStatus.PENDING.value: + phase_state["updatedAt"] = utc_now() + + runtime = state["phases"]["runtime"] + if ( + runtime["status"] == PhaseStatus.COMPLETE.value + and "workday-topics-activated" not in runtime["completedActions"] + ): + runtime["status"] = PhaseStatus.ACTIVE.value + runtime["updatedAt"] = utc_now() + _reset_phase(state["phases"]["employee-validation"]) + + if all( + phase["status"] == PhaseStatus.COMPLETE.value + for phase in state["phases"].values() + ): + state["status"] = "ready" + state["tenantFoundation"] = _tenant_foundation_from_state(state) + state["migration"] = { + "source": ( + "workday-da-state-v1" + if document.get("stateSchemaVersion") + else "legacy-workday-da-config" + ), + "migratedAt": utc_now(), + } + state["updatedAt"] = utc_now() + return validate_state(state) + + +def _reset_phase(phase: dict[str, Any]) -> None: + phase.update( + { + "status": PhaseStatus.PENDING.value, + "completedActions": [], + "approvedPlanHash": None, + "approvedPlan": None, + "evidence": [], + "blocker": None, + "updatedAt": utc_now(), + } + ) + + +def _tenant_foundation_from_state( + state: Mapping[str, Any], +) -> dict[str, Any] | None: + phases = state.get("phases") or {} + entra = phases.get("entra") or {} + workday = phases.get("workday-admin") or {} + if ( + entra.get("status") != PhaseStatus.COMPLETE.value + or workday.get("status") != PhaseStatus.COMPLETE.value + ): + return None + scope = state.get("scope") or {} + if any(not str(scope.get(key) or "").strip() for key in _FOUNDATION_SCOPE_KEYS): + return None + identifiers = state.get("identifiers") or {} + endpoints = state.get("endpoints") or {} + if any( + identifiers.get(key) is None or identifiers.get(key) == "" + for key in TENANT_FOUNDATION_REQUIRED_IDENTIFIER_KEYS + ): + return None + if any( + not str(endpoints.get(key) or "").strip() + for key in TENANT_FOUNDATION_REQUIRED_ENDPOINT_KEYS + ): + return None + return { + "scope": {key: copy.deepcopy(scope[key]) for key in _FOUNDATION_SCOPE_KEYS}, + "identifiers": copy.deepcopy(dict(identifiers)), + "endpoints": copy.deepcopy(dict(endpoints)), + "phases": { + phase_id: { + "completedActions": copy.deepcopy(phases[phase_id]["completedActions"]), + "evidence": copy.deepcopy(phases[phase_id]["evidence"]), + } + for phase_id in ("entra", "workday-admin") + }, + "capturedAt": utc_now(), + } + + +def _normalized_foundation_value(value: Any) -> Any: + if isinstance(value, str): + return value.strip().casefold() + if isinstance(value, Mapping): + return { + str(key): _normalized_foundation_value(item) + for key, item in sorted(value.items()) + } + if isinstance(value, list): + return [_normalized_foundation_value(item) for item in value] + return value + + +def _certificate_identity(value: Any) -> tuple[str, str, str] | None: + if not isinstance(value, Mapping): + return None + thumbprint = str(value.get("thumbprint") or "").replace(" ", "").strip().casefold() + valid_from = str(value.get("validFrom") or "").strip()[:10] + valid_to = str(value.get("validTo") or "").strip()[:10] + if not thumbprint or not valid_from or not valid_to: + return None + return thumbprint, valid_from, valid_to + + +def _foundation_matches_current_entra( + state: Mapping[str, Any], + foundation: Mapping[str, Any], +) -> bool: + scope = state.get("scope") or {} + foundation_scope = foundation.get("scope") or {} + for key in _FOUNDATION_SCOPE_KEYS: + if _normalized_foundation_value(scope.get(key)) != ( + _normalized_foundation_value(foundation_scope.get(key)) + ): + return False + identifiers = state.get("identifiers") or {} + foundation_identifiers = foundation.get("identifiers") or {} + for key in _FOUNDATION_ENTRA_IDENTIFIER_KEYS: + if key == "signingCertificate": + if _certificate_identity(identifiers.get(key)) != ( + _certificate_identity(foundation_identifiers.get(key)) + ): + return False + continue + if _normalized_foundation_value(identifiers.get(key)) != ( + _normalized_foundation_value(foundation_identifiers.get(key)) + ): + return False + return True + + +def _invalidate_from_phase( + state: dict[str, Any], + phase_id: str, +) -> None: + invalidate = False + for definition in PHASE_DEFINITIONS: + if definition.identifier.value == phase_id: + invalidate = True + if invalidate: + _reset_phase(state["phases"][definition.identifier.value]) + + +def _invalidate_after_phase( + state: dict[str, Any], + phase_id: str, +) -> None: + matched = False + for definition in PHASE_DEFINITIONS: + if matched: + _reset_phase(state["phases"][definition.identifier.value]) + if definition.identifier.value == phase_id: + matched = True + + +def _upgrade_or_migrate_state( + document: Mapping[str, Any], +) -> dict[str, Any]: + source_version = document.get("schemaVersion") + if source_version == 4: + return upgrade_v4_state(document) + if source_version == 3: + return upgrade_v3_state(document) + if source_version == 2: + return upgrade_v2_state(document) + if "schemaVersion" in document: + raise WorkdayConnectStoreError( + "Unsupported Workday connect state schema version: " + f"{source_version!r}. Use the kit version that created this state " + "or restore a compatible backup." + ) + return migrate_legacy_state(document) + + +def _scope_invalidation_phase(changed_keys: set[str]) -> str: + if changed_keys & _PREFLIGHT_SCOPE_KEYS: + return "preflight" + if changed_keys and changed_keys <= _ENTRA_SCOPE_KEYS: + return "entra" + return "preflight" + + +def _section_invalidation_phase( + section: str, + changed_keys: set[str], +) -> str | None: + if not changed_keys: + return None + if section == "scope": + return _scope_invalidation_phase(changed_keys) + if section == "identifiers": + if changed_keys & _ENTRA_IDENTIFIER_KEYS: + return "entra" + if changed_keys <= _WORKDAY_IDENTIFIER_KEYS: + return "workday-admin" + return "entra" + if section == "endpoints": + return "workday-admin" + if section == "operators": + phases = {_OPERATOR_PHASES.get(key, "preflight") for key in changed_keys} + return min( + phases, + key=lambda phase_id: next( + index + for index, definition in enumerate(PHASE_DEFINITIONS) + if definition.identifier.value == phase_id + ), + ) + return None + + +def _upgrade_structured_state( + document: Mapping[str, Any], + *, + source_version: int, +) -> dict[str, Any]: + state = copy.deepcopy(dict(document)) + state["schemaVersion"] = STATE_SCHEMA_VERSION + if "tenantFoundation" not in state: + state["tenantFoundation"] = _tenant_foundation_from_state(state) + first_incomplete: str | None = None + for definition in PHASE_DEFINITIONS: + phase_id = definition.identifier.value + phase = state["phases"][phase_id] + phase.pop("scopeHash", None) + phase.pop("manualHandoff", None) + if phase["status"] == "waiting": + phase["status"] = PhaseStatus.ACTIVE.value + if first_incomplete is not None: + if phase["status"] == PhaseStatus.COMPLETE.value: + _reset_phase(phase) + continue + if phase["status"] != PhaseStatus.COMPLETE.value: + first_incomplete = phase_id + continue + required = PHASE_REQUIRED_ACTIONS[phase_id] + completed = set(phase.get("completedActions") or []) + evidence_actions = { + str(record.get("action") or "") + for record in (phase.get("evidence") or []) + if isinstance(record, dict) + } + if not required <= completed or not required <= evidence_actions: + phase["status"] = PhaseStatus.ACTIVE.value + phase["updatedAt"] = utc_now() + first_incomplete = phase_id + state["status"] = ( + "ready" + if all( + phase["status"] == PhaseStatus.COMPLETE.value + for phase in state["phases"].values() + ) + else "in-progress" + ) + state["migration"] = { + "source": f"workday-connect-state-v{source_version}", + "migratedAt": utc_now(), + } + state["updatedAt"] = utc_now() + return validate_state(state) + + +def upgrade_v2_state(document: Mapping[str, Any]) -> dict[str, Any]: + return _upgrade_structured_state(document, source_version=2) + + +def upgrade_v3_state(document: Mapping[str, Any]) -> dict[str, Any]: + return _upgrade_structured_state(document, source_version=3) + + +def upgrade_v4_state(document: Mapping[str, Any]) -> dict[str, Any]: + return _upgrade_structured_state(document, source_version=4) + + +class WorkdayConnectStore: + """Own the single durable Workday connect state file.""" + + def __init__( + self, + workspace_root: Path, + *, + lock_timeout: float = 5.0, + ) -> None: + self.workspace_root = workspace_root.resolve() + self.config_path = self.workspace_root / CONFIG_PATH + self.lock_path = self.config_path.with_name("state.lock") + self.backup_path = self.config_path.with_name("config.pre-v5.json") + self.lock_timeout = lock_timeout + + def initialize(self) -> dict[str, Any]: + with _file_lock(self.lock_path, self.lock_timeout): + existing = _read_json(self.config_path) + if not existing: + state = default_state() + _atomic_write_json(self.config_path, state) + return state + if existing.get("schemaVersion") == STATE_SCHEMA_VERSION: + return validate_state(existing) + if not self.backup_path.exists(): + self.backup_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(self.config_path, self.backup_path) + state = _upgrade_or_migrate_state(existing) + _atomic_write_json(self.config_path, state) + return state + + def load(self) -> dict[str, Any]: + if not self.config_path.exists(): + return self.initialize() + current = _read_json(self.config_path) + if current.get("schemaVersion") != STATE_SCHEMA_VERSION: + return self.initialize() + return validate_state(current) + + def _mutate(self, mutation) -> dict[str, Any]: + with _file_lock(self.lock_path, self.lock_timeout): + current = _read_json(self.config_path) + if not current: + current = default_state() + elif current.get("schemaVersion") != STATE_SCHEMA_VERSION: + if not self.backup_path.exists(): + shutil.copy2(self.config_path, self.backup_path) + current = _upgrade_or_migrate_state(current) + state = copy.deepcopy(validate_state(current)) + mutation(state) + state["status"] = ( + "ready" + if all( + phase["status"] == PhaseStatus.COMPLETE.value + for phase in state["phases"].values() + ) + else "in-progress" + ) + state["updatedAt"] = utc_now() + validate_state(state) + _atomic_write_json(self.config_path, state) + return state + + def merge_section( + self, + section: str, + values: Mapping[str, Any], + ) -> dict[str, Any]: + if section not in {"scope", "identifiers", "endpoints", "operators"}: + raise WorkdayConnectStoreError( + f"Unsupported Workday state section: {section}." + ) + if not isinstance(values, Mapping): + raise WorkdayConnectStoreError( + f"Workday state section '{section}' must be an object." + ) + + def mutation(state: dict[str, Any]) -> None: + changed_keys = { + key for key, value in values.items() if state[section].get(key) != value + } + invalidation_phase = _section_invalidation_phase( + section, + changed_keys, + ) + if invalidation_phase: + _invalidate_from_phase(state, invalidation_phase) + state[section].update(dict(values)) + + return self._mutate(mutation) + + def capture_tenant_foundation(self) -> dict[str, Any]: + """Persist reusable Entra and Workday tenant configuration evidence.""" + + def mutation(state: dict[str, Any]) -> None: + foundation = _tenant_foundation_from_state(state) + if foundation is None: + raise WorkdayConnectStoreError( + "Complete Entra and Workday administrator verification " + "before capturing reusable tenant configuration." + ) + state["tenantFoundation"] = foundation + + return self._mutate(mutation) + + def restore_workday_foundation(self) -> tuple[dict[str, Any], bool]: + """Reuse Workday administrator evidence after a fresh Entra reread.""" + reused = False + + def mutation(state: dict[str, Any]) -> None: + nonlocal reused + foundation = state.get("tenantFoundation") + if not isinstance(foundation, Mapping): + return + if ( + state["phases"]["preflight"]["status"] != PhaseStatus.COMPLETE.value + or state["phases"]["entra"]["status"] != PhaseStatus.COMPLETE.value + ): + return + if not _foundation_matches_current_entra(state, foundation): + return + foundation_identifiers = foundation.get("identifiers") or {} + for key in _WORKDAY_IDENTIFIER_KEYS: + if foundation_identifiers.get(key) is not None: + state["identifiers"][key] = copy.deepcopy( + foundation_identifiers[key] + ) + state["endpoints"].update( + copy.deepcopy(dict(foundation.get("endpoints") or {})) + ) + snapshot = (foundation.get("phases") or {}).get("workday-admin") + if not isinstance(snapshot, Mapping): + return + phase = state["phases"]["workday-admin"] + _reset_phase(phase) + phase["status"] = PhaseStatus.COMPLETE.value + phase["completedActions"] = copy.deepcopy( + list(snapshot.get("completedActions") or []) + ) + phase["evidence"] = copy.deepcopy(list(snapshot.get("evidence") or [])) + phase["evidence"].append( + { + "action": "tenant-foundation-reused", + "outcome": "verified", + "provenance": "stored-tenant-foundation", + "foundationCapturedAt": foundation["capturedAt"], + "capturedAt": utc_now(), + } + ) + phase["updatedAt"] = utc_now() + reused = True + + state = self._mutate(mutation) + return state, reused + + def set_phase_status( + self, + phase_id: str, + status: str, + *, + blocker: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: + if phase_id not in PHASE_BY_ID: + raise WorkdayConnectStoreError(f"Unknown Workday phase: {phase_id}.") + if status not in {value.value for value in PhaseStatus}: + raise WorkdayConnectStoreError(f"Unknown Workday phase status: {status}.") + + def mutation(state: dict[str, Any]) -> None: + definition = PHASE_BY_ID[phase_id] + prerequisite = definition.prerequisite + if status == PhaseStatus.COMPLETE.value and prerequisite: + prerequisite_status = state["phases"][prerequisite.value]["status"] + if prerequisite_status != PhaseStatus.COMPLETE.value: + raise WorkdayConnectStoreError( + f"Complete '{prerequisite.value}' before '{phase_id}'." + ) + phase = state["phases"][phase_id] + if ( + phase["status"] == PhaseStatus.COMPLETE.value + and status != PhaseStatus.COMPLETE.value + ): + _invalidate_after_phase(state, phase_id) + if status == PhaseStatus.COMPLETE.value: + required = PHASE_REQUIRED_ACTIONS[phase_id] + completed = set(phase["completedActions"]) + evidence_actions = { + str(record.get("action") or "") for record in phase["evidence"] + } + missing = sorted((required - completed) | (required - evidence_actions)) + if missing: + raise WorkdayConnectStoreError( + f"Phase '{phase_id}' is missing required verified " + "actions: " + ", ".join(missing) + "." + ) + phase["status"] = status + phase["blocker"] = dict(blocker) if blocker else None + phase["updatedAt"] = utc_now() + + return self._mutate(mutation) + + def complete_action( + self, + phase_id: str, + action: str, + *, + evidence: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: + if phase_id not in PHASE_BY_ID: + raise WorkdayConnectStoreError(f"Unknown Workday phase: {phase_id}.") + if not action or not isinstance(action, str): + raise WorkdayConnectStoreError( + "Workday completed action must be a non-empty string." + ) + + def mutation(state: dict[str, Any]) -> None: + prerequisite = PHASE_BY_ID[phase_id].prerequisite + if ( + prerequisite is not None + and state["phases"][prerequisite.value]["status"] + != PhaseStatus.COMPLETE.value + ): + raise WorkdayConnectStoreError( + f"Complete '{prerequisite.value}' before recording " + f"'{phase_id}' evidence." + ) + phase = state["phases"][phase_id] + if action not in phase["completedActions"]: + phase["completedActions"].append(action) + if evidence is not None: + phase["evidence"] = [ + record + for record in phase["evidence"] + if record.get("action") != action + ] + phase["evidence"].append( + { + **dict(evidence), + "action": action, + "capturedAt": utc_now(), + } + ) + if phase["status"] in { + PhaseStatus.PENDING.value, + PhaseStatus.BLOCKED.value, + }: + phase["status"] = PhaseStatus.ACTIVE.value + phase["blocker"] = None + phase["updatedAt"] = utc_now() + + return self._mutate(mutation) + + def approve_plan( + self, + phase_id: str, + plan: Mapping[str, Any], + ) -> tuple[dict[str, Any], str]: + if phase_id not in {"preflight", "runtime"}: + raise WorkdayConnectStoreError( + "Exact apply-plan approval is supported only for preflight " + "installation and controller-owned runtime changes." + ) + if plan.get("phase") != phase_id: + raise WorkdayConnectStoreError( + "Workday plan phase does not match the requested phase." + ) + approved_hash = plan_hash(plan) + + def mutation(state: dict[str, Any]) -> None: + prerequisite = PHASE_BY_ID[phase_id].prerequisite + if ( + prerequisite is not None + and state["phases"][prerequisite.value]["status"] + != PhaseStatus.COMPLETE.value + ): + raise WorkdayConnectStoreError( + f"Complete '{prerequisite.value}' before approving '{phase_id}'." + ) + phase = state["phases"][phase_id] + if phase["status"] == PhaseStatus.COMPLETE.value: + _invalidate_after_phase(state, phase_id) + phase["approvedPlan"] = dict(plan) + phase["approvedPlanHash"] = approved_hash + phase["status"] = PhaseStatus.ACTIVE.value + phase["blocker"] = None + phase["updatedAt"] = utc_now() + + return self._mutate(mutation), approved_hash + + def verify_plan( + self, + phase_id: str, + current_plan: Mapping[str, Any], + approved_hash: str, + ) -> str: + state = self.load() + phase = state["phases"].get(phase_id) + if phase is None: + raise WorkdayConnectStoreError(f"Unknown Workday phase: {phase_id}.") + current_hash = plan_hash(current_plan) + stored_hash = phase.get("approvedPlanHash") + if current_hash != approved_hash or stored_hash != approved_hash: + raise WorkdayConnectPlanChangedError( + "The Workday change plan or target changed after approval. " + "Review and approve the current plan before applying it." + ) + return current_hash + + def status(self) -> dict[str, Any]: + state = self.load() + phases = [] + next_phase = None + for definition in PHASE_DEFINITIONS: + phase = state["phases"][definition.identifier.value] + phases.append( + { + "id": definition.identifier.value, + "title": definition.title, + "status": phase["status"], + } + ) + if next_phase is None and phase["status"] != PhaseStatus.COMPLETE.value: + next_phase = definition.identifier.value + return { + "schemaVersion": 1, + "provider": "workday", + "status": state["status"], + "phases": phases, + "nextPhaseId": next_phase, + "nextPhaseSummary": next_phase_summary(state), + "progressText": progress_text(state), + "blocker": ( + state["phases"][next_phase]["blocker"] + if next_phase is not None + else None + ), + } diff --git a/solutions/ess-maker-skills/src/skills/connect/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/SKILL.md index df8406c24..8ede07a5d 100644 --- a/solutions/ess-maker-skills/src/skills/connect/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/connect/SKILL.md @@ -51,16 +51,17 @@ Workday routes by architecture before package detection: `src/skills/connect/workday/contract.json`. - **CEA full/legacy package or no package** — stop at the current unsupported installation boundary without changing state. - - **DA HR agent** — use `src/skills/setup/workday-da/SKILL.md` for the - resumable package, Entra, tenant, Power Platform, and runtime checklist. - - **DA IT or another DA agent** — unsupported for Workday in this release; - stop before creating state or entering a Workday lifecycle. + - **Native DA HR agent** — use `src/skills/setup/workday-da/SKILL.md` for the + resumable six-phase controller lifecycle. + - **Classic DA HR, DA IT, or another DA agent** — unsupported for this + Workday lifecycle; stop before creating state. CEA per-agent lifecycle state is stored at `.local/connect/workday/agents/{agent-slug}/lifecycle.json`. DA Workday state - is stored in `.local/connect/workday-da/config.json` and - `.local/setup/workday-da/tasks.md`. + is stored only in `.local/connect/workday-da/config.json`. -Each integration's steps.md and config.json persist after completion. -Running `/connect` again lets the user add a different integration -without losing existing ones. +Each integration retains only the state artifacts listed above. ServiceNow +uses its `steps.md` and `config.json`; CEA Workday uses per-agent +`lifecycle.json`; native DA Workday uses only its `config.json`. Running +`/connect` again lets the user add a different integration without losing +existing ones. diff --git a/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md index 928130a3d..ad0dc7b03 100644 --- a/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md +++ b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md @@ -57,11 +57,12 @@ reads a contract, runs the checkpoints it names, and renders results. | `mutates` | boolean | no (default `false`) | `true` if completing this phase changes the live agent (edits a file, pushes a change). Drives the role gate below. | | `requiredRole` | string | required when `mutates` is `true` | Human-readable role name passed to `permission-gate.md` as `REQUIRED_ROLE` before the phase's action runs. | | `gateMode` | string | no (default `"attested"`) | `"programmatic"` or `"attested"` — passed to `permission-gate.md` as `GATE_MODE`. Use `"programmatic"` only when `roleQuery` names a real, working query. | -| `roleQuery` | array of strings | required when `gateMode` is `"programmatic"` | The exact command(s) `permission-gate.md` runs as `ROLE_QUERY`, and the role name(s) that count as a pass. Copy an existing, already-proven query rather than inventing a new one (e.g. the Dataverse security-role check `src/skills/setup/workday/install-workday-extension-pack.md` section P5.0 uses for "Environment Maker"). | +| `roleQuery` | array of strings | required when `gateMode` is `"programmatic"` | The exact command(s) `permission-gate.md` runs as `ROLE_QUERY`. Reuse a checked-in, tested provider query rather than inventing an unverified permission check. | | `roleQueryPassNames` | array of strings | required when `gateMode` is `"programmatic"` | Role names in the query's result that count as holding `requiredRole` (include the role itself and any role that supersedes it, e.g. `System Administrator`). | | `actionDoc` | string (path) | required when `mutates` is `true` | Path to a provider-owned markdown fragment containing the bespoke steps needed to make the phase's checkpoint(s) pass (e.g. editing a topic file and pushing it). The runner reads and follows this file; it contains its own Message blocks and is written by the provider, not the runner. | | `rollbackLabel` | string | no | Passed to `scripts/checkpoint.py` before a mutating action runs, so the operator has a named restore point. | -| `rollbackPushGlob` | string | no | Required with `rollbackLabel` when the action pushes a local file. The runner restores only this path from the named checkpoint and uses the same exact `push.py --only` glob when publishing the rollback. | +| `rollbackPushGlob` | string | no | Static path used when the action always pushes the same local file. The runner restores only this path from the named checkpoint and uses the same exact `push.py --only` value when publishing the rollback. | +| `rollbackPushGlobFromAction` | boolean | no | Set to `true` when the action resolves the pushed path dynamically. The action must return `ACTION_ROLLBACK_PUSH_GLOB`; the runner validates and persists it before checkpoint verification. Do not combine this with `rollbackPushGlob`. | ### Evolving a phase's checkpoint list @@ -134,6 +135,8 @@ acknowledgement is complete. Partial or unavailable evidence leaves the phase - `phases.{id}.actionApplied` — mutating phases only; `true` once the action doc has been followed at least once (so a resume doesn't re-apply an idempotent-unsafe action; it re-verifies instead). +- `phases.{id}.rollbackPushGlob` — exact action-resolved local path persisted + when the contract uses `rollbackPushGlobFromAction: true`. - `phases.{id}.checkpointAcknowledgements` — map keyed by checkpoint ID for accepted `Manual`/`Warning` results. Each value records the acknowledged status and UTC `acknowledgedAt`. A resume may reuse the acknowledgement only diff --git a/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md index 913f13e2f..4c23943d0 100644 --- a/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md +++ b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md @@ -176,7 +176,15 @@ its own Message blocks and tool calls and must return an explicit `ACTION_RESULT`: - **`"applied"`** — the mutation was observed to complete successfully. Set - `phases.{id}.actionApplied = true` and write the state file immediately. + `phases.{id}.actionApplied = true`. If the phase has + `rollbackPushGlobFromAction: true`, require the action to return + `ACTION_ROLLBACK_PUSH_GLOB` as one normalized relative path beneath + `topics/`, with no `..` segment and no wildcard characters; persist it as + `phases.{id}.rollbackPushGlob`. If the path is missing or unsafe, set the + phase to `blocked`, write `actionApplied = true` immediately, and stop for + manual attention; the live mutation may already have happened and must not + be repeated without a known exact rollback scope. With a valid path, write + the state file immediately. - **`"cancelled"`** — the user declined before mutation. Keep `actionApplied = false`, leave the phase `in-progress`, write the state file, and stop. Do not run the phase checkpoints. @@ -218,13 +226,16 @@ Aggregate the phase's outcome using the phase's `completionStatuses` - **Any checkpoint `Failed`/`Error`:** set `phases.{id}.status = "blocked"`, record `checkpointResults`. Write the state file. - If this phase has `actionApplied: true`, `rollbackLabel`, and - `rollbackPushGlob`, restore and publish the exact pre-action state: + If this phase has `actionApplied: true` and `rollbackLabel`, resolve + `{ROLLBACK_PUSH_GLOB}` from `phases.{id}.rollbackPushGlob` when + `rollbackPushGlobFromAction: true`; otherwise use the contract's + `rollbackPushGlob`. Stop with manual attention if the required value is + missing. Restore and publish the exact pre-action state: ``` - python scripts/checkpoint.py --revert-reason "{rollbackLabel}" --only "{rollbackPushGlob}" - python scripts/push.py --only "{rollbackPushGlob}" --dry-run - python scripts/push.py --only "{rollbackPushGlob}" --yes + python scripts/checkpoint.py --revert-reason "{rollbackLabel}" --only "{ROLLBACK_PUSH_GLOB}" + python scripts/push.py --only "{ROLLBACK_PUSH_GLOB}" --dry-run + python scripts/push.py --only "{ROLLBACK_PUSH_GLOB}" --yes ``` If all three commands succeed, set `actionApplied = false`, keep the phase diff --git a/solutions/ess-maker-skills/src/skills/connect/step1.md b/solutions/ess-maker-skills/src/skills/connect/step1.md index cf323e978..4d13c005d 100644 --- a/solutions/ess-maker-skills/src/skills/connect/step1.md +++ b/solutions/ess-maker-skills/src/skills/connect/step1.md @@ -11,11 +11,15 @@ Build a list of connected integrations (if any): - **ServiceNow** — connected if `.local/connect/servicenow/steps.md` exists and all items are checked. -- **Workday** — connected only if - `.local/connect/workday/agents/{active-agent-slug}/lifecycle.json` exists, - its `agentSlug` exactly matches the active agent, and every phase is `done`. - Shared provider setup state is not agent connection state and must not make - a sibling or newly selected agent appear connected. +- **Workday** — connected if either: + - `.local/connect/workday/agents/{active-agent-slug}/lifecycle.json` exists, + its `agentSlug` exactly matches the active agent, and every phase is + `done`; or + - `.local/connect/workday-da/config.json` has `schemaVersion: 5`, + `status: "ready"`, and `scope.agent.slug` and `scope.agent.botId` exactly + match the active native agent. + Shared provider state without an exact active-agent match must not make a + sibling or newly selected agent appear connected. --- @@ -269,13 +273,16 @@ Please select the ESS HR Agent or contact your administrator. Stop immediately without creating Workday state or entering a lifecycle. -For `gptagent_copilotforemployeeselfservicehr` or the legacy -`msdyn_copilotforemployeeselfservicedahr` alias, read +For `gptagent_copilotforemployeeselfservicehr`, read `src/skills/setup/workday-da/SKILL.md` and follow it. That setup uses `WD-DA-PKG-001`. Do not create CEA Workday lifecycle state or run `WD-PKG-001`: DA packages share some Workday connection-reference names with CEA, so the CEA package fingerprint is not an architecture discriminator. +For the classic DA HR schema `msdyn_copilotforemployeeselfservicedahr`, explain +that the simplified Workday lifecycle currently supports only the native ESS +HR agent. Stop without creating or changing Workday state. + For a CEA agent, check the currently installed Workday extension before honoring lifecycle state: diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/actions/activate-workday-topics.md b/solutions/ess-maker-skills/src/skills/connect/workday/actions/activate-workday-topics.md new file mode 100644 index 000000000..08633b44f --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/connect/workday/actions/activate-workday-topics.md @@ -0,0 +1,112 @@ +# Action: Activate all Workday topics + +Run this action only after the reviewed Workday flows are connected to the +agent and **Allow permission to share parameters** is enabled. + +Every **Message** block is the exact text to show the user. Copy it verbatim. + +--- + +## B.1 — Resolve the complete Workday topic set + +Read `workspace/agents/{AGENT_SLUG}/.component-map.json`. This map is the +materialized workspace projection of the source `agent.yml`. + +Select every entry that satisfies all of these conditions: + +- `componentKind` is `DialogComponent`; +- `schemaName` starts with `{AGENT_SCHEMA}.topic.Workday`; +- `displayName` starts with `Workday`; +- the mapped path starts with `topics/` and ends with `.mcs.yml`; +- the mapped file exists beneath the selected agent directory. + +Reject unsafe paths, duplicate component IDs, missing schema names, or an empty +result. `push.py --activate` independently enforces the same exact mapped set +and rejects omitted Workday topics or any selected non-Workday dialog; these +instructions are not the only safety boundary. Do not use a handwritten +filename list. For the current reviewed ESS HR template this resolves all 21 +Workday dialog topics from `agent.yml`, including business topics and +supporting system topics. + +Sort the mapped paths and build `{WORKDAY_TOPIC_ARGS}` as one exact +`--only "{path}"` argument per selected topic. + +--- + +## B.2 — Preview activation + +Run from the solution root containing `.local/config.json`: + +```powershell +python scripts/push.py {WORKDAY_TOPIC_ARGS} --activate --dry-run --preferred-username "{POWER_PLATFORM_MAKER}" +``` + +The preview count must equal the selected component-map count. Every previewed +component must be one of the resolved Workday dialog topics. Stop if the count +differs, any non-Workday topic appears, or the command reports pending local +content changes. Content changes require their own scoped review and push; +activation approval never approves topic-content edits. + +**Message:** + +The Workday connections and shared parameters are ready. I found +**{WORKDAY_TOPIC_COUNT}** Workday topics included with this agent. I can now +enable all of them without changing their configured behavior. + +**End message.** + +Use the `vscode_askQuestions` tool: + +```json +[ + { + "header": "Enable Workday topics", + "question": "Enable all Workday topics in the active ESS HR agent?", + "options": [ + { "label": "Enable" }, + { "label": "Not now" } + ], + "allowFreeformInput": false + } +] +``` + +Leave the selection unset. Enabling topics is an explicit mutation approval, +not a recommended answer. + +If the user selects **Not now**, set `ACTION_RESULT = "cancelled"` and leave +the runtime phase active. + +--- + +## B.3 — Activate and verify + +If the user selects **Enable**, run: + +```powershell +python scripts/push.py {WORKDAY_TOPIC_ARGS} --activate --yes --preferred-username "{POWER_PLATFORM_MAKER}" +``` + +`push.py` sends a full `BotComponentUpdate` for each selected topic through the +native MinimalBot components endpoint, preserves the dialog body, sets both +`state` and `status` to `Active`, and rereads every component. Continue when the +command reports that all `{WORKDAY_TOPIC_COUNT}` topics were verified Active. +The command may also report dependency diagnostics such as +`CloudFlow NotFound`; preserve those diagnostics for support correlation, but +do not treat them as an activation failure or proof of a broken package. + +Record the live activation evidence: + +```powershell +python scripts/workday_connect.py record-topic-activation +``` + +This controller command resolves the same complete mapped Workday topic set, +authenticates as the recorded maker, and rereads `state` and `status` for every +topic. It accepts no manual boolean evidence. The command records activation as +complete when all mapped topics are Active; topic metadata diagnostics do not +create a runtime blocker by themselves. + +Set `ACTION_RESULT = "applied"` and +`WORKDAY_TOPICS_ACTIVATED = true`. On any error or count mismatch, stop and +leave the runtime phase active. diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md b/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md index ff3f408c6..7e2cdd67f 100644 --- a/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md +++ b/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md @@ -12,40 +12,47 @@ Every **Message** block is the exact text to show the user. Copy it verbatim. ## A.0 — Role gate (Environment Maker) The lifecycle runner already applied `permission-gate.md` before reading this -file — see `src/skills/connect/shared/lifecycle-runner.md` section L.4a, which -uses `GATE_MODE = "programmatic"` with the same Dataverse security-role query -`src/skills/setup/workday/install-workday-extension-pack.md` section P5.0 -uses for this exact role. This file starts from a passed gate; it does not -re-check it. +file using the exact programmatic Dataverse security-role query and accepted +role names declared for this phase in `contract.json`. This file starts from a +passed gate; it does not re-check it. ## A.1 — Explain what's about to change **Message:** -I'll wire your agent's **User Context** topic to call Workday on every -conversation. Without this, Workday topics respond with "This feature isn't -available yet." +I'll connect your agent's **User Context** setup to Workday so it can identify +the signed-in employee when a Workday request begins. **End message.** --- -## A.2 — Resolve the installed system topic +## A.2 — Resolve both topics from the workspace map -Find the installed Workday "Set User Context" system topic's dialog id under -`.local/agents/{AGENT_SLUG}/topics/`. Use the actual installed topic's -`schemaName` — do not assume a fixed name, since it varies by install path -(for example, `WorkdaySystemGetUserContextV2` on the current extension pack). -This installed tree is read-only package evidence. The editable redirect -remains in `workspace/agents/{AGENT_SLUG}/topics/`. +Read `workspace/agents/{AGENT_SLUG}/.component-map.json`. + +- Find exactly one entry whose `displayName` is + `[Admin] - User Context - Setup`. Save its map key as + `{USER_CONTEXT_TOPIC_PATH}`. +- Find exactly one entry whose `displayName` is + `Workday [System] - 1: Set User Context V2`. Save its `schemaName` as + `{USER_CONTEXT_DIALOG}`. + +Both entries must be `DialogComponent` records and both mapped files must +exist. Stop on missing or duplicate matches. Do not assume either filename: +current native agents commonly use `topics/Setusercontext.mcs.yml`, while +older materialized workspaces may use `topics/user-context-setup.mcs.yml`. --- ## A.3 — Edit the redirect -Set the agent's -`workspace/agents/{AGENT_SLUG}/topics/user-context-setup.mcs.yml` -`OnRedirect` to a `BeginDialog` calling that dialog id: +Read `workspace/agents/{AGENT_SLUG}/{USER_CONTEXT_TOPIC_PATH}`. Continue only +when it is either the empty `OnRedirect` scaffold or already contains the +exact redirect below. Refuse to overwrite any other actions or custom +content. + +Set its `OnRedirect` to a `BeginDialog` calling the resolved dialog id: ```yaml kind: AdaptiveDialog @@ -70,11 +77,14 @@ before continuing. Preview and push: ``` -python scripts/push.py --only "topics/user-context-setup.mcs.yml" --dry-run +python scripts/push.py --only "{USER_CONTEXT_TOPIC_PATH}" --dry-run --preferred-username "{POWER_PLATFORM_MAKER}" ``` -Review the preview. The preview must contain only the `user-context-setup` topic. If any other -file appears, stop and report it instead of publishing unrelated work. +Run this command from the solution root containing `.local/config.json`. +Review the preview. It must contain only the setup topic. This action updates +the redirect but deliberately does not activate Workday topics; activation +runs only after flow connection and parameter sharing are complete. If any +other file appears, stop and report it instead of publishing unrelated work. Use the `vscode_askQuestions` tool: @@ -84,7 +94,7 @@ Use the `vscode_askQuestions` tool: "header": "Publish Workday wiring", "question": "Publish this scoped User Context topic change to the active agent?", "options": [ - { "label": "Publish", "recommended": true }, + { "label": "Publish" }, { "label": "Not now" } ], "allowFreeformInput": false @@ -92,12 +102,15 @@ Use the `vscode_askQuestions` tool: ] ``` +Leave the selection unset. Publishing is an explicit mutation approval, not a +recommended answer. + If the user selects **Not now**, set `ACTION_RESULT = "cancelled"`, return to the lifecycle runner without pushing, and leave the phase `in-progress`. If the user selects **Publish**, run: ``` -python scripts/push.py --only "topics/user-context-setup.mcs.yml" --yes +python scripts/push.py --only "{USER_CONTEXT_TOPIC_PATH}" --yes --preferred-username "{POWER_PLATFORM_MAKER}" ``` The explicit question above is the approval for this concrete scoped change; @@ -109,7 +122,9 @@ If it fails, stop and report the failure; do not return an applied result. ## A.5 — Return -Return `ACTION_RESULT` to the lifecycle runner. It re-runs `WD-REST-002` for -`AGENT_SLUG` only after an `"applied"` result and decides whether to advance -or use the named restore point — this file does not re-run the checkpoint -itself. +Return `ACTION_RESULT` to the lifecycle runner. With an `"applied"` result, +also return the exact `{USER_CONTEXT_TOPIC_PATH}` as +`ACTION_ROLLBACK_PUSH_GLOB`. Do not return a wildcard or directory. The runner +re-runs `WD-REST-002` for `AGENT_SLUG` only after an `"applied"` result and +decides whether to advance or use the named restore point — this file does not +re-run the checkpoint itself. diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/contract.json b/solutions/ess-maker-skills/src/skills/connect/workday/contract.json index a5b92924a..cede46f8b 100644 --- a/solutions/ess-maker-skills/src/skills/connect/workday/contract.json +++ b/solutions/ess-maker-skills/src/skills/connect/workday/contract.json @@ -30,7 +30,7 @@ "roleQueryPassNames": ["Environment Maker", "System Customizer", "System Administrator"], "actionDoc": "src/skills/connect/workday/actions/wire-user-context-redirect.md", "rollbackLabel": "Add User Context redirect to Workday", - "rollbackPushGlob": "topics/user-context-setup.mcs.yml" + "rollbackPushGlobFromAction": true }, { "id": "validation", diff --git a/solutions/ess-maker-skills/src/skills/setup/SKILL.md b/solutions/ess-maker-skills/src/skills/setup/SKILL.md index ac20b04d0..31b965d00 100644 --- a/solutions/ess-maker-skills/src/skills/setup/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/setup/SKILL.md @@ -1,6 +1,14 @@ # Hybrid Workday Extension Setup +This file is only the boundary for an explicit request to configure the retired +hybrid Workday extension. It is **not** the entry point for `/connect workday` +or `/connect-workday`. + +If this file is reached from either Workday connect command, immediately read +`src/skills/connect/SKILL.md` and follow its architecture-aware routing. Stop +processing this file; do not show the hybrid-unavailable message below. + Hybrid Workday keeps its flows, connections, plugins, template configurations, and environment configuration in a separately owned Dataverse-backed extension while the agent itself uses the DA-GA platform. @@ -14,10 +22,7 @@ Hybrid Workday extension setup is not available in this release. Your DA-GA agent setup is unchanged, and no Dataverse environment, solution, connection, or flow was modified. -If a hybrid Workday extension is already configured and its Dataverse endpoint -is recorded in this workspace, `/backup-template-configs` and -`/restore-template-configs` remain available for its reference-data -customisations. +Use `/connect workday` to connect Workday to a supported ESS HR agent. **End message.** diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md index e9f7c46fc..35c58388a 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md @@ -1,253 +1,175 @@ -# Workday Connect (DA) — Orchestrator - -Every **Message** block is the exact text to show the user. Copy it verbatim. Do -not rephrase, add commentary, or tell the user what tools you are calling or what -files you are reading. - -This router sequences the five Workday connect steps for the **ESS HR agent**, -using the master checklist as a -**resume-aware spine**: it renders the working checklist on first run, resumes at -the first unverified step, and dispatches to the owning step's playbook. It -**never** advances past a `MANUAL` / attestation row on a flightcheck pass alone — -those require explicit user acknowledgement (enforced by -[`shared/checklist-updater.md`](./shared/checklist-updater.md)). - -This skill assumes the Employee Self-Service base agent itself is already -installed — that's owned by `/setup`, not by this skill. DA-1 checks for it and -sends you to `/setup` first if it isn't there yet. - -This release does not support Workday for the ESS DA IT Agent. Before creating -the working checklist or reading provider state, resolve `activeAgent` from -`.local/config.json` and require an ESS DA HR agent entry with a stable slug, -`botId`, and HR schema name. Then read the canonical -`.local/setup/config.json` `agents` record keyed by that `botId` and require -canonical workspace evidence plus `steps.SETUP-07.state: "done"`. Do not -require `connect_ready: true`; configuring Workday may resolve the remaining -runtime connection blocker. Do not use the retired `selected_products` field -or choose the first agent in a multi-agent workspace. If the target is IT, -Hub, CEA, ambiguous, incomplete, or unresolved, show: - -**Message:** - -This Workday setup supports the ESS HR agent only. Select the ESS HR agent, -or contact your administrator if it isn't available. - -**End message.** - -Stop immediately without creating or updating any Workday state. This guard is -required even though the `/connect workday` router performs the same check, -because this file must remain safe if invoked directly. - ---- - -## Handling Workday credentials — never put secrets in chat - -The Workday **password is a secret**. **Never** ask for it with a chat question -(`vscode_askQuestions`, or a plain "paste your Workday password" message) — the -chat question tool has no masked-input option, so anything typed is recorded -verbatim in the transcript. - -When a Workday secret is genuinely required (only the FlightCheck Workday SOAP -workflow tests need one), it is collected **exclusively** through a masked -input that keeps the value out of chat history: - -- the `.vscode/mcp.json` `workdayPass` input — a `promptString` with - `"password": true`, which VS Code masks and substitutes directly into the - check environment, or -- the FlightCheck CLI's own `getpass` prompt when you run - `python scripts/flightcheck/cli.py --scope workdayda` in the terminal. - -Non-secret connection identifiers (tenant, SOAP/REST/token URLs, OAuth client -ID, App ID URI) are safe to capture in chat — see -[`shared/connection-fields.md`](./shared/connection-fields.md). The Workday -**username** is likewise not masked (`"password": false`); only the password is. - ---- - -## Start - -1. **Show the readiness briefing.** After the DA HR agent guard and routing - FlightChecks have passed, show this before creating or resuming the - checklist. Show it on every invocation so a resumed setup makes its - remaining administrator dependencies clear. - - **Message:** - - Here's the plan for connecting Workday to your ESS HR agent. Some steps - require administrators outside the maker role, so involve them now if you - don't hold these permissions: - - | Phase | What we'll do | Who is needed | - | --- | --- | --- | - | Workday extension | Install or verify the Workday package for the ESS HR agent | Power Platform Environment Maker | - | Microsoft Entra | Configure Workday SSO, API permission, consent, user assignment, NameID, and SAML signing | Entra Application Administrator or Cloud Application Administrator; a consent-capable administrator if required | - | Workday tenant | Configure tenant security, the API client, functional areas, endpoints, authentication policy, and certificate trust | Workday Administrator | - | Power Platform connections | Configure Workday OAuthUser and Dataverse connections, shared parameters, bindings, and cloud flows | Power Platform Environment Maker | - | Agent authorization | Preview and run the Dataverse bot-to-flow authorization script | Power Platform Administrator with Dataverse System Administrator access | - | Network readiness | Allow the required Workday REST and SOAP hosts | InfoSec or network administrator | - | Topics and validation | Select Workday topics and validate a real signed-in employee scenario | Environment Maker, Workday test employee, and Workday Administrator if remediation is needed | - - I'll automate checks and supported changes where reliable APIs are - available. For Workday or portal-only settings, I'll give the responsible - administrator the exact steps and wait for confirmation. I won't mark the - environment ready until the signed-in Workday scenario succeeds. - - **End message.** - -2. **Working copy.** If `.local/setup/workday-da/tasks.md` does not exist, render - it by copying the template `src/skills/setup/workday-da/tasks.md`. Do not - hand-edit its status markers — the shared checklist-updater writes them. - -3. **Resume point.** Read `setupStatus` in `.local/connect/workday-da/config.json` - (the durable source of truth; the tasks file is only the view). If the file or - the `setupStatus` key is missing, treat every row as `pending`. A row counts as - complete only when `setupStatus["{Step}"].state` is `"done"`. - -4. **Show the checklist, then find where to resume.** Determine each item's state - from `setupStatus`: ✅ = `done`, 🔄 = `in-progress`, ⛔ = `blocked`, ⬜ = - `pending` or unset. Show the checklist **grouped exactly as in the template** — - the group headings and item titles below are verbatim from - `src/skills/setup/workday-da/tasks.md`; render every group and every item, - replacing each `{m}` with that item's marker. **Never show Step IDs or - checkpoint IDs.** - - **Message:** - - Here's the checklist for connecting Workday to your agent: - - **1. Workday extension package** - - {m} Install the Workday extension package - - **2. Connect Microsoft Entra sign-in to Workday** - - {m} Set up Workday sign-in - - {m} Allow Power Platform to call Workday - - {m} Approve the sign-in permissions - - {m} Choose who can use Workday - - {m} Match the signed-in employee - - {m} Sign the Workday sign-in response - - {m} Confirm the correct Microsoft Entra tenant - - **3. Workday tenant configuration** - - {m} Register the Workday API client - - {m} Capture your Workday connection details - - {m} Verify employee SAML sign-in policy - - {m} Match the signing certificate - - **4. Power Platform and agent integration** - - {m} Create the Workday connection - - {m} Create the Microsoft Dataverse connection - - {m} Bind the extension connections - - {m} Turn on the Workday cloud flows - - {m} Connect Workday to the agent - - {m} Authorize the agent to use the Workday flows - - {m} Configure employee context and topics - - {m} Allow Workday through the firewall - - **5. Validate Workday readiness** - - {m} Validate a signed-in Workday scenario - - Picking up at: {title of the first item whose state is not `done`}. - - **End message.** - - Then walk the items in Step order (DA1.1, DA2.1 … DA5.1 — these IDs are - internal only), pick the first whose state is not `done`, and dispatch by that - Step in **Dispatch** below. A step's playbook may re-run its own idempotent - foundation steps (role gate, resource lookup) ahead of the resume item to - rehydrate in-memory state — follow the playbook's stated build order rather - than jumping straight into it. - -5. If **every** item is `done`, also require provider `status` to be `"ready"` - before showing **All done**. If every row is done but status is not ready, - treat DA5.1 as `in-progress` and dispatch to DA-5 to reconcile readiness; - never claim success from checklist state alone. - ---- - -## Dispatch - -**Persist each row the moment its checkpoint passes.** Every step calls -[`shared/checklist-updater.md`](./shared/checklist-updater.md) per row, inline — -updating both the working checklist and the durable `setupStatus` mirror -immediately — and **must not** batch those writes to the end of its run. This -keeps progress crash-safe: if a step errors midway, the rows already verified -stay complete and this router resumes at the first row that isn't. - -### DA1.1 — Install the Workday extension package (DA-1) - -Read `src/skills/setup/workday-da/install-extension.md` and follow it. That -playbook checks the DA base agent is installed and sends the user to `/setup` -if it isn't, attempts an automated install of the Workday extension package, -falls back to a guided manual AppSource install if automation isn't available -in this tenant, verifies the package landed (`WD-DA-PKG-001`), and updates row -**DA1.1** through the shared checklist-updater. - -When it returns, go back to **Start** to resume at the next unverified row. - -### DA2.1 through DA2.7 — Provision the Workday Entra app (DA-2) - -Read `src/skills/setup/workday-da/provision-entra-app.md` and follow it. That -playbook role-gates (App / Cloud Application Administrator), instantiates and -configures the Workday SSO gallery app, exposes the API scope and -pre-authorizes the Workday connector, grants and consents the Graph -permissions, assigns the enterprise app, sets the NameID mapping and SAML -signing option, and confirms single-tenant federation. It verifies each -outcome (`WD-CONN-102`, `WD-ENTRA-SCOPE-001`, `WD-ENTRA-CONSENT-001`, -`WD-ASSIGN-001`, `WD-ENTRA-NAMEID-001`, `WD-ENTRA-SIGNOPT-001`, `WD-CONN-010`) -and updates rows **DA2.1**–**DA2.7** through the shared checklist-updater -(DA2.1/DA2.6 manual and DA2.7 attest rows need acknowledgement). On resume it -always re-runs its role gate and DA2.1 (create the SSO app) first — both -idempotent — before the first incomplete row, since DA2.2–DA2.4 depend on the -in-memory app object id that only DA2.1 populates. - -When it returns, go back to **Start** to resume at the next unverified row. - -### DA3.1 through DA3.4 — Configure the Workday tenant (DA-3) - -Read `src/skills/setup/workday-da/configure-tenant.md` and follow it. That -playbook role-gates (Workday Administrator, by attestation), records the -current single-tenant SAML federation before any change, uploads and verifies -the X.509 signing certificate (`WD-CONN-102`), edits Tenant Setup – Security, -registers the Workday API client and captures the connection fields -(`WD-API-CLIENT-001`), and verifies the signed-in employee SAML policy -(`WD-TENANT-001`) — updating rows **DA3.1**–**DA3.4** through the shared -checklist-updater. All four are manual Workday-admin tasks (attest / manual -gates) that need acknowledgement; `WD-API-CLIENT-001` and `WD-TENANT-001` -report `MANUAL`. On resume it always re-runs its role gate and the -single-tenant SAML pre-check first — both idempotent — before the first -incomplete row. - -When it returns, go back to **Start** to resume at the next unverified row. - -### DA4.1 through DA4.8 — Configure Power Platform and agent integration (DA-4) - -Read `src/skills/setup/workday-da/configure-power-platform.md` and follow it. -That playbook guides creation of the Workday and Dataverse connections, binds -the installed solution references, activates the runtime flows, connects the -flows to the agent with parameter sharing, applies checked-in script -authorization, configures DA V2 employee context and topic selection, and -records firewall allowlisting. It updates rows **DA4.1**–**DA4.8** through the -shared checklist-updater. Manual and attestation rows require explicit -evidence; DA4.3, supported DA4.4 activation, and DA4.6 are programmatic. - -When it returns, go back to **Start** to resume at DA5.1. - -### DA5.1 — Validate Workday readiness (DA-5) - -Read `src/skills/setup/workday-da/verify-connection.md` and follow it. That -playbook re-runs `WD-DA-PKG-001`, summarizes all setup areas, and requires a -successful signed-in employee Workday scenario. It updates **DA5.1** only after -runtime evidence is captured and sets provider `status` to `"ready"`. - -When it returns, go back to **Start** — every row should now be `done`. - -## All done - -**Message:** - -Your ESS HR agent is connected to Workday and the signed-in employee path -has been validated in this environment. The Workday connection is ready; you -do not need to run `/setup` again. - -**End message.** +# Connect Workday to the ESS HR agent + +Guide the customer through one resumable Workday connection lifecycle. Use +`scripts/workday_connect.py` and +`.local/connect/workday-da/config.json` internally; do not create, copy, +update, or infer status from a Markdown checklist. +Use `shared/config-schema.md` as the internal state and migration reference; +it is not a customer-executed phase and must not be shown as an extra step. + +## Safety contract + +- Support only the active native ESS HR agent recorded by `/setup`. Classic DA + and ESS IT agents are outside this lifecycle. The controller verifies the + exact agent, workspace materialization, architecture, and Dataverse + environment during preflight. +- Never ask for a Workday password, client secret, access token, refresh token, + cookie, certificate private key, or certificate body in chat. +- Explain an authentication prompt before launching it. Azure CLI/Graph, PAC, + Dataverse, connector OAuth, and Agent Builder are separate credential stores; + a prompt for a different store is expected, but a valid store must not be + prompted twice for the same account and session. +- Preview the exact target and actions before approval. After approval, verify + the plan hash immediately before every mutation. If discovery or scope + changes, discard the approval and show the new plan. +- After every mutation, reread the target and persist evidence only after the + verified result matches the approved plan. +- Never diagnose a permission problem from a guess. Show the API, CLI, or + checked-in script evidence that produced the diagnosis. +- Use structured `vscode_askQuestions` forms for customer evidence. Never + replace a multi-field form with one large free-text question or ask the + customer to edit a prose template. +- Leave every option initially unset. Do not add `recommended`, `default`, + “recommended” label text, or any equivalent preselection to approvals, + factual observations, connection choices, or validation outcomes. Continue + only after the customer explicitly submits a choice. +- Reuse the exact recorded account through the shared credential cache. Run + only the narrow verification required for the current phase; do not launch + broader checks that request unrelated API audiences. + +## Customer-facing language contract + +Commands, file paths, JSON payloads, state keys, plan hashes, checkpoint IDs, +schema names, component maps, API names, and implementation terms in this skill +are internal execution instructions. Never show or narrate them unless the +customer explicitly asks for technical diagnostics. + +Customer-facing messages must describe only: + +- the customer-visible target and current phase; +- who needs to perform a portal or Workday action; +- the exact portal navigation and field value needed for that action; +- whether a supported change or verification succeeded; +- the concise remediation needed when it did not. + +Translate internal outcomes into plain language. For example, say **all +Workday topics are enabled**, not that component `state` and `status` are +`Active`. Topic metadata diagnostics are support context and must not be +translated into a missing-package, missing-flow, or runtime-failure claim by +themselves. Never show `CloudFlow NotFound`, `MinimalBot`, component-map, +native-definition terminology, raw command output, tracebacks, encoding +errors, or internal identifiers in a customer message. + +Read `WORKDAY_CONNECT_RESULT_JSON` directly. Do not create an ad hoc Python or +PowerShell formatter merely to render controller status. If an internal +formatting command fails, correct or retry it internally and show only the +validated customer-facing result. + +## Capability contract + +Describe each action according to who actually performs it: + +| Phase | What the skill can do | What remains a user or administrator action | +| --- | --- | --- | +| Preflight | Verify the selected agent, environment, account, and supported Workday package; install the package when needed | Complete Microsoft sign-in and choose an environment when no exact URL is known | +| Microsoft Entra | Discover exact applications, validate roles, generate one administrator handoff, reread Graph, and record verified evidence | Create or change the Entra application in the portal | +| Workday administrator | Generate the handoff, validate returned non-secret values, derive endpoints, and record evidence | Change SAML, OAuth, API-client, certificate, or authentication-policy settings in Workday | +| Connections | Record the reviewed physical-connection readiness evidence | Create connector connections and complete connector OAuth | +| Runtime | After approval, bind the Workday connections, activate the package flows, configure required runtime permissions, connect employee context routing, enable every Workday topic included with the agent, and verify the result | Connect flows to the agent and enable parameter sharing in Copilot Studio when those settings require maker interaction | +| Employee validation | Record safe validation evidence and retain the current blocker | Publish the agent, sign in as an employee, and run the real employee scenario | + +Never say "I changed," "I configured," "I enabled," or "I updated" for a +manual action. Say what the administrator or maker must do, then say what the +skill can verify or record afterward. Claim an automated change only after its +command succeeded and the target was reread. + +## Tenant foundation and deployment scope + +Treat the Entra and Workday configuration as a reusable tenant foundation. +Treat package installation, physical connections, runtime flow wiring, native +topics, publishing, and employee validation as environment-and-agent-specific +deployment work. + +- A different Power Platform environment, ESS HR agent, or maker account must + not by itself require the Entra or Workday administrators to repeat setup. +- When the Entra tenant, Workday tenant, and exact Entra application still + match stored foundation evidence, reread the Entra configuration. If it is + healthy, reuse the stored Workday administrator evidence and continue at + Connections. +- Involve an administrator only when foundation evidence is absent, the + tenant/application identity changed, the Entra reread finds drift, the + signing certificate changed or is unhealthy, or a later connection/runtime + test proves the stored Workday configuration no longer works. +- Never reuse foundation evidence across a different Entra tenant, Workday + tenant, SAML Service Provider ID, Entra application, or signing certificate. +- Preserve the full administrator guide even on the reuse path, but show only + the affected remediation step instead of making the user repeat healthy + configuration. + +## Start or resume + +Before running status, show this readiness briefing on every invocation. A +resumed setup must still make its remaining administrator dependencies clear. + +> Here's who may be needed to connect Workday to your ESS HR agent: +> +> | Phase | Responsibility | Who is needed | +> | --- | --- | --- | +> | Preflight | Verify the ESS HR agent and environment, and install or verify the supported Workday package | Power Platform Environment Maker with package installation access | +> | Microsoft Entra | Configure the Workday enterprise application, SAML, API permission, consent, assignment, and NameID | Application Administrator or Cloud Application Administrator; a consent-capable administrator when required | +> | Workday administrator | Configure tenant SAML and certificate trust, OAuth and the API client, functional-area access, endpoints, and the employee authentication policy | Workday Administrator | +> | Connections | Create the Workday OAuthUser and Dataverse connections and complete connector sign-in | Power Platform Environment Maker | +> | Runtime configuration | Connect the installed Workday components, activate the required flows, configure runtime permissions and connection sharing, and enable all Workday topics | Power Platform Environment Maker; Dataverse System Administrator access for runtime authorization | +> | Employee validation | Publish the agent and validate a real signed-in employee scenario | Environment Maker and Workday test employee; Workday Administrator or network administrator if remediation is needed | +> +> I'll automate checks and supported changes where reliable APIs are available. +> For Workday or portal-only settings, I'll provide the responsible +> administrator with the exact steps and wait for verified evidence. The +> environment isn't ready until the signed-in Workday scenario succeeds. + +Run: + +```powershell +python scripts/workday_connect.py status +``` + +After the readiness briefing: + +1. Render the returned `progressText` as Markdown. It is the visible six-row + roadmap and must not be collapsed into a one-line phase list. +2. Render the returned `nextPhaseSummary` in this form: + + ```markdown + ### Next phase: {title} + + What happens in this phase: + + - {whatHappens item 1} + - {whatHappens item 2} + - {whatHappens item 3} + ``` + +3. Show the current blocker afterward when one is present. + +Do not render internal action IDs, hashes, or the full JSON state. Do not +replace the phase explanation with only `Next phase: {title}`. + +Dispatch from `nextPhaseId`: + +- `preflight` -> read `install-extension.md` +- `entra` -> read `provision-entra-app.md` +- `workday-admin` -> read `configure-tenant.md` +- `connections` or `runtime` -> read `configure-power-platform.md` +- `employee-validation` -> read `verify-connection.md` + +When a phase returns, run `status` again and continue from the controller's +next phase. Never restart completed phases because the user asked a side +question; answer the side question, then resume the same blocker. + +## Completion + +Only show the following after controller status is `ready`: + +> Your ESS HR agent is connected to Workday, and the signed-in employee path +> has been validated in this environment. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md index 94e2e29a9..9c4791c17 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md @@ -1,278 +1,354 @@ -# DA-4 — Configure Power Platform and Agent Integration - -Role: **Environment Maker**, with a **Power Platform Administrator** for -bot-to-flow authorization and **InfoSec/IT** for network allowlisting. This step -applies the Workday and Entra values captured earlier to the installed ESS DA HR -extension. It owns checklist rows **DA4.1 through DA4.8**. - -Every **Message** block is the exact text to show the user. Copy it verbatim. Do -not claim that a manual portal setting was verified automatically. - -The two required runtime connection references are: - -| Connection | Logical name | -| --- | --- | -| Workday OAuthUser | `msdyn_sharedworkdaysoap_workdayruntime` | -| Microsoft Dataverse | `msdyn_sharedcommondataserviceforapps_workdayruntime` | - -Never reuse Dev connection IDs, bot IDs, or workflow IDs in another -environment. - ---- - -## DA4.0 — Prepare the connections page - -This setup always uses the signed-in employee Workday runtime. Do not present -an installation-path or migration choice. - -Build the environment's Connections URL from the recorded ring: - -- `preprod` → - `https://make.preprod.powerautomate.com/environments/{ENV_ID}/connections` -- `prod` → - `https://make.powerautomate.com/environments/{ENV_ID}/connections` - -Persist `installPath: "simplified"`. - -## DA4.1 — Connect the Workday OAuthUser reference - -**Message:** - -Now we'll create the two connections the Workday runtime needs. - -Open this environment's **Connections** page: - -{CONNECTIONS_URL} - -1. Select **New connection**, search for **Workday**, and create a connection - using **Microsoft Entra ID Integrated** authentication. -3. Enter the values below. Complete the sign-in/consent window if one opens. -4. Wait until the Workday connection shows **Connected**. - -Use the values captured earlier: - -- Microsoft Entra resource URL: the Workday SAML identifier configured for - this tenant, not the `api://` application ID URI. -- OAuth token URL: `{oauthTokenUrl}`. -- Workday API client ID: `{oauthClientId}`. -- SOAP base URL: `{soapBaseUrl}`. -- REST base URL: `{restBaseUrl}`. It must end exactly at `/api`. - -**End message.** - -If no Workday connection exists yet, do not ask the maker to confirm the -reference binding—guide the connection creation first. Re-read the ring-native -connection inventory and confirm the new `shared_workdaysoap` connection is -`Connected` and carries the expected resource, token, client, SOAP, REST, and -tenant values. -Record DA4.1 with `GATE="manual"`, `ACK=true`, and evidence describing the -connection name and environment. If it is not connected, leave the row -`in-progress`. - -## DA4.2 — Connect the Dataverse reference - -**Message:** - -On the same **Connections** page, select **New connection** and create a -**Microsoft Dataverse** connection with your maker account. Wait until both the -Workday and Dataverse connections show **Connected**, then tell me they are -ready. - -**End message.** - -Re-read the ring-native connection inventory and confirm the Dataverse -connection is `Connected` and belongs to this environment. Record DA4.2 with -the connection name and environment in the evidence. - -## DA4.3 — Bind the extension connections - -Bind the installed solution references programmatically: - -1. Resolve the physical Workday and Dataverse connection IDs created in - DA4.1–DA4.2. -2. PATCH `msdyn_sharedworkdaysoap_workdayruntime.connectionid` to the Workday - connection ID. -3. PATCH - `msdyn_sharedcommondataserviceforapps_workdayruntime.connectionid` to the - Dataverse connection ID. -4. Re-read both rows and confirm the IDs persisted. -5. Confirm neither reference points to a connection from a different - environment or user. - -Preview the two target logical names and connection display names before -PATCHing. The authorization script does not perform this step. Record DA4.3 -only after the post-write verification passes. - -## DA4.4 — Turn on the Workday cloud flows - -Do not activate flows until DA4.1–DA4.3 are complete and the two installed -runtime `connectionreference` rows have non-empty connection bindings. A flow -whose references are unbound may activate but will fail at runtime. - -Discover the Workday flows installed with the ESS DA HR extension when a -reliable DA-scoped listing is available. If they can be enabled through the -supported Power Platform API, preview the affected flows and ask for approval -before enabling them. - -Otherwise show: - -**Message:** - -Open **Power Apps → Solutions → Workday → Cloud flows**. Turn on every flow used -by the ESS HR agent, then confirm they all show **On**. Do not enable unrelated -flows from other solutions. - -**End message.** - -Record DA4.4 only after every target Workday flow is verified as active. - -## DA4.5 — Connect the agent and share parameters - -**Message:** - -The Workday and Dataverse connections are ready and the runtime flows are on. -Now connect those flows to this agent: - -1. Open the active agent's **Copilot Studio → Settings → Connection settings** - page: - - `https://{CPS_HOST}/environments/{ENV_ID}/copilots/{BOT_ID}/da-settings/connectionSettings` -2. Open each Workday flow entry and select **Connect**. -3. Select the Workday connection created earlier and submit. -4. Under **Manage**, select **See details**. -5. Open **Connection parameters**. -6. Turn on **Allow permission to share parameters** and save. - -If the parameter values appear empty, turn the setting off and save, turn it -back on and save again, then confirm the REST, SOAP, token, client, and resource -values remain populated. - -**End message.** - -This is agent/runtime wiring; solution-level binding does not replace it. Do -not infer it from the physical connection inventory's `allowSharing` property. -Require explicit confirmation that every Workday flow entry is connected, -parameter sharing is enabled, the fields remain populated, and the connection -is connected. If a connection is **Stale** or **Needs attention**, reconnect it -before continuing. Record DA4.5 as manual. - -## DA4.6 — Authorize the DA to use the Workday flows - -Use the checked-in authorization script: - -`scripts/alm/Enable-CosmosDAFlowAuthorization.ps1` - -Execute this PowerShell file directly. Do not translate, regenerate, or replace -it with Python. PowerShell 7 is preferred; Windows PowerShell 5.1 is also -supported by the script syntax. The script validates the Azure CLI Dataverse -token before use. If that token is rejected (including PPE environments), it -automatically reuses the kit's Dataverse authentication cache and opens the -standard kit sign-in only when a refresh is required. - -Resolve parameters instead of asking the maker to paste GUIDs: - -- `OrgUrl`: `.local/config.json` `dataverseEndpoint` when present; otherwise - `.local/connect/workday-da/config.json` `sidecarDataverseEndpoint`. This must - be the same effective Dataverse environment used by DA-1. -- `BotId`: active ESS DA HR agent → `agent.botId`. -- `WorkflowId[]`: the target Workday workflow IDs referenced by the active - agent's Workday topics. Resolve topic `flowId` values to Dataverse - `workflowid` values and exclude unrelated flows. -- `TeamName`: a deterministic name containing the agent and environment. - -If the bot or workflow set cannot be resolved unambiguously, stop and explain -which value is missing. Never guess or run the script with a partial flow set. - -Before invoking the checked-in script, perform the same read-only -delegated-authorization and team lookups documented by the script: - -- exactly one MCSBot delegated authorization and one linked Access team already - exist for the bot → the script reuses them and adds missing workflow shares; -- no authorization or team exists → the script may create them. Its Dataverse - writes request `Prefer: return=representation`, so the new record IDs are - captured and bound in the same run; -- more than one delegated authorization or linked team exists → stop and - require administrator remediation. The script also fails closed on these - ambiguous records. - -First run the script with `-WhatIf`, show the target organization, agent, and -flow display names, and obtain explicit approval. Then run the same command -without `-WhatIf`. - -The script's `-WhatIf` run may exit `1` after showing a correct `would create` -or `would share` plan. This happens because its final verification checks for -records and shares that `-WhatIf` intentionally did not write. Treat that -preview as acceptable only when the target values are correct and every -`[FAIL]` corresponds exactly to a listed preview operation. Authentication, -permission, lookup, missing-flow, wrong-target, conflicting-existing-record, or -multiple-team errors remain blocking. Do not apply based on an ambiguous -preview. - -DA4.6 passes only when the applied result has exactly one linked Access team, -the script exits with code `0`, ends with -`Dataverse authorization is in place.`, returns one access team for the target -bot, contains no `[FAIL]` line, and confirms `WriteAccess` for every supplied -workflow. On any failure, -leave the row blocked and show the script error. Do not replace this with an -attestation. - -After successful apply verification, update DA4.6 through -[`shared/checklist-updater.md`](shared/checklist-updater.md) with -`STEP_ID="DA4.6"`, `GATE="prog"`, and -`CHECKPOINT_RESULT="PASSED"`, `RESULT_SOURCE="external"`, and -`EXTERNAL_EVIDENCE` containing the target environment, bot, workflow display -names, script exit code, and verification summary. Render that summary instead -of reading `workspace/flightcheck/results.json`. On an apply or verification -failure, use `CHECKPOINT_RESULT="FAILED"`, `RESULT_SOURCE="external"`, and the -safe failure summary so the row becomes blocked. - -## DA4.7 — Configure employee context and Workday topics - -Inspect the installed DA package before changing the agent. Do not assume the -CEA topic name or file shape. Identify the package's V2 signed-in-user context -component that uses the Workday `/workers/me` path. - -Present these choices: - -1. **Enable all Workday topics** — recommended for makers who want the complete - Workday experience. -2. **Choose specific Workday topics** — show a multi-select list of available - business scenarios. -3. **Keep the current topic selection** — make no topic-status changes. - -Whichever option is selected, include the V2 signed-in-user context and every -system dependency required by the selected business topics. Preview the exact -topic list and obtain approval before changing anything. Confirm: - -- the DA-equivalent V2 user-context component is enabled and wired; -- selected topics are enabled; -- unselected topics remain disabled; -- choosing **Enable all** enables every installed Workday business topic plus - the required Workday system topics. - -Topic activation is server-only state and is not stored in the topic YAML. -The current AgentBuilder client can fetch components, update the bot entity, -import, and publish, but it has no proven per-component status mutation API. -Until a supported API is added, do not guess a MinimalBot payload. Provide the -equivalent Copilot Studio enablement steps, including the **Enable all** -selection, and record DA4.7 as manual after confirmation. - -## DA4.8 — Record firewall allowlisting - -**Message:** - -Your InfoSec/IT team must allow outbound access from the Power Platform Workday -managed connectors to these Workday hosts: - -- REST: `{restBaseUrl host}` -- SOAP: `{soapBaseUrl host}` - -Has that allowlisting been put in place for this environment? - -**End message.** - -This is an attestation, not a local connectivity test. Record DA4.8 only after -explicit acknowledgement and captured evidence. - -Return to the orchestrator. +# Phases 4 and 5 - Connections and runtime + +## Connections + +The skill does not create physical connector connections or complete connector +OAuth. The maker performs those actions; the skill discovers and verifies the +result. + +Agent **Connection Settings** is a later Runtime step. While either physical +connection is missing, disconnected, ambiguous, or awaiting confirmation, tell +the maker: + +> Do not open Copilot Studio Connection Settings yet. That page is configured +> only after both Power Platform connections are verified and the Workday +> Runtime changes have been applied. + +Do not provide the agent Connection Settings link during the Connections phase. +Do not diagnose the flow authorization script as failed when Runtime apply has +not run. + +Form direct connection-creation links from the recorded environment ID and +service ring. Resolve `{POWER_AUTOMATE_ORIGIN}` exactly as follows: + +- `prod` -> `https://make.powerautomate.com` +- `preprod` -> `https://make.preprod.powerautomate.com` +- `test` -> `https://make.test.powerautomate.com` + +Never send a non-production environment to the production maker portal. If the +ring is missing or unsupported, do not guess; ask the maker to confirm it. + +Use these environment-scoped links: + +- Workday: + `{POWER_AUTOMATE_ORIGIN}/environments/{ENVIRONMENT_ID}/connections/available/shared_workdaysoap` +- Microsoft Dataverse: + `{POWER_AUTOMATE_ORIGIN}/environments/{ENVIRONMENT_ID}/connections/available/shared_commondataserviceforapps` +- Connections list fallback: + `{POWER_AUTOMATE_ORIGIN}/environments/{ENVIRONMENT_ID}/connections` + +Do not begin with a yes/no question asking whether both connections are +already connected. First explain that this phase needs exactly two Power +Platform connections, then discover them: + +```powershell +python scripts/workday_connect.py record-connections +``` + +When both required connections resolve exactly, this read-only pass returns +`requiresConfirmation: true` with safe connection display names and the saved +non-secret Workday target values. It does not mark the phase complete. + +If the Workday connection is missing or disconnected, read the already +validated values from the Workday state and show them with these +customer-facing labels: + +- **Microsoft Entra resource URL:** the Workday SAML Service Provider ID, + `http://www.workday.com/{workdayTenant}`. Do not use the Entra application + ID URI beginning with `api://`. +- **Workday OAuth token URL:** the exact Token Endpoint copied from + **View API Client** in Workday. +- **Client ID:** the Workday OAuth client ID copied from **View API Client**, + not the Microsoft Entra application ID. + +These values were collected during the Workday administrator phase. Do not ask +the maker or administrator to provide them again. If any value is missing, +return to the affected Workday administrator step rather than guessing. + +Then give the maker this creation process: + +1. Show a **Create Workday connection** link using the environment-scoped + Workday URL above. It opens the correct connector in the already selected + environment. +2. If the direct link does not open, use the Connections list fallback or open + the Power Apps maker portal, select the exact environment, open + **Connections**, select **New connection**, and choose **Workday**. +3. Select **Microsoft Entra ID Integrated** authentication. +4. Enter the three displayed values in the matching connection fields. +5. Select **Create** and complete the Workday sign-in window. This connector + uses a separate credential store, so an additional sign-in prompt is + expected even when Microsoft or PAC authentication already succeeded. +6. Return to **Connections** and confirm that the Workday connection shows + **Connected**. + +Do not request or collect a Workday password, client secret, access token, +refresh token, or cookie. The maker completes authentication in the connector +sign-in window. + +If the Microsoft Dataverse connection is missing or disconnected: + +1. Show a **Create Microsoft Dataverse connection** link using the + environment-scoped Microsoft Dataverse URL above. +2. If the direct link does not open, use the Connections list fallback, select + **New connection**, and choose **Microsoft Dataverse**. +3. Create or repair the connection using the selected maker account. +4. Confirm that it shows **Connected**. + +Reuse a healthy existing connection when one already exists. Do not create +duplicates merely to satisfy the phase, and do not ask the maker to paste +connection IDs. + +Until both connections show **Connected** and `record-connections` succeeds, +keep the customer in the Power Apps **Connections** page. Ask them to return to +the skill for another check; do not redirect them to the agent's Connection +Settings page. + +Show the safe display name of the selected Workday connection and the three +saved non-secret Workday values. Use `vscode_askQuestions`: + +```json +[ + { + "header": "Confirm Workday connection", + "question": "Was this exact Workday connection created with the displayed Microsoft Entra resource URL, Workday OAuth token URL, and Workday OAuth client ID?", + "options": [ + { "label": "Yes, confirm this connection" }, + { "label": "No, review or repair it" } + ], + "allowFreeformInput": false + } +] +``` + +Leave the selection unset. This is target evidence, not a suggested answer. + +If the maker does not confirm, leave Connections waiting. After confirmation, +verify both live connections using the internally resolved connection IDs: + +```powershell +python scripts/workday_connect.py record-connections --workday-connection-id "{WORKDAY_CONNECTION_ID}" --dataverse-connection-id "{DATAVERSE_CONNECTION_ID}" --confirm-workday-target +``` + +The command discovers connected physical connections in the selected +environment and records the result only after both required connections are +live. + +- If exactly one connection exists for each connector, discovery is + deterministic. +- If more than one exists, show safe display names and ask which connection to + use. Do not recommend, preselect, or visually favor a connection based on its + name or owner. Resolve the selected display name to its ID internally, then rerun + `record-connections` with + `--workday-connection-id` and/or `--dataverse-connection-id`; never ask the + maker to paste or repeat an ID. +- If none exists or a connection is not connected, leave the phase waiting and + show the exact missing connector. + +Do not construct or pass manual connection evidence. + +## Runtime approval and apply + +Run runtime discovery. The controller reuses the exact connection IDs recorded +in the Connections phase: + +```powershell +python scripts/workday_connect.py runtime-plan +``` + +This discovers the installed connection references, supported package flows, +and selected agent. Employee-context topic wiring is verified later in this +phase. + +For a package with a reviewed runtime flow catalog, the controller performs the +following writes after exact-plan approval. These are real automated changes, +not instructions for the maker: + +Show only the returned `approvalSummary`, not raw connection, application, +workflow, or bot identifiers. Then use `vscode_askQuestions`: + +```json +[ + { + "header": "Apply Workday runtime changes", + "question": "Apply these exact Workday runtime changes to the selected environment and agent?", + "options": [ + { "label": "Apply changes" }, + { "label": "Not now" } + ], + "allowFreeformInput": false + } +] +``` + +Leave the selection unset. Do not represent mutation approval as recommended. + +If the maker selects **Not now**, leave Runtime waiting and do not call +`runtime-approve` or `runtime-apply`. + +If the maker approves, write the returned `plan` object directly to +`.local/connect/workday-da/runtime-plan.json` using a structured file-write +tool. Do not serialize it into a generated shell command. The combined runtime +plan will: + +- bind the two reviewed connection references; +- activate only the checked-in Workday flow catalog; +- authorize the exact agent to invoke those exact workflow IDs; +- reread every changed record. + +If the setup topic contains custom content, stop and preserve it. Do not +overwrite or approximate the topic. + +Approve the exact plan: + +```powershell +python scripts/workday_connect.py runtime-approve --plan-file ".local\connect\workday-da\runtime-plan.json" +``` + +Apply using the returned hash and the same disambiguating connection IDs, if +any: + +```powershell +python scripts/workday_connect.py runtime-apply --plan-hash "{hash}" +``` + +The controller rediscovers the current target, rejects stale approval, reuses +one Dataverse token for Python mutations, invokes the checked-in delegated +authorization script, and verifies connection-reference bindings, flow state, +and authorization after each ordered stage. User Context V2 and selected-agent +flow attachment are verified separately below. The controller records each +verified stage immediately, so a later failure resumes from durable evidence +rather than hiding earlier successful changes. Report permission issues only +from an explicit forbidden response, `[FAIL]` marker, ambiguity result, or +nonzero script exit. + +Do not direct the maker to agent Connection Settings unless `runtime-apply` +returns `applied.verified: true` and confirms all three verified stages: +`connection-references-bound`, `runtime-flows-active`, and +`delegated-authorization-configured`. If Runtime apply has not run or any stage +is incomplete, keep Runtime active and show the controller's actual blocker. + +## Agent binding after flow activation + +Only after runtime apply has activated the reviewed flows, wire the native +agent's local `[Admin] - User Context - Setup` topic to +`Workday [System] - 1: Set User Context V2` using the existing guarded +checkpoint, scoped dry-run, approval, and push pattern in +`src/skills/connect/workday/actions/wire-user-context-redirect.md`. Pass the +recorded Power Platform maker as `--preferred-username` so the native push +cannot silently reuse another cached account. This scoped push changes only +the setup redirect; Workday topics remain inactive until connection sharing is +complete. Run `WD-REST-002` after the scoped push and require it to pass: + +```powershell +python scripts/flightcheck/cli.py --checkpoint WD-REST-002 --connect-config ".local/connect/workday-da/config.json" --agent-slug "{AGENT_SLUG}" --preferred-username "{POWER_PLATFORM_MAKER}" +``` + +Topic metadata can report stale or transient component-reference diagnostics +even when the installed package and runtime flows are functioning. Record those +diagnostics for support, but do not treat them alone as proof of a broken +package, tell the customer to repair the installation, or block this phase. +Continue with the supported live checkpoints, topic-state verification, and +employee scenario. Do not recreate, clone, reselect, or rewrite packaged flows +in response to topic metadata alone. + +Only now direct the maker to the selected agent's **Settings** > +**Connection Settings** page. Before they continue, show the exact recorded +Power Platform maker account and tell them to verify that Copilot Studio's +browser profile is signed in as that account. The CLI credential cache and the +Copilot Studio browser session are separate. If the browser shows another +account, the maker must switch accounts or use a separate browser profile +before selecting a connection. + +Connect **ESS Workday Runtime REST Execution** and any other Workday flow shown +there. The reviewed native agent contract marks **ESS Workday Runtime** and +**ESS Workday Runtime References** as `EmbeddedOnly`; embedded flows are not +expected to require a maker-selected user connection. For every Workday +connection the page does expose, enable **Allow permission to share +parameters**. This prevents each employee from receiving an unexpected +first-use connection prompt. + +Internal execution note—never show this implementation detail to the customer: +`connectionType: EmbeddedOnly` is separate from the Dataverse +`delegatedauthorization` records created earlier. The former controls the +agent-facing connection contract; the latter grants the Cosmos-backed agent +principal access to the reviewed Dataverse workflows. Do not skip or scope the +authorization stage solely from `connectionType`. + +Show the exact agent name and the reviewed agent-facing flow +**ESS Workday Runtime REST Execution**. Use `vscode_askQuestions`: + +```json +[ + { + "header": "Confirm Workday flow connection", + "question": "In this exact agent, is ESS Workday Runtime REST Execution connected and is parameter sharing enabled for every Workday connection shown by Copilot Studio?", + "options": [ + { "label": "Yes, confirmed" }, + { "label": "No, review the agent connections" } + ], + "allowFreeformInput": false + } +] +``` + +Leave the selection unset. This confirmation must reflect what the maker +observed in the selected agent. + +If the maker does not confirm, leave Runtime active. If confirmed, write this +target-bound evidence to +`.local/connect/workday-da/agent-flow-attachment.json` using a structured +file-write tool: + +```json +{ + "outcome": "maker-confirmed", + "botId": "{SELECTED_AGENT_BOT_ID}", + "flowNames": ["ESS Workday Runtime REST Execution"], + "parameterSharingOutcome": "enabled-for-exposed-connections" +} +``` + +Then run internally: +`src/skills/connect/workday/actions/activate-workday-topics.md`. That action +derives the complete Workday dialog list from the selected agent's +`.component-map.json`, previews the exact scope, and uses the native components +endpoint to set both `state` and `status` to `Active` for every Workday topic. +Do not activate only the two User Context setup topics. + +Then run: + +```powershell +python scripts/workday_connect.py record-topic-activation +``` + +This command proves that every Workday topic included with the agent is +enabled. Topic diagnostics are retained as supporting detail but do not change +the activation result or create a package-repair blocker by themselves. + +Then run: + +```powershell +python scripts/workday_connect.py record-agent-binding --attachment-file ".local\connect\workday-da\agent-flow-attachment.json" +``` + +This command reruns `WD-REST-002` and `WD-CONN-013` with the recorded Workday +state, signs in to the native components endpoint as the recorded maker, +derives the complete Workday topic set from `.component-map.json`, and rereads +every mapped topic. It completes the runtime phase only when every checkpoint +passes, the target-bound flow attachment is confirmed, and every Workday topic +is Active. It retains any topic diagnostics for support correlation without +presenting them as runtime failure evidence. The signed-in employee scenario +remains the functional confirmation that the Workday runtime works. Do not +substitute an unscoped "done" response for the structured confirmation. +Do not run `WD-CONN-013` separately or ask for the flow-connection +confirmation twice; `record-agent-binding` performs the required live check +after the single target-bound confirmation above. + +If runtime discovery reports that the selected package has no reviewed flow +catalog, record a manual handoff. Do not claim that connection references, +flows, authorization, or topics were changed. + +Topic/business-scenario selection that is not represented by a reviewed +deterministic helper remains a concise manual handoff; do not expand it into a +per-topic internal checklist. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md index eabe63a8c..664b4c4fc 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md @@ -1,463 +1,313 @@ -# DA-3 — Configure the Workday Tenant +# Phase 3 - Workday administrator -Role: **Workday Administrator**. This step performs the Workday-tenant-side -configuration the simplified setup requires: the SAML X.509 signing certificate, -Tenant Setup – Security, the Workday API client, and the authentication policy. -It owns master-checklist rows **DA3.1 through DA3.4**. +This phase never modifies Workday. The skill generates one handoff, validates +the administrator's non-secret response, derives deterministic endpoints, and +records evidence. All Workday tenant changes are performed by the Workday +administrator. -Depends on DA-2 (the Entra app must already exist — this step reads its -`entraAppId` / `appIdUri` and the activated signing-cert thumbprint). It is -**Workday-only**: none of these tasks is reachable through a Microsoft admin API, -and standing up a Workday connection to self-verify would be **circular** (it -needs the same Entra-app + tenant configuration the ESS agent itself needs). So -every step here is a **manual Workday-admin task**, and its flightcheck reports -`MANUAL` — it echoes what the operator captured and names the Workday screen to -verify, but it never marks a row done on its own. None of this differs from how a -CEA Employee Self-Service agent's Workday tenant is configured — the Workday side -of the connection doesn't know or care what agent architecture is calling it — -only the persisted state paths differ. +Generate one administrator handoff: -Every **Message** block is the exact text to show the user. Copy it verbatim. Do -not rephrase, add commentary, or tell the user what tools you are calling or what -files you are reading. **Never** show internal variable names or IDs in chat. - -**Checkpoints this step drives (run each in isolation):** - -| Step | Checkpoint | Gate | -|------|-----------|------| -| DA3.1 | `WD-API-CLIENT-001` — Workday API client registered (SAML ****** grant, functional areas, Include Workday Owned Scope = Yes) | attest | -| DA3.2 | `WD-API-CLIENT-001` — Workday connection fields captured with the registered API client | attest | -| DA3.3 | `WD-TENANT-001` — signed-in employee SAML authentication policy verified | attest | -| DA3.4 | `WD-CONN-102` *(reuse)* — Workday X.509 signing cert matches the Entra one | manual/attest | - -Run any one with: - -``` -python scripts/flightcheck/cli.py --checkpoint +```powershell +python scripts/workday_connect.py workday-admin-packet ``` -**After every checkpoint run, show its result in chat first.** As soon as a -`--checkpoint` run returns, render the result to the user per -[`shared/checklist-updater.md`](shared/checklist-updater.md) §U.0–U.0a — the -compact result table and, for any `MANUAL` (or `Warning` / `NotConfigured`) row, -its full verification steps — **before** you show any later **Message** or ask any -attestation question. Single-checkpoint runs never open the HTML report, so this -in-chat render is the only place the user sees the manual steps; never ask a user -to attest to steps they have not been shown. - -Both `WD-API-CLIENT-001` and `WD-TENANT-001` are always `MANUAL` — they read only -`.local/connect/workday-da/config.json` and echo the captured values. A `MANUAL` -result is **never** completion: each attest row also needs the user's explicit -acknowledgement (enforced by -[`shared/checklist-updater.md`](shared/checklist-updater.md)). - -**Build order.** These tasks must happen in Workday's natural order, which is -**not** the row-number order: sign-in cert (DA3.0c) → Tenant Setup – Security -(DA3.0d) → **register the API client (DA3.1 + DA3.2)** → **verify the -signed-in employee authentication policy (DA3.3)**. Each section states which -checklist row(s) it completes. - -**On every resume, always re-run DA3.0 (Workday-admin gate) and DA3.0b -(single-tenant SAML pre-gate) first — both are idempotent/read-only — before -working the first incomplete row.** The SAML pre-gate is a safety check that -must run before any tenant change; skipping it on resume risks silently -overwriting an active federation. After re-running DA3.0 and DA3.0b, skip any row -whose `setupStatus` state is already `done`. - ---- - -## DA3.0 — Workday administrator gate - -Every task in this step is a **manual Workday-tenant change** — the SAML signing -certificate, Tenant Setup – Security, the Workday API client, and the -authentication policy. None is reachable through a Microsoft admin API, and the -person running this kit (the maker) is often **not** a Workday administrator. So -these steps must be performed **together with a Workday administrator**. Before -making any tenant change, confirm one is lined up. - -This is the attested gate for **DA3.1** (`GATE_MODE = "attested"`, `STEP_ID = -"DA3.1"`, per [`shared/permission-gate.md`](shared/permission-gate.md)) — Workday -has **no directory the kit can query**, so it is an explicit confirmation, not a -programmatic check. - -**Message:** - -The next steps change your Workday tenant directly — the SAML signing -certificate, Tenant Setup – Security, the Workday API client, and the -authentication policy. These are Workday-administrator tasks, so they should be -done **together with a Workday administrator** (if that isn't you). Before we -start, please confirm you have a Workday administrator ready to work through these -steps with you. - -**End message.** - -Use the `vscode_askQuestions` tool: +Show the packet once as a single ordered task list. Do not split it into +repeated confirmations or rerun manual-only FlightChecks that merely repeat +the same instructions. + +If `record-entra` reports `tenantFoundationReused: true`, do not show this +handoff and do not require the Workday administrator again. Continue at +Connections. Show only the affected Workday remediation step if a later +connection or employee test proves that the stored foundation has drifted. + +When no matching foundation can be reused, guide the Workday administrator +through these steps in order. Do not add a separate availability confirmation; +if the administrator is not available, present the handoff and pause before +showing the response form. + +1. **Protect the existing federation.** In Workday, run **Edit Tenant Setup - + Security** and find **SAML Setup**. In **SAML Identity Providers**, locate + the enabled row whose **Used for Environments** value matches the employee + environment being connected. Use `identityProviderQuestion` and + `issuerConfirmationQuestion` from the packet to populate the corresponding + fields in the consolidated response form below. Do not present them as + separate questions. The domain examples are recognition clues, not proof. + + Handle the answer as follows: + + - **Microsoft Entra ID** - continue. If the administrator confirms that the + displayed Issuer exactly matches the value shown in the form, + record `identityProviderOutcome` as `verified-entra-issuer`; do not make + them retype the value. If it differs, collect the exact displayed + **Issuer** in the single response form. Do not infer a match from the + provider choice alone. + - **Okta**, **Ping Identity**, or **Another sign-in provider** - stop before + changing the row. Explain that the current row belongs to an existing + sign-in configuration and must not be replaced. Ask the Workday and + identity administrators to decide whether a separate Microsoft Entra row + can be added safely for this environment. + - **No enabled SAML row** - continue with the Microsoft Entra setup below. + After the new row is saved and enabled, ask the administrator to copy its + exact **Issuer** value. + - **I'm not sure** - explain that Microsoft Entra issuers commonly contain + `login.microsoftonline.com` or `sts.windows.net`, Okta issuers commonly + contain `okta.com`, and Ping issuers commonly contain `pingone.com`, + `pingidentity.com`, or an organization-specific Ping host. If the value + is still unclear, stop and ask the identity administrator rather than + guessing. + + After the supported Microsoft Entra row is identified or created, verify + its Issuer and Service Provider ID. +2. **Install the Entra signing certificate.** In Entra, open **Enterprise + applications -> the exact Workday application -> Single sign-on -> SAML + Signing Certificate** and download **Certificate (Base64)**. In Workday, run + **Create x509 Public Key**, paste that public certificate, give it a + customer-chosen recognizable name, and save it. Return to the enabled + Microsoft Entra row and select that key in its **X509 Certificate** field. + + Use `certificateSelectionQuestion` and `certificateValidityQuestion` from + the packet to populate the corresponding fields in the consolidated form. + Do not present them separately. Never suggest, prefill, or ask the administrator to confirm + a guessed certificate name such as “Microsoft Azure Federated SSO + Certificate.” + + Handle the answer as follows: + + - **The new certificate created from the Entra Base64 file** - record + `certificateSelectionOutcome` as + `entra-signing-certificate-selected`. If both displayed dates exactly + match the verified Entra dates shown in the form, record + `certificateValidityOutcome` as + `matches-verified-entra-certificate`. The customer-created certificate + display name is optional support context, not a completion gate. + - **A different existing Workday certificate** - stop. Do not replace or + reuse it until the Workday and identity administrators confirm it is the + same active Entra signing certificate. + - **No certificate is selected** - ask the administrator to select the new + Workday public key created from the Entra Base64 certificate, then return + to this question. + - **I'm not sure** - direct the administrator to the enabled Microsoft Entra + SAML row's **X509 Certificate** field. If they still cannot identify the + selected key, stop rather than guessing. + + Never collect the certificate body in chat. +3. **Configure tenant security.** Return to **Edit Tenant Setup - Security**. + Enable **OAuth 2.0 Clients Enabled** and **SAML**. In SAML Setup, set the + exact Service Provider ID to + `http://www.workday.com/{workdayTenant}`. Do not use + `api://{entraAppId}` in that Workday field. +4. **Register the employee API client.** Run **Register API Client**. Set + **Client Grant Type** to **SAML Bearer**. Under **Scope (Functional Areas)**, + select **Core Payroll**, **Organizations and Roles**, **Staffing**, and + **Time Off and Leave**. Set **Include Workday Owned Scope** to **Yes**, then + save. +5. **Capture non-secret connection values.** Open **View API Client** for that + client and record the OAuth client ID and token endpoint. Record the tenant’s + REST base URL ending exactly at `/ccx/api` and its SOAP service base URL. + Do not return a client secret, password, token, cookie, or certificate body. +6. **Verify employee authentication.** Open **Manage Authentication Policies** + for the employee environment. Confirm an active rule allows **SAML** for the + intended employees. Do not replace administrator safeguards, existing + network restrictions, or route this user-delegated setup through an + Integration System User rule. +7. **Confirm network readiness.** Give the REST and SOAP host names from step 5 + to the network administrator when organizational egress filtering applies. + Record either that both hosts are allowed or that no customer-managed + firewall change is required. Do not wait until final employee validation to + discover a known allowlist requirement. + +Collect exactly one response form using one structured +`vscode_askQuestions` call. Do not collapse these fields into a multiline text +box, ask the administrator to edit a prose template, or ask for these values +as a sequence of separate chat questions: + +- confirmation that the enabled issuer exactly matches the displayed verified + Entra issuer, or the exact different Issuer value; +- enabled Service Provider ID; +- confirmation that the Entra-derived certificate is selected; +- confirmation that its displayed validity dates exactly match the verified + Entra dates; +- optional Workday certificate display name; +- Workday OAuth client ID; +- OAuth token URL; +- REST base URL ending at `/ccx/api`; +- SOAP base URL; +- authentication-policy outcome; +- network-readiness outcome. + +Use this exact form, substituting the packet's expected issuer, Service +Provider ID, and verified certificate dates: ```json [ { - "header": "Workday administrator", - "question": "Have you looped in a Workday admin to perform the Workday side of configuration?", + "header": "Identity provider", + "question": "Which sign-in provider does the enabled Workday SAML row use?", "options": [ - { "label": "Yes, I have", "recommended": true }, - { "label": "No, I have not" } + { "label": "Microsoft Entra ID", "description": "Issuer commonly contains login.microsoftonline.com or sts.windows.net" }, + { "label": "Okta", "description": "Issuer commonly contains okta.com" }, + { "label": "Ping Identity", "description": "Issuer commonly contains pingone.com, pingidentity.com, or an organization-specific Ping host" }, + { "label": "Another sign-in provider" }, + { "label": "No enabled SAML row" }, + { "label": "I'm not sure" } ], "allowFreeformInput": false - } -] -``` - -**If the user chose "Yes, I have":** -- Set `GATE_RESULT = "pass"` and - `GATE_EVIDENCE = { "method": "attested", "outcome": "pass", "provenance": "user-attestation", "note": "user confirmed a Workday administrator is available to perform DA3.1–DA3.4 with them", "capturedAt": "" }`. -- Carry `GATE_EVIDENCE` forward (recorded when the DA3 rows are updated), and - continue to DA3.0b. - -**If the user chose "No, I have not":** - -**Message:** - -No problem — these steps have to be done with a Workday administrator. Line one up -(or ask whoever holds that role to join you), then come back and run this skill -again. - -**End message.** - -- Set `GATE_RESULT = "stop"` and **halt** — do not continue. - -> An attested `"pass"` records that a Workday administrator was **confirmed -> available**, not directory-proven. It satisfies the *gate*, but it does **not** -> by itself complete any DA3 row — each row still needs its own captured evidence -> and acknowledgement per -> [`shared/checklist-updater.md`](shared/checklist-updater.md). - ---- - -## DA3.0b — Single-tenant SAML pre-gate *(do this before any tenant change)* - -Workday supports exactly **one** active Entra-tenant SAML federation at a time. -Pointing a second Entra tenant at the same Workday tenant silently breaks the -first. Before changing anything, identify and record the **current active SAML -IdP** so a later step never overwrites an unrelated federation. - -**Message:** - -Before I change any Workday security settings, I need to check the tenant's -current SAML sign-on. In Workday, search for and open the **Edit Tenant Setup – -Security** task and find the **SAML Setup** section. Tell me, for the currently -enabled Identity Provider row: the **Issuer** (or IdP name), the **Service -Provider ID**, and the **x509 Certificate** name plus its **Valid From** / -**Valid To** dates (Workday shows no thumbprint). If there is -no active SAML IdP yet, just say **none**. - -**End message.** - -Wait for the user's answer, then record it as the pre-gate evidence -(`SAML_ISSUER`, `SAML_SP_ID`, `SAML_CERT`). - -- **If an IdP is already active AND it is not the Entra app DA-2 provisioned** - (the Issuer / Service Provider ID does not match this tenant's `appIdUri` / - `entraAppId` from `.local/connect/workday-da/config.json`): - - **Message:** - - This Workday tenant already has a **different** SAML identity provider active. - Workday only allows one at a time, and replacing it would break the existing - sign-on for its users. I'm stopping here so nothing is overwritten — please - confirm with whoever owns that federation before continuing, then come back. - - **End message.** - - **Halt.** Do not proceed. - -- **Otherwise** (no active IdP, or the active one is this tenant's own Entra app) - → continue. - ---- - -## DA3.0c — Upload the X.509 signing certificate & confirm certificate parity *(completes DA3.4)* - -Create the Workday **X.509 Public Key** from the Entra signing certificate DA-2 -activated, then confirm the certificate matches — a mismatch means the wrong -certificate was uploaded and SSO will fail. - -**Message:** - -In Entra, open **Enterprise applications → your Workday app → Single sign-on → -SAML Signing Certificate**, and download the **Certificate (Base64)**. Then in -Workday, run the **Create x509 Public Key** task and paste that certificate. Type -**done** when the key is created. - -**End message.** - -Wait for the user, then verify the certificate parity against the certificate -DA-2 activated in Entra. - -**Message:** - -Now I'll compare the certificate you uploaded in Workday against the one activated -in Entra to make sure they match. - -**End message.** - -**Verify (WD-CONN-102):** - -``` -python scripts/flightcheck/cli.py --checkpoint WD-CONN-102 --connect-config ".local/connect/workday-da/config.json" -``` - -`WD-CONN-102` reports the Entra-side certificate health and returns `MANUAL` for -the Workday-side comparison (the Workday cert field is not API-reachable). - -If FlightCheck's Microsoft Graph token has expired or the cache was cleared, this -command **opens a browser window for a Graph sign-in** before it returns. That is -expected — do **not** cancel or re-run it while it pauses; it is blocked on the -sign-in, not hung, and continues once you complete it. - -**Show the `WD-CONN-102` result in chat first.** It always returns `MANUAL` for -the Workday-side comparison, so render it per -[`shared/checklist-updater.md`](shared/checklist-updater.md) §U.0–U.0a — the -result table **and** its full verification steps — **before** the -certificate-parity question below. Never ask the user to attest to a comparison -they have not been shown. - -**Message:** - -Workday doesn't display a certificate thumbprint, so we compare another way. -Confirm you uploaded the exact **Certificate (Base64)** from your Workday app in -Entra (Single sign-on → SAML Signing Certificate), and that the **Valid From** / -**Valid To** dates shown on the Workday x509 Public Key match that Entra -certificate's validity dates. Do they match? - -**End message.** - -Use the `vscode_askQuestions` tool: - -```json -[ + }, { - "header": "Certificate parity", - "question": "Does the uploaded Workday certificate (and its Valid From / Valid To dates) match the Entra signing certificate?", + "header": "Issuer", + "question": "Does the enabled Microsoft Entra SAML row's Issuer exactly match {EXPECTED_ENTRA_ISSUER}?", "options": [ - { "label": "Yes, they match", "recommended": true }, - { "label": "No / not sure" } + { "label": "Yes, it matches exactly" }, + { "label": "No, the displayed Issuer is different" }, + { "label": "I'm not sure" } ], "allowFreeformInput": false - } -] -``` - -- **"Yes, they match"** → update **DA3.4** via - [`shared/checklist-updater.md`](shared/checklist-updater.md) with - `STEP_ID="DA3.4"`, `GATE="manual"`, `CHECKPOINT_RESULT="MANUAL"`, `ACK=true`, - `ROW_EVIDENCE` recording the compared thumbprints and confirmation, and the - carried `GATE_EVIDENCE`. -- **"No / not sure"** → leave DA3.4 `in-progress`; have the user re-upload the - correct Base64 certificate from Entra and re-check. Do not continue to DA3.0d - with a mismatched cert. - ---- - -## DA3.0d — Edit Tenant Setup – Security - -Configure the tenant's security so OAuth and SAML sign-on work. This is captured -as part of the `WD-TENANT-001` attestation (verified at the end of DA3.3). - -**Message:** - -In Workday, run **Edit Tenant Setup – Security**. Set the **Redirect URL** for -the sign-on, and enable both **OAuth 2.0 Clients Enabled** and **SAML**. In the -SAML Setup, confirm the **Service Provider ID** matches your Entra app's -**Identifier (Entity ID)** — they must be identical. Type **done** when saved. - -**End message.** - -Wait for the user, then continue to DA3.1. - ---- - -## DA3.1 + DA3.2 — Register the API client & capture the connection fields - -Register the Workday API client, then capture the connection identifiers the -Workday extension package's connection form needs. - -**Message:** - -In Workday, run the **Register API Client** task with **Client Grant Type = SAML -******. Under **Scope (Functional Areas)** select **Core Payroll**, -**Organizations and Roles**, **Staffing**, and **Time Off and Leave**, and set -**Include Workday Owned Scope = Yes** (this is required for the REST -`/workers/me` call). Save it, then open **View API Client** for the client you -just created. Type **done** when you're on the View API Client screen. - -**End message.** - -**Message:** - -This setup uses each signed-in employee's Workday identity. It does **not** use -an Integration System User, a RaaS report, or an Integration System Security -Group. The functional areas above define which Workday APIs the client can call; -the employee's existing Workday security determines which employee data those -calls may return. There is no separate domain-to-integration-security-group -mapping step in this setup. - -**End message.** - -Wait for the user. Then **capture and validate the connection fields** using the -shared [`shared/connection-fields.md`](shared/connection-fields.md) (sections -C.1–C.6), passing whatever is already known from -`.local/connect/workday-da/config.json`: - -- `OAUTH_CLIENT_ID`, `TOKEN_ENDPOINT` — from the **View API Client** screen. -- `WD_TENANT`, `WD_BASE_URL`, `WD_TOKEN_HOST` — read from - `.local/connect/workday-da/config.json` if already captured, otherwise gathered - here from the Workday tenant URL (the token endpoint on the View API Client - screen has the form `https://{WD_TOKEN_HOST}/ccx/oauth2/{WD_TENANT}/token`). -- `APP_ID_URI` — the Entra `appIdUri` from DA-2. - -`shared/connection-fields.md` derives the **SOAP base URL** from the Workday web -host (with a user-prompt fallback), trims the **REST base URL** to `/api`, and -persists `oauthClientId`, `tokenEndpoint`, `soapBaseUrl`, `restBaseUrl`, and -`appIdUri` back to `.local/connect/workday-da/config.json` (round-trip merge — -never drop fields owned by other steps). - -**Message:** - -Now I'll confirm the Workday API client you registered was captured correctly. - -**End message.** - -**Verify (WD-API-CLIENT-001):** - -``` -python scripts/flightcheck/cli.py --checkpoint WD-API-CLIENT-001 --connect-config ".local/connect/workday-da/config.json" -``` - -This echoes the captured `oauthClientId` / `tokenEndpoint` and restates the -registration facts to confirm. `WD-API-CLIENT-001` always returns `MANUAL`, so -render its result in chat per -[`shared/checklist-updater.md`](shared/checklist-updater.md) §U.0–U.0a — the -result table **and** its full verification steps — **before** you ask the user -to acknowledge the row. Then: - -- Confirm the row via [`shared/checklist-updater.md`](shared/checklist-updater.md) - with `STEP_ID="DA3.1"`, `GATE="attest"`, `CHECKPOINT_RESULT="MANUAL"`, - `ACK=true` once the user acknowledges the client is registered correctly, - plus `ROW_EVIDENCE` recording the confirmed registration facts and the - carried `GATE_EVIDENCE`. -- Then update **DA3.2** (connection fields captured) via - [`shared/checklist-updater.md`](shared/checklist-updater.md) with - `STEP_ID="DA3.2"`, `GATE="attest"`, `CHECKPOINT_RESULT="MANUAL"`, `ACK=true` — - using the persisted fields as `ROW_EVIDENCE` and carrying `GATE_EVIDENCE`. - -If the user says the client is wrong or fields are missing, leave DA3.1/DA3.2 -`in-progress` and re-capture before continuing. - ---- - -## DA3.3 — Verify the signed-in employee authentication policy - -Verify that the Workday environment permits SAML authentication for the intended -employee population. Workday tenants vary in how authentication policies are -organized, and the policy screens may not expose an OAuth-client condition. -Never invent one, never route this signed-in employee setup through an ISU rule, -and never enable a disabled policy only to satisfy this checklist. - -**Message:** - -In Workday, open **Manage Authentication Policies** for the environment your -employees use. With your Workday administrator, verify that an active rule allows -**SAML** for the intended employee population. - -- Do not use an ISU or integration-system security-group rule for this setup. -- Do not look for an OAuth-client restriction if this tenant's policy screen - does not provide one. -- Preserve administrator access, employee coverage, and existing network/IP - restrictions. -- If the current active policy already allows employee SAML sign-in, no change - is needed. -- If a change is required, review all pending authentication-policy changes - before activating them. - -**End message.** - -Use the `vscode_askQuestions` tool: - -```json -[ + }, + { + "header": "Different issuer", + "question": "If the Issuer is different, enter the exact displayed value. Otherwise leave this blank." + }, + { + "header": "Service Provider ID", + "question": "Enter the enabled Service Provider ID. Expected value: {EXPECTED_SERVICE_PROVIDER_ID}" + }, + { + "header": "Certificate", + "question": "Which certificate is selected on the enabled Microsoft Entra SAML row in Workday?", + "options": [ + { "label": "The new certificate created from the Entra Base64 file" }, + { "label": "A different existing Workday certificate" }, + { "label": "No certificate is selected" }, + { "label": "I'm not sure" } + ], + "allowFreeformInput": false + }, + { + "header": "Certificate dates", + "question": "Do the selected Workday certificate dates exactly match {CERTIFICATE_VALID_FROM} through {CERTIFICATE_VALID_TO}?", + "options": [ + { "label": "Yes, both dates match exactly" }, + { "label": "No, one or both dates are different" }, + { "label": "I'm not sure" } + ], + "allowFreeformInput": false + }, + { + "header": "Certificate name", + "question": "Optional: enter the Workday certificate display name, or leave this blank." + }, + { + "header": "OAuth client ID", + "question": "Enter the non-secret OAuth client ID shown by Workday." + }, + { + "header": "OAuth token URL", + "question": "Enter the OAuth token URL shown by Workday." + }, + { + "header": "REST base URL", + "question": "Enter the Workday REST base URL ending at /ccx/api." + }, { - "header": "Employee SAML policy", - "question": "What did the Workday administrator confirm for the employee authentication policy?", + "header": "SOAP base URL", + "question": "Enter the Workday SOAP service base URL." + }, + { + "header": "Authentication policy", + "question": "What did the administrator verify for the employee authentication policy?", + "options": [ + { "label": "An existing active policy allows SAML" }, + { "label": "A reviewed policy was activated" }, + { "label": "I'm not sure" } + ], + "allowFreeformInput": false + }, + { + "header": "Network readiness", + "question": "What did the administrator verify for the Workday service hosts?", "options": [ - { - "label": "Existing active policy already allows employee SAML", - "description": "No policy change or activation was needed", - "recommended": true - }, - { - "label": "Reviewed policy change was activated", - "description": "The admin preserved employee/admin access and existing network restrictions" - }, - { - "label": "Not confirmed yet", - "description": "Keep this step in progress" - } + { "label": "Both Workday hosts are allowed" }, + { "label": "No customer-managed firewall change is required" }, + { "label": "I'm not sure" } ], "allowFreeformInput": false } ] ``` -For either confirmed option, capture the selected policy name or rule and whether -the existing configuration was reused or a reviewed change was activated. For -**Not confirmed yet**, leave DA3.3 `in-progress` and stop without blocking or -resetting completed rows. - -Then verify the whole tenant configuration. - -**Message:** - -Now I'll confirm your Workday tenant security and authentication-policy settings -are in place. - -**End message.** - -**Verify (WD-TENANT-001):** - +Leave every field and option initially unset. Do not add `recommended`, +`default`, suggested-answer wording, or any equivalent preselection. The +expected values in the question text are comparison references, not answers. + +Map the submitted fields to the controller response object: + +- **Microsoft Entra ID** plus **Yes, it matches exactly** -> + `identityProviderOutcome: verified-entra-issuer`; +- a different Issuer -> `activeIdentityProviderIssuer`, then stop for identity + administrator review instead of submitting successful evidence; +- **The new certificate created from the Entra Base64 file** -> + `certificateSelectionOutcome: entra-signing-certificate-selected`; +- matching certificate dates -> + `certificateValidityOutcome: matches-verified-entra-certificate`; +- the optional display name -> `certificateName`; +- the six entered connection/policy fields -> their corresponding controller + keys; +- existing active policy -> `existing-active-policy`; +- reviewed and activated policy -> `reviewed-policy-activated`; +- both hosts allowed -> `confirmed-hosts-allowed`; +- no firewall change required -> + `no-customer-firewall-change-required`. + +Any unsupported provider, mismatch, missing certificate, date mismatch, or +**I'm not sure** answer is a remediation outcome, not successful evidence. +Show the affected remediation step and keep the phase waiting. + +If the administrator omits a required value or replies only with wording such +as "done", "all good", "continue", or "proceed", do not move to another field, +search workspace files, inspect environment variables, or infer the missing +evidence. Reopen the same structured form with only the missing or invalid +fields; never replace it with a free-text request for several numbered answers. +Preserve progress and stop until the structured form is complete. + +Never collect a secret, password, token, cookie, certificate body, or private +key. Write the response directly to +`.local/connect/workday-da/workday-admin-response.json` using a structured +file-write tool; never interpolate administrator-entered values into a +generated shell command. Pass the response once: + +```powershell +python scripts/workday_connect.py record-workday-admin --response-file ".local\connect\workday-da\workday-admin-response.json" ``` -python scripts/flightcheck/cli.py --checkpoint WD-TENANT-001 --connect-config ".local/connect/workday-da/config.json" -``` - -This echoes the captured `tenant` / `restBaseUrl` / `soapBaseUrl` / `appIdUri` -and restates the Tenant Setup – Security and signed-in employee -authentication-policy facts to confirm. -`WD-TENANT-001` always returns `MANUAL`, so render its result in chat per -[`shared/checklist-updater.md`](shared/checklist-updater.md) §U.0–U.0a — the -result table **and** its full verification steps — **before** you ask the user to -confirm. Then update **DA3.3** via -[`shared/checklist-updater.md`](shared/checklist-updater.md) with -`STEP_ID="DA3.3"`, `GATE="attest"`, `CHECKPOINT_RESULT="MANUAL"`, `ACK=true` once -the user confirms one of the two supported outcomes above. Pass that selected -policy/rule and whether it was reused or activated as `ROW_EVIDENCE`, together -with the carried `GATE_EVIDENCE`. -The **functional** proof of all of this comes downstream, when the Workday -extension package's Dataverse connection authenticates successfully — not -from any standalone Workday call here. Verifying that connection end-to-end is -outside this skill's current scope; see DA-4 for what is and isn't checked. +Use these exact outcome values: ---- +- `authenticationPolicyOutcome`: `existing-active-policy` or + `reviewed-policy-activated`; +- `networkReadinessOutcome`: `confirmed-hosts-allowed` or + `no-customer-firewall-change-required`. -## Done +For example: -When DA3.1–DA3.4 are all `done`, return control to the orchestrator (`SKILL.md`) -to resume at the next unverified row. - -**Message:** - -Your Workday tenant is configured — the signing certificate, Tenant Security, the -API client, and signed-in employee authentication policy are all set. Next I'll -review your Workday connection and let you know what's left. +```json +{ + "identityProviderOutcome": "verified-entra-issuer", + "enabledServiceProviderId": "http://www.workday.com/{workdayTenant}", + "certificateSelectionOutcome": "entra-signing-certificate-selected", + "certificateValidityOutcome": "matches-verified-entra-certificate", + "oauthClientId": "{non-secret Workday OAuth client ID}", + "oauthTokenUrl": "https://{workday-host}/ccx/oauth2/{tenant}/token", + "restBaseUrl": "https://{workday-host}/ccx/api", + "soapBaseUrl": "https://{workday-host}/ccx/service/{tenant}", + "authenticationPolicyOutcome": "existing-active-policy", + "networkReadinessOutcome": "confirmed-hosts-allowed" +} +``` -**End message.** +The controller validates the verified Entra issuer, Service Provider ID, HTTPS +endpoints, exact REST base suffix, certificate selection, and certificate-date +match before recording the non-secret identifiers, endpoints, and evidence +atomically. It captures the completed Entra and Workday phases as a tenant +foundation that can be reused for another environment or ESS HR agent. If the +administrator is not available, stop here; rerun +`workday-admin-packet` later without losing deployment progress. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md index 8722bcbff..49eb9ddc0 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md @@ -1,143 +1,104 @@ -# DA-1 — Install the Workday Extension Package +# Phase 1 - Preflight -Every **Message** block is the exact text to show the user. Copy it verbatim. Do -not rephrase, add commentary, or tell the user what tools you are calling or what -files you are reading. +Explain only credential stores that may prompt during this phase: -This step completes **DA1.1** on the Workday connect checklist. It confirms the -ESS HR agent is available, then installs its Workday package. The router must -stop DA IT agents before this file is read. +- Dataverse browser sign-in verifies the exact environment and maker account. +- PAC may use device-code sign-in to inspect or install the Workday package. + PAC is a separate Microsoft credential store, so this can be one additional + sign-in. The controller pins and verifies the resulting PAC account. ---- +Run read-only preflight discovery: -## P1.0 — Check for the DA base agent, and install the extension if it's missing - -Resolve the target environment automatically: - -1. Use `.local/config.json` `dataverseEndpoint` when present (legacy - Dataverse-backed workspace). -2. Otherwise read `.local/connect/workday-da/config.json` - `sidecarDataverseEndpoint`. -3. If neither exists, show the environments available to the - signed-in account and ask the maker to choose one. Do not ask them to type or - copy a URL when a selectable environment is available. Persist the selected - Dataverse URL as `sidecarDataverseEndpoint`. - -Call the resolved value `WORKDAY_DATAVERSE_URL`. Never copy it into -`.local/config.json`; that file's native `powerPlatformApiEndpoint` remains the -agent identity boundary. If `.local/connect/workday-da/config.json` does not -yet exist, create it as an empty JSON object before the first checkpoint; if it -exists, preserve all current fields. - -Resolve the package flavor from the active agent schema: - -- `gptagent_copilotforemployeeselfservicehr` → `runtime` -- `msdyn_copilotforemployeeselfservicedahr` → `legacy-da` +```powershell +python scripts/workday_connect.py preflight +``` -Call this value `PACKAGE_FLAVOR`. Stop if the active agent does not match one -of these supported HR schemas. +Fresh native-agent setup state identifies the exact environment ID but may not +contain a Dataverse organization URL. The controller first resolves that URL +from setup's cached environment inventory, then asks an existing PAC profile +for the organization whose environment ID exactly matches setup. Do not ask +the maker to re-enter or reselect the environment when either source proves an +exact ID match. -Run the checkpoint that reports both facts at once — whether a DA base agent -exists, and whether Workday is already installed against it: +When the controller reports that no Dataverse URL can be resolved, explain +that refreshing Power Platform environment inventory uses its own Microsoft +sign-in, then run: +```powershell +python scripts/list_environments.py ``` -python scripts/flightcheck/cli.py --checkpoint WD-DA-PKG-001 --connect-config ".local/connect/workday-da/config.json" -``` - -Read the checkpoint result from `workspace/flightcheck/results.json`. The only -supported target is `hr`. An IT base agent or IT Workday package in the same -environment is outside this lifecycle and must not affect DA1.1. -- **`PASSED`** → the required HR Workday extension package is already installed. - Show the result, record `verticals: ["hr"]` into - `.local/connect/workday-da/config.json`, and go to **record DA1.1** below. -- **`FAILED`** with "No ESS DA HR agent was found in this environment" or - "An ESS DA IT agent is installed, but no ESS DA HR agent was found" → the - supported HR base agent isn't installed. Stop here — this skill doesn't - install the base agent. +Match the inventory result to `.local/config.json` `environmentId`. When there +is exactly one matching environment with a Dataverse URL, rerun preflight with +that URL automatically. Do not show a selection list or ask the maker to +choose again. + +If the recorded environment ID is absent or has no linked Dataverse URL, stop +and explain the exact mismatch. Ask for an exact Dataverse URL only when the +maker confirms it belongs to the already recorded setup environment; never +silently select a different environment by display name. Once an exact URL is +known, direct Dataverse verification is authoritative even if a later +inventory call omits it. + +Use `--maker-username` only to pin an intended maker account or resolve account +ambiguity. On resume, the controller reuses the previously verified maker +identity automatically. + +The command verifies the target and checks whether the package already exists. +When the package is already installed, it completes the phase without an +installation approval. + +When the returned result contains `requiresApproval: true`, show only its +`approvalSummary`. Then use `vscode_askQuestions`: + +```json +[ + { + "header": "Install Workday package", + "question": "Install the supported Workday package in the verified Power Platform environment?", + "options": [ + { "label": "Install" }, + { "label": "Not now" } + ], + "allowFreeformInput": false + } +] +``` - **Message:** +Leave the selection unset until the maker explicitly chooses. - I don't see an Employee Self-Service HR agent installed in this environment - yet. Run `/setup` first to install it, then come back and run - `/connect workday` again. +If the maker selects **Not now**, leave Preflight waiting and return to the +lifecycle runner. Do not call either approval or apply. - **End message.** +If the maker selects **Install**, write the returned `plan` object directly to +`.local/connect/workday-da/preflight-plan.json` using a structured file-write +tool. Do not serialize it into a generated shell command. Then run: - Halt this skill entirely — do not proceed to DA-2 or DA-3. +```powershell +python scripts/workday_connect.py preflight-approve --plan-file ".local\connect\workday-da\preflight-plan.json" +python scripts/workday_connect.py preflight --install-plan-hash "{PLAN_HASH}" +``` -- **`FAILED`** with "The Workday package required by the ESS HR agent is not - installed" → the HR base agent is present but Workday isn't installed yet. - Continue to **P1.1**. -- Any other **`FAILED`** result → show the result and stop. Do not guess - whether installation is safe from an unrecognized failure reason. -- **`WARNING` / `SKIPPED`** (Dataverse verification could not run, e.g. - authentication, permissions, endpoint initialization, or a transient error) - → show the result verbatim, keep DA1.1 `in-progress`, and stop; ask the user - to resolve the underlying issue and re-run this step. Never attempt package - installation from an inconclusive result. +The second `preflight` call rediscovers the target and rejects the operation if +the approved package, environment, agent, or maker changed. ---- +The completed phase: -## P1.1 — Attempt an automated install +- verifies the selected setup-complete native ESS HR agent; +- chooses the architecture-specific Workday package; +- verifies the exact Dataverse URL directly rather than relying on inventory + visibility; +- verifies the authenticated account and Entra tenant; +- detects the package or installs it only after exact-plan approval through + PAC; and +- rereads Dataverse to prove the package is installed. -``` -python scripts/install_workday_da_extension.py --url "{WORKDAY_DATAVERSE_URL}" --vertical "hr" --package-flavor "{PACKAGE_FLAVOR}" --ring "{RING}" -``` +This is a controller-owned automated change. It is accurate to say the package +was installed only when PAC succeeded and the Dataverse reread found the +expected solution. The maker still performs any browser or device-code sign-in +and chooses the environment when discovery cannot resolve one exact target. -Read `RING` from canonical setup state `environment.ring`; use `prod` only for -legacy state with no recorded ring. Run the command once. The installer selects -or creates a PAC profile for that ring and PAC polls AppSource installation -internally. - -Parse the script's JSON marker line: - -- **`INSTALLED_WORKDAY_DA_EXTENSION_JSON:`** → the HR package installed (or was - already installed and the script confirmed it). Re-run -`--checkpoint WD-DA-PKG-001` with the same `--connect-config`; proceed only -when it reports `PASSED`. -- **`WORKDAY_PACKAGE_INSTALL_FAILED_JSON:`** → show its concise `error` value - and stop with DA1.1 `in-progress`. Do not claim the package needs a manual - AppSource installation. PAC's output is the source of truth: - - If PAC CLI is missing, explain that it is the local tool used to install - the Workday package, then ask once whether the maker wants the kit to - install a current-user managed copy. Do not present .NET and PAC as - unexplained product setup steps. If approved, run: - - ```powershell - dotnet tool install --tool-path "$env:LOCALAPPDATA\InternalTools\pac" --interactive --verbosity n --configfile "scripts\managed-pac.nuget.config" Microsoft.PowerApps.CLI.Tool - ``` - - If the tool is already present but needs repair or update, run the same - command with `update` instead of `install`. Then rerun P1.1. - If the managed install reports that a .NET SDK is missing, explain that it - is required only to install the local PAC tool. Obtain approval before - installing it, and resume at DA1.1 afterward; do not restart the Workday - checklist or repeat completed rows. - - If PAC starts device-code authentication, wait for it to finish. - - If multiple profiles exist for the required ring, ask the maker to select - the intended profile with `pac auth select`, then retry. - - For permission or package-availability errors, show PAC's output and ask - the maker to correct that exact issue before retrying. - ---- - -## Record DA1.1 - -When `WD-DA-PKG-001` is `PASSED`: - -1. Merge `verticals: ["hr"]` and `vertical: "hr"` into - `.local/connect/workday-da/config.json` (round-trip merge — never drop other - keys). Remove any stale `it` entry written by a pre-release version. -2. Call [`shared/checklist-updater.md`](./shared/checklist-updater.md) with - `STEP_ID = "DA1.1"`, `CHECKPOINT_RESULT = "PASSED"`, `GATE = "prog"`. - -**Message:** - -The Workday extension package is installed for the **ESS HR Agent**. - -**End message.** - -Return to the DA orchestrator (`src/skills/setup/workday-da/SKILL.md`, -**Start**) to resume at the next unverified row. +On failure, show the controller's concise error and preserve its blocker. Do +not replace a precise PAC, authentication, or package error with a generic +manual-install instruction. On success, return to `SKILL.md`. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md index 7c4bfb75a..1139f8f72 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md @@ -1,696 +1,217 @@ -# DA-2 — Provision the Workday Entra App +# Phase 2 - Microsoft Entra -Role: **App / Cloud Application Administrator** (a **consent-capable** role — -Application Administrator, Cloud Application Administrator, Privileged Role -Administrator, or Global Administrator — is required for the admin-consent step). -This step configures the Microsoft Entra app registration for the Workday SSO -integration so the agent can call Workday on behalf of the signed-in user. It owns -master-checklist rows **DA2.1 through DA2.7**. +This phase discovers one exact Workday SAML application, then guides an +administrator through the required Entra changes and verifies the result. +It requires an Application Administrator or Cloud Application Administrator; +administrator consent may require a consent-capable role. -Depends on DA-1 (the Workday extension package must already be installed). It is -**Entra-only** — it needs Microsoft Graph, not Dataverse. Everything below is -identical to how a CEA Employee Self-Service agent provisions its Workday Entra -app — Entra app registration doesn't differ by agent architecture — only the -persisted state paths differ. +`workday_connect.py` does not create or modify the Entra application. It +validates exact discovery, generates one administrator handoff, validates the +Graph reread, and records evidence. Do not say that the skill will create, +configure, update, grant, or enable an Entra setting. -Every **Message** block is the exact text to show the user. Copy it verbatim. Do -not rephrase, add commentary, or tell the user what tools you are calling or what -files you are reading. **Never** show internal variable names or IDs in chat -(e.g. do not print `WD_ENTRA_APP_OBJECT_ID = ...`). +## Discover before handoff -**Graph-first with a portal fallback on every step.** Each configuration step is -attempted through Microsoft Graph (`az rest` / `az ad`); if a Graph call fails for -a permission or tenant-policy reason, fall back to the portal instructions shown -in that step rather than aborting. - -**Checkpoints this step drives (run each in isolation):** - -| Step | Checkpoint | Gate | -|------|-----------|------| -| DA2.1 | `WD-CONN-102` *(reuse)* — SAML signing-certificate health | prog instantiate; healthy-state MANUAL | -| DA2.2 | `WD-ENTRA-SCOPE-001` — scope exposed + connector pre-authorized + Graph perms | prog | -| DA2.3 | `WD-ENTRA-CONSENT-001` — admin consent granted | prog; escalate to manual | -| DA2.4 | `WD-ASSIGN-001` — enterprise-app user assignment (or not required) | prog | -| DA2.5 | `WD-ENTRA-NAMEID-001` — NameID `claimsMappingPolicy` | prog; degrade to manual | -| DA2.6 | `WD-ENTRA-SIGNOPT-001` — SAML signing option (portal-only) | manual | -| DA2.7 | `WD-CONN-010` *(reuse)* — single-tenant federation alignment | attest | - -Run any one with: - -``` -python scripts/flightcheck/cli.py --checkpoint -``` - -**After every checkpoint run, show its result in chat first.** As soon as a -`--checkpoint` run returns, render the result to the user per -[`shared/checklist-updater.md`](shared/checklist-updater.md) §U.0–U.0a — the -compact result table and, for any `MANUAL` (or `Warning` / `NotConfigured`) row, -its full verification steps — **before** you show any later **Message** or ask any -attestation question. Single-checkpoint runs never open the HTML report, so this -in-chat render is the only place the user sees the manual steps; never ask a user -to attest to steps they have not been shown. - -**Build order (row order now matches it).** Row **DA2.1** — the SSO gallery app — -is the foundation every other row configures, so it is built first and the rows -are numbered in build order (DA2.1 → DA2.7). Each section below is titled by the -checklist row it completes. **On every resume, always re-run DA2.0 (role gate), -DA2.0b (Workday tenant URL) and DA2.1 (ensure the app exists) first — all -idempotent — before working the first incomplete row.** This is required, not -cosmetic: DA2.2–DA2.4 configure the app through the in-memory -`WD_ENTRA_APP_OBJECT_ID` that only DA2.1 populates, so entering directly at a -later row after a resume would leave it undefined. After re-running DA2.0, DA2.0b -and DA2.1, skip any row whose `setupStatus` state is already `done`. - ---- - -## DA2.0 — Role gate (App / Cloud Application Administrator) - -Before querying roles or changing any application, align Azure CLI to the -canonical tenant selected during `/setup`: - -1. Read `environment.tenant_id` from `.local/setup/config.json` and save it as - `SETUP_TENANT_ID`. If it is absent, stop and ask the user to rerun `/setup`; - never infer the tenant from the current Azure CLI session. -2. Read the active Azure CLI tenant: - - ``` - az account show --query tenantId -o tsv - ``` - -3. If it does not exactly equal `SETUP_TENANT_ID`, sign in to the setup tenant: +1. Read the canonical Entra tenant ID and Workday tenant from controller state. + If the Workday tenant is missing, ask for the signed-in Workday URL and + validate its first path segment against + `^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`, then run: + ```powershell + python scripts/workday_connect.py set-workday-tenant --tenant "{tenant}" ``` - az login --tenant "{SETUP_TENANT_ID}" --use-device-code --allow-no-subscriptions +2. Align Azure CLI to the canonical Entra tenant. Explain that this is the + Microsoft Graph/Azure CLI credential store before any sign-in. +3. Query the signed-in user's directory roles by stable role-template ID. + Never authorize from a localized display name and never downgrade a failed + privileged-role query to self-attestation. +4. List application and service-principal identity together. Match only exact, + normalized equality with: + + ```text + http://www.workday.com/{workdayTenant} ``` -4. Re-run `az account show --query tenantId -o tsv`. If it still differs, halt - before running the role query or any `az ad` / Graph mutation. Persist - `tenantId = SETUP_TENANT_ID` to - `.local/connect/workday-da/config.json` only after this verification. - -Apply the shared [`shared/permission-gate.md`](shared/permission-gate.md) before -any Entra work, with: - -- `REQUIRED_ROLE` = `"Application Administrator"` (or Cloud Application - Administrator / Privileged Role Administrator / Global Administrator) -- `GATE_MODE` = `"programmatic"` -- `STEP_ID` = `"DA2.1"` -- `ROLE_QUERY` = a Microsoft Graph directory-role membership check for the - signed-in user: - - ``` - az rest --method GET --url "https://graph.microsoft.com/v1.0/me/memberOf/microsoft.graph.directoryRole?%24select=displayName,roleTemplateId" --query "value[].{displayName:displayName,roleTemplateId:roleTemplateId}" -o json - ``` - - The role is held only when a returned `roleTemplateId` equals one of these - stable built-in role template IDs: - - - Application Administrator: - `9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3` - - Cloud Application Administrator: - `158c047a-c907-4556-b7ef-446551a6b5f7` - - Privileged Role Administrator: - `e8611ab8-c189-46e8-94e1-60213ab1f814` - - Global Administrator: - `62e90394-69f5-4237-9190-012177145e10` - - Do not authorize from `displayName`; it is included only for readable - evidence. Treat an - `Insufficient privileges` / `Authorization_RequestDenied` / forbidden response - as "role not held". If the query errors for an unrelated reason (network, not - signed in), retry once and then stop. Never downgrade this programmatic role - gate to self-attestation. - -If `GATE_RESULT` is `"stop"`, **halt** — do not continue. Otherwise carry -`GATE_EVIDENCE` forward (recorded when the DA2 rows are updated). - ---- - -## DA2.0b — Capture the Workday tenant URL *(enables deterministic app discovery)* - -Knowing the Workday tenant lets DA2.1 pin the **exact** Entra SSO app for this -Workday tenant — the app federated to it carries `http://www.workday.com/{tenant}` -as its SAML identifier — instead of guessing among look-alike "Workday" apps. This -step is **idempotent** and **best-effort**: if the URL isn't handy, skip it and -DA2.1 falls back to an interactive picker. - -**If `tenant` is already set** in `.local/connect/workday-da/config.json`, skip -this step — it was captured here on an earlier run, or by DA-3. - -Otherwise ask for the Workday URL with the `vscode_askQuestions` tool: + Never select by display name alone and never use substring matching. + +Build discovery JSON with `displayName`, application `appId`, application +`objectId`, service-principal `servicePrincipalId`, and `identifierUris`. +Write it directly to `.local/connect/workday-da/entra-discovery.json` using a +structured file-write tool; never interpolate Graph values into a generated +shell command. Then run: + +```powershell +python scripts/workday_connect.py entra-handoff --discovery-file ".local\connect\workday-da\entra-discovery.json" +``` + +If no exact app exists, include `"allowCreate": true` only after the user +chooses to create the Workday gallery app. If multiple apps have the exact +Service Provider ID, stop for administrator remediation. + +Use this current phase guide and the controller contract only. Do not inspect +or reuse the legacy `src/skills/setup/workday/` procedure to fill gaps. + +Show the returned target, Service Provider ID, Entra Application ID URI, +permissions, and administrator actions once as one handoff. Do not add a +separate apply approval: the controller does not perform these portal changes. + +## Reuse an existing tenant foundation + +If `foundationReuse.eligible` is `true`, do not ask an administrator to repeat +the setup. Reread the exact application and service principal through Microsoft +Graph and verify the settings listed below. If every check passes, call +`record-entra`; it restores the matching Workday administrator phase from +tenant-scoped evidence and the lifecycle continues at Connections. + +If a check fails, show only the affected remediation step from the +administrator guide. Do not present the entire guide as mandatory merely +because the user selected another environment, agent, or maker account. + +If `requiresRediscovery` is `true`, ask an Entra administrator to open +**Microsoft Entra admin center -> Enterprise applications -> New application**, +find the official **Workday** gallery application, and create it in the selected +tenant. Stop after creation and repeat exact application discovery. Entra must +assign the application and service-principal IDs before later settings can be +planned safely. + +## Administrator guide for missing or changed settings + +Use the exact application returned by discovery. Never select another +application by display name alone. + +1. **Configure SAML.** Open **Enterprise applications -> the exact Workday + application -> Single sign-on -> SAML**. Set **Identifier (Entity ID)** to + `http://www.workday.com/{workdayTenant}`. Create or activate the signing + certificate required by the tenant. Under **SAML Signing Certificate -> + Edit**, set **Signing Option** to **Sign SAML response and assertion**. +2. **Keep the two identifiers distinct.** The Workday SAML Service Provider ID + is `http://www.workday.com/{workdayTenant}`. The Entra application ID URI is + `api://{entraAppId}`. Never copy one into the other field. +3. **Expose the connector scope.** Open **App registrations -> the exact + Workday application -> Expose an API**. Set the Application ID URI to + `api://{entraAppId}`, add the `user_impersonation` scope, then add authorized + client application `4e4707ca-5f53-46a6-a819-f7765446e6ff` for that scope. +4. **Add delegated permissions.** Open **App registrations -> the exact + Workday application -> API permissions -> Add a permission -> Microsoft + Graph -> Delegated permissions**. Add `openid`, `profile`, and `User.Read`, + then select **Grant admin consent** using a consent-capable administrator. +5. **Configure assignment.** Open **Enterprise applications -> the exact + Workday application -> Users and groups**. If assignment is required, + assign the intended ESS employee security group; prefer a maintained group + over individual users. +6. **Configure NameID.** Open **Enterprise applications -> the exact Workday + application -> Single sign-on -> Attributes & Claims**. Edit **Unique User + Identifier (Name ID)** so the source attribute equals the Workday User Name + used by the tenant, commonly `user.mail` or `user.userPrincipalName`. + +After each change, reread the setting where Microsoft Graph exposes it. A +Graph or Azure CLI command is evidence only when it exits with code 0 and +returns valid JSON. Never record a check as verified from partial stdout after +a nonzero exit. Avoid multi-parameter Graph URLs that Windows command wrappers +can split; request the resource with one query parameter and filter the +returned JSON locally when necessary. + +Do not ask for or use a broad "everything is done" confirmation as evidence. +After the Graph reread, use one structured form for only the settings Graph +cannot prove. Ask for the exact selected SAML signing option and the exact +NameID source attribute. Store each exact non-secret value as `observedValue` +in its check object. A reply such as "done", "all good", "continue", or +"proceed" is not evidence for either field and must not be converted into +administrator attestation. + +Use this exact `vscode_askQuestions` form: ```json [ { - "header": "Workday URL", - "question": "Paste the address-bar URL from your browser while you're signed in to Workday (for example https://impl.workday.com/yourcompany/d/home.htmld). Don't have it handy? Leave it blank and I'll identify the Workday app another way.", - "allowFreeformInput": true + "header": "NameID source", + "question": "What exact source attribute is configured for Unique User Identifier (Name ID) in the Workday application's SAML Attributes & Claims?" + }, + { + "header": "SAML signing", + "question": "What exact SAML Signing Option is selected under SAML Signing Certificate -> Edit?", + "options": [ + { "label": "Sign SAML response and assertion" }, + { "label": "Sign SAML assertion" }, + { "label": "Sign SAML response" } + ], + "allowFreeformInput": false } ] ``` -**If the user provides a URL**, parse it silently (do not echo the parsing): - -- `WD_TENANT` — the first path segment after the host - (`https://impl.workday.com/contoso_impl/d/…` → `contoso_impl`). -- `WD_BASE_URL` — the scheme + host (`https://impl.workday.com`). -- `WD_TOKEN_HOST` — the Workday **services** host derived from the web host: - - `impl.workday.com` → `wd2-impl-services1.workday.com` - - `wd5.myworkday.com` → `wd5-services1.myworkday.com` - - `{dcN}.myworkday.com` → `{dcN}-services1.myworkday.com` - - If the host matches no known pattern, keep `WD_TENANT` / `WD_BASE_URL` and leave - `WD_TOKEN_HOST` for DA-3 to resolve from the API-client token endpoint. - -Before persisting or interpolating the tenant, require: - -- an `https` URL; -- a non-empty first path segment; -- `WD_TENANT` matches - `^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`. - -If any condition fails, reject the value and ask again. Never persist or place -an unvalidated path segment into an `az` command. - -**Persist** to `.local/connect/workday-da/config.json` (merge — keep other keys, -per [`shared/config-schema.md`](shared/config-schema.md)): `tenant` = -`WD_TENANT`, `baseUrl` = `WD_BASE_URL`, and `tokenHost` = `WD_TOKEN_HOST` when -derived. - -**If the user leaves it blank**, record nothing and continue — DA2.1 will -identify the app by display name and ask you to choose if more than one matches. - ---- - -## DA2.1 — Instantiate the Workday SSO gallery app *(foundation — do this first)* - -This creates the single Entra app every other DA2 row configures: the Workday SSO -gallery app, in SAML mode, with a token-signing certificate. It is **idempotent** — -re-running never creates a duplicate. - -**First, check whether the app already exists.** Read -`.local/connect/workday-da/config.json`. If `entraAppObjectId` is set, the app was -already created (by this step or by an earlier `/connect workday` run) — load -`WD_ENTRA_APP_OBJECT_ID` (from `entraAppObjectId`), `WD_ENTRA_APP_ID` (from -`entraAppId`), and re-resolve the service-principal id: - -``` -az ad sp list --filter "appId eq '{WD_ENTRA_APP_ID}'" --query "[0].id" -o tsv -``` - -Save it as `WD_ENTRA_SP_ID` and skip to **verify (WD-CONN-102)** below. - -**If no app is recorded yet**, discover or instantiate it. - -**First, when the Workday tenant is known** — DA2.0b recorded `tenant` in -`.local/connect/workday-da/config.json` — pin the app **deterministically** by its -tenant-scoped SAML identifier. The Entra app federated to this Workday tenant -carries `http://www.workday.com/{tenant}` in its `identifierUris`, so no guessing -is needed: - -``` -$targetIdentifier = "http://www.workday.com/{tenant}" -$normalizedTarget = $targetIdentifier.Trim().TrimEnd('/').ToLowerInvariant() -$apps = az ad app list --all --query "[?identifierUris != null].{name:displayName, appId:appId, id:id, identifierUris:identifierUris}" -o json | ConvertFrom-Json -$matches = @($apps | Where-Object { - @($_.identifierUris | ForEach-Object { - ([string]$_).Trim().TrimEnd('/').ToLowerInvariant() - }) -contains $normalizedTarget -}) -``` - -- Match only normalized **exact equality** as shown above. Never use - `contains()` or substring matching: a tenant such as `microsoft_dpt6` can - coexist with `microsoft_dpt6_okta`, and substring matching selects both. -- **Exactly one match** → this is unambiguously the right app. Save its `appId` → - `WD_ENTRA_APP_ID` and its `id` → `WD_ENTRA_APP_OBJECT_ID`, then resolve the - service-principal id (`az ad sp list --filter "appId eq '{WD_ENTRA_APP_ID}'" - --query "[0].id" -o tsv`) → `WD_ENTRA_SP_ID`. **Do not prompt** — skip to - **Persist** below. -- **More than one match** (rare — two apps carry this tenant's identifier) → use - the interactive picker described below, but list **only these matches**. -- **No match** → no existing app federates to this Workday tenant; fall through to - the by-name search below (which normally leads to creating a fresh app). - -**Otherwise — the tenant is unknown (DA2.0b was skipped) or the tenant pin found -no match** — look for an existing Workday SAML app by name: - -``` -az ad sp list --display-name "Workday" --query "[].{name:displayName, appId:appId, id:id, sso:preferredSingleSignOnMode, replyUrls:replyUrls}" -o json -``` - -- **If one or more Workday SAML apps already exist** — consider only the - returned apps in SAML mode (`sso == "saml"`). Because tenant identity was not - established, never auto-select by display name, even when there is exactly - one match. The app chosen here is pinned to `entraAppId` in config, and every - later step and FlightCheck check (consent, user assignment, NameID) keys off - it — picking the wrong sibling makes a correctly-configured app report - FAILED. Ask the user to choose. Use the `vscode_askQuestions` tool, building - the `options` array **dynamically from the returned SAML apps** — one option - per app, plus a final "Create a new app instead" option: - - ```json - [ - { - "header": "Workday Entra app", - "question": "I found more than one Workday enterprise app in your tenant. Which one should ESS use for Workday single sign-on?", - "options": [ - { "label": "Workday (ESS Copilot)", "description": "SAML · reply URL https://…/ess · provisioned by this kit", "recommended": true }, - { "label": "Create a new app instead", "description": "Provision a fresh \"Workday (ESS Copilot)\" app from the gallery" } - ], - "allowFreeformInput": false - } - ] - ``` - - Emit one option object per returned SAML app. Build a label-to-app mapping - before asking. If a display name is unique, use it as the label. If two or - more apps share a display name, make each **label itself** unique by - appending `· {last 6 characters of appId}`. Set `description` to its SSO - mode plus first reply URL; never include a full app/object GUID. Mark the - option for the kit-provisioned **`Workday (ESS Copilot)`** app as - `recommended` when unambiguous. Then: - - **User picks an existing app** → use the retained label-to-app mapping - (never a display-name search) to map the unique chosen label to that app and - save its `appId` → `WD_ENTRA_APP_ID` and its `id` (the service-principal - id) → `WD_ENTRA_SP_ID`, then resolve its **application** object id — the - `az ad sp list` results carry the *service-principal* id, **not** the app - object id, so query it explicitly: - - ``` - az ad app list --filter "appId eq '{WD_ENTRA_APP_ID}'" --query "[0].id" -o tsv - ``` - - → `WD_ENTRA_APP_OBJECT_ID`. Then **skip to Persist below** so `entraAppId` - is written to config — do **not** jump ahead to verify. - - **User picks "Create a new app instead"** → follow the **If none exists** - instantiate path below. - -- **If none exists**, instantiate from the Workday gallery template. Find the - template id, then instantiate it: - - ``` - az rest --method GET --url "https://graph.microsoft.com/v1.0/applicationTemplates?%24filter=displayName%20eq%20'Workday'" --query "value[0].id" -o tsv - ``` - - ```powershell - $body = @{displayName="Workday (ESS Copilot)"} | ConvertTo-Json - $body | Out-File "$env:TEMP\ess-wd-template.json" -Encoding utf8 - az rest --method POST --url "https://graph.microsoft.com/v1.0/applicationTemplates/{TEMPLATE_ID}/instantiate" --headers "Content-Type=application/json" --body "@$env:TEMP\ess-wd-template.json" - ``` - - From the response, save `application.appId` → `WD_ENTRA_APP_ID`, - `application.id` → `WD_ENTRA_APP_OBJECT_ID`, `servicePrincipal.id` → - `WD_ENTRA_SP_ID`. Then set SAML mode, the identifier/reply URLs, and add **and - activate** a token-signing certificate (set - `preferredSingleSignOnMode = "saml"`, `identifierUris`/`web.redirectUris`, then - `addTokenSigningCertificate` and set `preferredTokenSigningKeyThumbprint` to - activate it — capture the thumbprint + expiry). - - **Portal fallback (permission error on instantiate/PATCH):** - - **Message:** - - I need permission to create and configure enterprise applications in your Entra - tenant, which requires the **Application Administrator** or **Cloud Application - Administrator** role. If you can't get that role, ask your IT admin to create a - Workday enterprise app from the Entra gallery (SAML mode, with a token-signing - certificate) and share its Application ID with you, then tell me and I'll pick - it up from there. - - **End message.** - - Wait for the user, then re-resolve the app with the `az ad sp list` filter above. - -**Persist** the app identity to `.local/connect/workday-da/config.json` (merge — -keep other keys, per [`shared/config-schema.md`](shared/config-schema.md)): - -- `entraAppId` = `WD_ENTRA_APP_ID` -- `entraAppObjectId` = `WD_ENTRA_APP_OBJECT_ID` - -**Verify (WD-CONN-102):** - -This is the **first FlightCheck checkpoint in this skill that uses Microsoft -Graph**. FlightCheck signs in to Graph with its **own** token — separate from the -`az` sign-in used to create the app above and from the earlier environment -sign-in — so the command below **opens a browser window for a Microsoft Graph -sign-in** the first time it runs. Show the message first, then run the command. -Do **not** wait for a chat reply before running it, and do **not** cancel or -re-run the command while it appears to pause: it is **blocked on the browser -sign-in, not hung**, and returns on its own once the sign-in completes. (Later -Graph checkpoints reuse this token and run silently.) - -**Message (do NOT wait for a response — continue immediately):** - -I'm running the first readiness check now — it confirms the single sign-on signing -certificate for your Workday app is present and healthy. A browser window will open -for a Microsoft Graph sign-in — please complete it with the same admin account, and -I'll continue automatically once it finishes. - -**End message.** - -``` -python scripts/flightcheck/cli.py --checkpoint WD-CONN-102 --connect-config ".local/connect/workday-da/config.json" -``` - -`WD-CONN-102` reports the Entra-side signing-certificate health. It returns -`MANUAL` for the healthy state because Workday-side certificate parity is verified -later in DA-3 (row DA3.4). Present the certificate/thumbprint result to the -user, then update **DA2.1** via -[`shared/checklist-updater.md`](shared/checklist-updater.md) with -`STEP_ID="DA2.1"`, `GATE="manual"`, `CHECKPOINT_RESULT` = the checkpoint result, -and `ACK` = the user's explicit confirmation that the certificate was added and -activated. Pass the certificate thumbprint and activation confirmation as -`ROW_EVIDENCE`, and persist the DA2.0 `GATE_EVIDENCE`. - ---- - -## DA2.2 — Expose the API scope, pre-authorize the connector, grant Graph perms - -Configure the app (`WD_ENTRA_APP_OBJECT_ID` from DA2.1) so the Power Platform -Workday connector can obtain an on-behalf-of token. - -1. **Expose the `user_impersonation` scope** — apply - [`connect/azure/app-registration.md`](../../connect/azure/app-registration.md) - **§B.4** against this app, with `APP_OBJECT_ID` = `WD_ENTRA_APP_OBJECT_ID`, - `APP_CLIENT_ID` = `WD_ENTRA_APP_ID`, and `SCOPE_RESOURCE_LABEL` = `Workday`. - That sets the identifier URI `api://{WD_ENTRA_APP_ID}`, generates a - `SCOPE_GUID`, and exposes `user_impersonation` (with a built-in portal - fallback). - -2. **Pre-authorize the Workday connector** — apply the same file's **§B.5** with - `CONNECTOR_APP_ID` = `4e4707ca-5f53-46a6-a819-f7765446e6ff` (the Power Platform - **Workday** connector — never the ServiceNow `c26b24aa`), `APP_OBJECT_ID` = - `WD_ENTRA_APP_OBJECT_ID`, and the `SCOPE_GUID` from step 1. - -3. **Add the Graph delegated permissions** `openid`, `profile`, `User.Read`: - - ```powershell - $body = @{requiredResourceAccess=@(@{ - resourceAppId="00000003-0000-0000-c000-000000000000" - resourceAccess=@( - @{ id="37f7f235-527c-4136-accd-4a02d197296e"; type="Scope" } - @{ id="14dad69e-099b-42c9-810b-d002981feec1"; type="Scope" } - @{ id="e1fe6dd8-ba31-4d61-89e7-88639da4683d"; type="Scope" } - ) - })} | ConvertTo-Json -Depth 6 - $body | Out-File "$env:TEMP\ess-wd-graphperms.json" -Encoding utf8 - az rest --method PATCH --url "https://graph.microsoft.com/v1.0/applications/{WD_ENTRA_APP_OBJECT_ID}" --headers "Content-Type=application/json" --body "@$env:TEMP\ess-wd-graphperms.json" - ``` - - **Portal fallback (PATCH fails):** - - **Message:** - - I couldn't add the Microsoft Graph permissions automatically. You can add them - in the portal: open https://entra.microsoft.com → **App registrations** → your - Workday app → **API permissions** → **Add a permission** → **Microsoft Graph** - → **Delegated permissions** → add **openid**, **profile**, and **User.Read**. - Type **done** when you're finished. - - **End message.** - - Wait for the user, then continue. - -**Persist** to `.local/connect/workday-da/config.json` (merge): `scopeGuid` = -`SCOPE_GUID`, `appIdUri` = `api://{WD_ENTRA_APP_ID}`, `entraSSO` = `true`. - -**Message:** - -Now I'll verify the Workday app exposes its API permission and that the Power -Platform Workday connector is pre-authorized to call it. - -**End message.** - -**Verify (WD-ENTRA-SCOPE-001):** - -``` -python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-SCOPE-001 --connect-config ".local/connect/workday-da/config.json" -``` - -- **`PASSED`** → update **DA2.2** via - [`shared/checklist-updater.md`](shared/checklist-updater.md) with - `STEP_ID="DA2.2"`, `GATE="prog"`, `CHECKPOINT_RESULT="PASSED"`; persist - `GATE_EVIDENCE`. Continue to DA2.3. -- **`FAILED`** → the result names which of the three (scope / pre-authorization / - Graph perms) is missing. Redo that step (Graph or portal fallback), then re-run - the checkpoint. Keep DA2.2 `in-progress` until it passes. -- **`WARNING` / `SKIPPED`** → surface the message; re-run once. A `SKIPPED` means - Graph auth or the app couldn't be resolved — confirm DA2.1 completed first. - ---- - -## DA2.3 — Grant admin consent for the Graph delegated permissions - -Grant tenant-wide admin consent so the on-behalf-of handshake works for all end -users. Attempt it through Graph; if the caller lacks a consent-capable role, -**escalate to manual consent** rather than hard-failing. - -Grant admin consent for the app's service principal (portal is the reliable path; -attempt the portal/`az` grant): - -**Message:** - -Now I need an administrator to grant consent for the Workday app's permissions. -Open https://entra.microsoft.com → **Enterprise applications** → the **Workday -(ESS Copilot)** app → **Permissions** → **Grant admin consent for -<your tenant>**, then approve the prompt. This needs a consent-capable role -(Application Administrator, Cloud Application Administrator, Privileged Role -Administrator, or Global Administrator). Type **done** when the consent is granted. - -**End message.** - -Wait for the user, then verify. - -**Message:** - -Now I'll confirm that admin consent was recorded for the Workday app's -permissions. - -**End message.** - -**Verify (WD-ENTRA-CONSENT-001):** - -``` -python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-CONSENT-001 --connect-config ".local/connect/workday-da/config.json" -``` - -- **`PASSED`** → update **DA2.3** via - [`shared/checklist-updater.md`](shared/checklist-updater.md) with - `STEP_ID="DA2.3"`, `GATE="prog"`, `CHECKPOINT_RESULT="PASSED"`. Continue to - DA2.4. -- **`FAILED`** → consent isn't recorded yet. - - **Message:** - - I don't see admin consent for the Workday app's Graph permissions yet. If you - don't hold a consent-capable role (Application Administrator, Cloud Application - Administrator, Privileged Role Administrator, or Global Administrator), ask an - administrator to run **Grant admin consent** on the Workday enterprise app, then - tell me and I'll re-check. - - **End message.** - - After the user confirms, re-run the checkpoint. Keep DA2.3 `in-progress` - (escalated to manual consent) until it passes. - ---- - -## DA2.4 — Enterprise-app user assignment (or confirm not required) - -Ensure the Workday enterprise app either does not require user assignment, or has -the ESS user security group assigned — otherwise the OBO handshake fails for end -users at first access. - -**Message:** - -Now I'll check whether the Workday enterprise app requires user assignment and, if -so, that the right users are assigned. - -**End message.** - -**Verify (WD-ASSIGN-001):** - -``` -python scripts/flightcheck/cli.py --checkpoint WD-ASSIGN-001 --connect-config ".local/connect/workday-da/config.json" -``` - -- **`PASSED`** (assignment satisfied via a group, or not required) → update - **DA2.4** via [`shared/checklist-updater.md`](shared/checklist-updater.md) with - `STEP_ID="DA2.4"`, `GATE="prog"`, `CHECKPOINT_RESULT="PASSED"`. Continue to - DA2.5. -- **`FAILED`** (assignment required, nothing assigned): - - **Message:** - - The Workday enterprise app requires user assignment but nothing is assigned yet. - Open https://entra.microsoft.com → **Enterprise applications** → the Workday app - → **Users and groups** → **Add user/group**, and assign the ESS user security - group (preferred over individual users). Type **done** when you've assigned it. - - **End message.** - - After the user confirms, re-run the checkpoint. Keep DA2.4 `in-progress` until - it passes. -- **`WARNING`** (assignment not required, or only individual users assigned) → this - is a hardening recommendation, not a blocker. Surface the message; treat a - passing-with-warning as a `prog` pass for the row only if the underlying state is - acceptable to the user, otherwise leave `in-progress` and let them assign a group. - ---- - -## DA2.5 — NameID claim mapping (`claimsMappingPolicy`) - -Map the SAML NameID claim so the value Workday receives equals the Workday User -Name. Attempt the `claimsMappingPolicy` create + assign through Graph; if the -policy route proves brittle, degrade to the manual portal path. - -Create a claimsMappingPolicy that overrides the NameID claim (map to the attribute -that equals the Workday User Name — typically `user.mail` or -`user.userPrincipalName`) and assign it to the Workday service principal -(`WD_ENTRA_SP_ID`) via -`POST /servicePrincipals/{WD_ENTRA_SP_ID}/claimsMappingPolicies/$ref`. - -**Portal fallback (policy create/assign fails, or the tenant blocks custom -policies):** - -**Message:** - -I couldn't set the NameID mapping automatically. You can set it in the portal: -open https://entra.microsoft.com → **Enterprise applications** → the Workday app → -**Single sign-on** → **Attributes & Claims** → edit the **Unique User -Identifier (Name ID)** claim so its source attribute equals the Workday User Name -(commonly **user.mail** or **user.userPrincipalName**). Type **done** when it's -set. - -**End message.** - -Wait for the user, then verify. - -**Message:** - -Now I'll verify the single sign-on user identifier (NameID) is mapped to the value -your Workday tenant expects. - -**End message.** - -**Verify (WD-ENTRA-NAMEID-001):** - -``` -python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-NAMEID-001 --connect-config ".local/connect/workday-da/config.json" -``` - -- **`PASSED`** (a NameID-overriding policy is assigned) → update **DA2.5** via - [`shared/checklist-updater.md`](shared/checklist-updater.md) with - `STEP_ID="DA2.5"`, `GATE="prog"`, `CHECKPOINT_RESULT="PASSED"`. Continue to - DA2.6. -- **`FAILED`** (no override — Entra sends the default UPN) → if your tenant - deliberately relies on the default `userPrincipalName` NameID **and** it already - equals the Workday User Name, this can be attested manually; otherwise create the - mapping (Graph or portal fallback) and re-run. Keep DA2.5 `in-progress` until - resolved. -- **`MANUAL`** (the policy route is unreadable — missing `Policy.Read.All`) → - degrade to a manual portal check: confirm the NameID mapping in the portal - (steps above), then treat DA2.5 as a `manual` row needing explicit - acknowledgement via - [`shared/checklist-updater.md`](shared/checklist-updater.md). - ---- - -## DA2.6 — "Sign SAML response and assertion" signing option *(portal-only)* - -This signing option has no documented Graph property, so it is a **manual portal -gate** — a Workday service provider that validates signatures rejects the -assertion if it is set wrong. +Leave both answers unset. Do not label a factual value as recommended. After +the administrator submits the form, perform the Graph reread immediately; do +not add a separate **Verify now** confirmation. -**Message:** +The administrator performs those changes in the Microsoft Entra admin center. +After the administrator confirms completion, reread the application and +service principal through Microsoft Graph. Do not mark a planned action as +complete from confirmation alone; require the reread to prove it where Graph +exposes the setting. Persist `entraAppId`, +`entraAppObjectId`, `entraAppIdUri`, `workdaySamlEntityId`, `scopeGuid`, and +safe certificate metadata under `identifiers`. Never persist certificate +contents. -Next I'll cover the SAML signing option — this one has to be confirmed in the -portal, because the kit can't read the setting directly. +For a portal-only setting that Graph cannot prove, include its non-secret +administrator confirmation in the `checks` object rather than claiming the +skill changed it. -**End message.** +Write the Graph reread directly to +`.local/connect/workday-da/entra-verification.json` using a structured +file-write tool, then run: -**Verify (WD-ENTRA-SIGNOPT-001):** this checkpoint always returns `MANUAL` (the kit -cannot read the setting). - -``` -python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-SIGNOPT-001 --connect-config ".local/connect/workday-da/config.json" +```powershell +python scripts/workday_connect.py record-entra --verification-file ".local\connect\workday-da\entra-verification.json" ``` -Present the checkpoint's instructions — its remediation now names the customer's -own Entra SAML IdP identifiers (Issuer / Entity ID, SSO / Login URL, SP audience, -and federation-metadata URL, derived from the captured `tenantId` and -`entraAppId`) so they can match them against their Workday SP configuration. If the -earlier certificate check (DA2.1 / `WD-CONN-102`) surfaced a signing-certificate -thumbprint, restate it here too so the customer knows exactly which certificate -Workday must trust. Then: - -**Message:** - -One SAML setting can only be set in the portal. Open https://entra.microsoft.com → -**Enterprise applications** → the Workday app → **Single sign-on** → **SAML -Signing Certificate** → **Edit** → set **Signing Option** to **Sign SAML response -and assertion**, and **Save**. Confirm only this Entra setting here. Workday-side -issuer, service-provider ID, and certificate verification happens in the next -phase, after the Workday-administrator gate. Type **done** when the Entra setting -is saved. - -**End message.** - -Then, per [`shared/checklist-updater.md`](shared/checklist-updater.md)'s manual -rule, ask for an explicit acknowledgement and update **DA2.6** with -`STEP_ID="DA2.6"`, `GATE="manual"`, `CHECKPOINT_RESULT="MANUAL"`, and `ACK` = the -user's explicit confirmation. Pass the displayed signing-option values and -confirmation as `ROW_EVIDENCE`. On `ACK=true` with that evidence the row becomes -`done`; a `MANUAL` result alone never completes it. - ---- - -## DA2.7 — Confirm single-Entra-tenant federation alignment - -Confirm that the selected Workday SAML application belongs to the same Entra -tenant selected during `/setup`. This phase stays Entra-only; it does not ask the -maker to inspect or change Workday before a Workday administrator is available. - -**Message:** - -Now I'll confirm that the selected Workday sign-in application belongs to this -environment's Microsoft Entra tenant. No Workday portal changes are needed in -this phase. +The JSON must contain the Graph-authenticated `tenantId`, exact application and +service-principal identity, both identifier URIs, the `user_impersonation` +scope GUID, safe certificate metadata, and one evidence object for each check: -**End message.** - -**Verify (WD-CONN-010):** - -``` -python scripts/flightcheck/cli.py --checkpoint WD-CONN-010 --connect-config ".local/connect/workday-da/config.json" +```json +{ + "checks": { + "samlMode": { + "outcome": "verified", + "provenance": "microsoft-graph" + }, + "signingCertificate": { + "outcome": "verified", + "provenance": "microsoft-graph" + }, + "connectorPreauthorized": { + "outcome": "verified", + "provenance": "microsoft-graph" + }, + "graphDelegatedPermissions": { + "outcome": "verified", + "provenance": "microsoft-graph" + }, + "adminConsent": { + "outcome": "verified", + "provenance": "microsoft-graph" + }, + "userAssignment": { + "outcome": "verified", + "provenance": "microsoft-graph" + }, + "nameId": { + "outcome": "verified", + "provenance": "microsoft-graph", + "observedValue": "user.userPrincipalName" + }, + "samlSigningOption": { + "outcome": "confirmed", + "provenance": "administrator-attestation", + "observedValue": "Sign SAML response and assertion" + } + } +} ``` -`WD-CONN-010` summarizes the federated Workday SAML app(s) and their entity IDs. -Present the result and scope the confirmation to the Entra application selected -in DA2.1. Do not ask the maker to open Workday or prove the active Workday IdP -here; DA3.0b performs that comparison after the Workday-administrator gate. -Then — this is an **attest** row — ask the user to confirm that the selected app -is the intended Workday tenant application and update **DA2.7** via -[`shared/checklist-updater.md`](shared/checklist-updater.md) with -`STEP_ID="DA2.7"`, `GATE="attest"`, `CHECKPOINT_RESULT` = the checkpoint result, -and `ACK` = the user's explicit confirmation. Pass the selected application -identity and tenant match as `ROW_EVIDENCE`, and persist the DA2.0 -`GATE_EVIDENCE`. - ---- - -## Done - -**Message:** - -Your Workday Entra app is configured and verified — the API scope, connector -authorization, admin consent, user assignment, NameID mapping, and SAML signing -are all in place. Next we'll configure the Workday tenant side (DA-3). - -**End message.** - -Rows DA2.1–DA2.7 are now recorded in the checklist. Return control to the -orchestrator (`SKILL.md`) to resume at the next unverified row. Stop here — the -Workday tenant configuration is a separate step. +Use administrator attestation only for a portal-only setting that Graph cannot +read. The command rejects a different tenant and incomplete or provenance-free +evidence. Resume by rereading available settings and showing only failed +remediation, not by repeating the full guide. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/checklist-updater.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/checklist-updater.md deleted file mode 100644 index 0b81fedf3..000000000 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/checklist-updater.md +++ /dev/null @@ -1,307 +0,0 @@ -# Master-Checklist Updater (DA) - -The single routine every DA Workday setup skill calls to update **its own rows** -in the DA master checklist. Centralizing it means each skill records status the -same way, and the **MANUAL/attestation rule** below is enforced in exactly one -place. - -Forked from the CEA `setup/shared/checklist-updater.md` with DA-scoped state -paths (`.local/setup/workday-da/tasks.md`, `.local/connect/workday-da/config.json`). -The logic is identical — only the persisted files differ — so the two skills can -evolve independently. - -Every **Message** block is the exact text to show the user. Copy it verbatim. Do -not narrate tool calls. - -**Inputs from the calling file:** -- `STEP_ID` — the DA master checklist Step ID to update (e.g. `"DA3.1"`, - `"DA2.4"`). See the canonical rows in the checklist template - `src/skills/setup/workday-da/tasks.md`. A skill updates **only** the Step IDs - it owns. -- `NEW_STATE` — `"in-progress"` \| `"done"` \| `"blocked"`. -- `CHECKPOINT_RESULT` — the flightcheck result for the row's checkpoint, one of - `PASSED` \| `FAILED` \| `ERROR` \| `WARNING` \| `MANUAL` \| - `NOT_CONFIGURED` \| `SKIPPED` \| `null` (null = not run yet). -- `GATE` — the row's gate type: `"prog"` \| `"manual"` \| `"attest"` \| - `"advisory"` (from the DA master checklist row; also recorded in config per - `config-schema.md`). -- `ACK` — *(manual/attest rows only)* `true` once the user has explicitly - acknowledged the step and any evidence has been captured; otherwise `false`. -- `RESULT_SOURCE` — `"flightcheck"` (default) when `CHECKPOINT_RESULT` came - from the current FlightCheck results file, or `"external"` when a - programmatic operation produced its own structured evidence. -- `EXTERNAL_EVIDENCE` — required when `RESULT_SOURCE="external"`; a safe - summary proving the operation's target, outcome, and verification. Never - include credentials, tokens, or raw sensitive output. -- `GATE_EVIDENCE` — optional structured evidence returned by - `permission-gate.md`. Preserve it under the row's `gateEvidence` field; do - not store the object in scalar `verifiedBy`. -- `ROW_EVIDENCE` — required before a `manual`/`attest` row can complete. It is - a safe object with `outcome`, `provenance`, `note`, and `capturedAt`; - `provenance` identifies the source such as `flightcheck`, - `user-acknowledgement`, or `external-operation`. - -**Outputs:** -- The matching checklist item in `.local/setup/workday-da/tasks.md` is updated - in place (checkbox + hidden `status:` field). -- The mirror record `setupStatus["{STEP_ID}"]` in - `.local/connect/workday-da/config.json` is updated (see `config-schema.md`). - ---- - -## Files - -- **Working copy (read/write):** `.local/setup/workday-da/tasks.md` — the - rendered, human-readable checklist. Rendered on first run from the template - `src/skills/setup/workday-da/tasks.md` (the canonical row source). If the - working copy doesn't exist yet, render it from the template before updating. -- **Durable mirror:** `setupStatus` in `.local/connect/workday-da/config.json`. - The tasks file is the view; `setupStatus` is the source of truth a later - step reads to know what's already done. - -Row shape in `tasks.md` (each item in the checklist template -`src/skills/setup/workday-da/tasks.md`): a checkbox line the user sees, followed -by an HTML comment the tooling reads. - -``` -- [ ] **** — - -``` - -- `- [ ]` / `- [x]` is the at-a-glance done marker. -- The hidden `id:` field is the `STEP_ID`; the hidden `status:` field carries the - full four-state value a single checkbox can't express. -- **Never surface a Step ID, checkpoint ID, or the hidden comment to the user** — - they see the checkbox and its description only. - ---- - -## U.0 — Show the checkpoint result to the user (in chat) - -**Timing — render this the instant a checkpoint run returns, and for a -manual/attest row *before* you ask the attestation question.** When a -`python scripts/flightcheck/cli.py --checkpoint ` run just produced -`CHECKPOINT_RESULT`, surface that result to the user — the U.0 table **and** the -U.0a manual steps — before touching any state and before any attestation, so every -checkpoint run has a visible outcome. Single-checkpoint runs never open the HTML -report, so this in-chat render is the only place the user sees the outcome; never -ask a user to attest to manual steps they have not been shown. If you already -rendered this checkpoint's result this pass (per a skill's post-checkpoint display -convention), do not repeat it — proceed to U.1. The U.1–U.3 status update below -runs afterwards (once any attestation is answered) and does **not** re-display the -result. - -- Skip this step when `RESULT_SOURCE="external"`; show `EXTERNAL_EVIDENCE` - using the calling playbook's operation-specific result instead. Also skip - when `CHECKPOINT_RESULT` is `null` (the checkpoint was not run this pass), - or when `workspace/flightcheck/results.json` does not exist. - -Read `workspace/flightcheck/results.json` — the run that led here wrote it. It has: - -```json -{ "results": [ { "checkpoint_id": "...", "description": "...", "status": "..." }, ... ] } -``` - -Render a GitHub-flavoured markdown table in chat, **one row per entry** in -`results`, using `description` verbatim for **Check** and `status` verbatim for -**Status**: - -``` -| Check | Status | -| --- | --- | -| | | -``` - -Rules: -- **Never** include `checkpoint_id`, the Step ID, or any other internal - identifier — there is no ID column; `description` is the only label shown. -- If a `description` or `status` contains a `|`, escape it as `\|`; collapse any - newline to a single space. -- If `results` is empty, render **no** table. -- This table is **in addition to** the row's own **Message** blocks and the - manual verification steps below (see U.0a) — it does not replace or alter them. -- Draw the table yourself in chat. Do not mention `results.json`, file paths, or - the tools used to produce it. - ---- - -## U.0a — Show the manual verification steps to the user (in chat) - -Do this right after the U.0 table, before touching any state. A -`python scripts/flightcheck/cli.py --checkpoint ` run **never opens the HTML -report** — for `MANUAL` checks the verification steps must appear **in chat**, not -in a browser popup. This routine is what puts them there. - -- Skip this step when `RESULT_SOURCE="external"`; the calling playbook has - already shown `EXTERNAL_EVIDENCE`. Also skip when `CHECKPOINT_RESULT` is - `null`, or when `workspace/flightcheck/results.json` does not exist. - -Each entry in `results.json` carries the full text of what the operator must do — -not just `description`/`status` but also the finding and the how-to: - -```json -{ "checkpoint_id": "...", "description": "...", "status": "Manual", - "result": "", - "remediation": "" } -``` - -For **every** entry in `results` whose `status` is `Manual` (also `Warning` or -`NotConfigured`, when present), render a block in chat — one per entry, in the -order they appear — using `description` as the heading, then `result`, then -`remediation`: - -``` -**** - - - - -``` - -Rules: -- Copy `result` and `remediation` **verbatim** — keep the numbered/bulleted steps - and every line break. Do **not** summarise, shorten, re-order, or paraphrase the - steps; the operator follows them exactly. -- Still **never** surface `checkpoint_id`, the Step ID, or the hidden comment. -- Do **not** open, mention, or link `report.html` — the steps live in chat now. -- If no entry has a `Manual`/`Warning`/`NotConfigured` status, render no block. -- Do not mention `results.json`, file paths, or the tools used to produce it. - ---- - -## U.1 — Locate the item - -Read `.local/setup/workday-da/tasks.md` (render from the template first if -absent). Find the checklist item whose hidden comment has `id:` equal to -`STEP_ID`. - -- If no such item exists, **stop and report** — a skill must not invent items. - The canonical item set lives in the checklist template - `src/skills/setup/workday-da/tasks.md`; a missing item means the template is - out of date, not that the updater should add one. -- If `STEP_ID` is **not** owned by the calling skill, **stop** — skills update - only their own items. - ---- - -## U.2 — Determine the new Status (the MANUAL/attestation rule) - -This is the load-bearing rule. **A `MANUAL` or attestation-gated row is never -auto-completed by a flightcheck pass.** - -First apply failure precedence: for every non-advisory row, -`CHECKPOINT_RESULT = FAILED` or `ERROR` always produces `blocked`, regardless -of `ACK`, `NEW_STATE`, or gate evidence. An acknowledgement records that a -person saw or performed a step; it never overrides an objective failure. - -Otherwise decide `Status` as follows: - -| `GATE` | Condition | Resulting `Status` | -|--------|-----------|--------------------| -| `prog` | `CHECKPOINT_RESULT` = `PASSED` | `done` | -| `prog` | `CHECKPOINT_RESULT` = `WARNING` / `NOT_CONFIGURED` / `SKIPPED` / `MANUAL` / `null` | `in-progress` | -| `manual` / `attest` | `ACK` = `true`, `ROW_EVIDENCE` is complete, and result is not `FAILED`/`ERROR` | `done` | -| `manual` / `attest` | `ACK` = `false` or `ROW_EVIDENCE` is missing | `in-progress` | -| `advisory` | the advisory step has been run and its report shown (or attempted and skipped) | `done` | - -Notes: -- An `advisory` row is not backed by a flightcheck checkpoint (`CHECKPOINT_RESULT` - is `null`). It **never blocks** — it completes to `done` once its advisory - output has been presented to the user, regardless of what the output found. If - the advisory step can't run, note it and still complete the row (advisory rows - never hold up the setup). -- A `CHECKPOINT_RESULT` of `MANUAL` means "the checkpoint reported what it could, - but completion needs a human." It **never** maps to `done` on its own — it - requires `ACK = true`. -- For `prog` rows, `NEW_STATE` from the caller must be consistent with - `CHECKPOINT_RESULT`; if they conflict, the checkpoint result wins (it's the - objective signal). -- For a `prog` row with `RESULT_SOURCE="external"`, `PASSED` is valid only when - non-empty `EXTERNAL_EVIDENCE` is supplied. Otherwise treat the result as - `null` and leave the row `in-progress`. - -If the row is `manual`/`attest` and `ACK` is `false`, before leaving the row -`in-progress` confirm the user actually saw the manual step. (Precondition: the -manual verification steps — U.0a — for this row's checkpoint must already have been -rendered in chat. If they were not, show them now, then ask.) - -```json -[ - { - "header": "Confirm step", - "question": "Have you completed this step and is the evidence captured?", - "options": [ - { "label": "Yes, it's done", "recommended": true }, - { "label": "Not yet" } - ], - "allowFreeformInput": false - } -] -``` - -Only treat the row as acknowledged (`ACK = true`) on an explicit "Yes, it's -done". Never infer acknowledgement from a flightcheck pass. - ---- - -## U.3 — Write the item + mirror - -**Persist immediately — never batch.** Write **both** files below **now**, as part -of this call, before returning control to the caller and before the caller proceeds -to its next row. A completed row must be durable the instant its checkpoint passes, -so that if a later row in the same skill errors, the progress already made is not -lost — the orchestrator resumes from the first non-`done` row in `setupStatus`. - -1. Update the located item in `.local/setup/workday-da/tasks.md` to the state - from U.2: - - Set the checkbox marker: `- [x]` when the resulting status is `done`, - otherwise `- [ ]`. - - Set the hidden `status:` field in that item's comment to the full value - (`pending` / `in-progress` / `done` / `blocked`). - - Leave the visible title/description and every other item untouched. Do not add - any Step ID, checkpoint ID, or status text to the visible line — the checkbox is - the only at-a-glance marker the user sees. -2. Update the mirror in `.local/connect/workday-da/config.json`: - ```json - { - "setupStatus": { - "{STEP_ID}": { - "state": "", - "checkpoint": "", - "gate": "", - "verifiedBy": "", - "evidence": { - "outcome": "", - "provenance": "", - "note": "", - "capturedAt": "" - }, - "gateEvidence": { - "method": "", - "outcome": "", - "provenance": "", - "note": "", - "capturedAt": "" - } - } - } - } - ``` - Set scalar `verifiedBy` from the resulting completed state: - - `programmatic` for a completed `prog` row, - - `attested` for a completed `manual`/`attest` row, - - `reviewed` for a completed `advisory` row, - - `null` for any row that is not `done`. - - Persist `ROW_EVIDENCE` as `evidence` and `GATE_EVIDENCE` as - `gateEvidence` when supplied. Merge these fields with the existing row; - never replace `verifiedBy` with an object. When a row regresses to - `in-progress` or `blocked`, clear stale completion `verifiedBy` and - `evidence`, while retaining current failure evidence and any still-valid - `gateEvidence`. - Merge — do not drop other `setupStatus` keys (round-trip contract in - `config-schema.md`). - -Return control to the calling file. Do not announce file paths or internal -mechanics to the user. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md index 2df032d58..6ad800d21 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md @@ -1,187 +1,78 @@ -# Workday DA Setup — Config Persistence Schema + +# Workday connect state contract -This file documents the **canonical shape** of the Workday connection config -that the `connect/workday-da` skill's steps read and write. It is a *reference -doc*, not an executable fragment — there are no Message blocks here. The steps -cite this file so they agree on field names, owners, and types. +The only writable lifecycle state is: -**Canonical data file:** `.local/connect/workday-da/config.json` - -Forked from the CEA `setup/shared/config-schema.md`. The field shapes are the -same; only the file path and the owning steps differ — DA has five steps -(DA-1 install, DA-2 Entra, DA-3 tenant, DA-4 Power Platform integration, -DA-5 runtime validation). - ---- - -## Do NOT confuse the two config files - -There are **two distinct** files. Keep them separate. - -| File | Owner | Purpose | -|------|-------|---------| -| `.local/connect/workday-da/config.json` | the `connect/workday-da` skill | Workday connection state — sidecar Dataverse URL, Workday URLs, tenant, Entra app, OAuth client, per-step status. **This schema.** | -| `.local/config.json` | foundation setup + FlightCheck | AgentBuilder-native identity (`powerPlatformApiEndpoint`, `activeAgent`, `agent`/`agents`) and, for legacy workspaces only, a foundation `dataverseEndpoint`. **Not this schema.** | - -Never write Workday connection fields into `.local/config.json`, and never -write agent identity into `.local/connect/workday-da/config.json`. A native MOS -agent may use `sidecarDataverseEndpoint` in this schema for the Dataverse -environment hosting the Workday solution and flows; FlightCheck consumes it -only when foundation config has no `dataverseEndpoint`. - ---- - -## Canonical fields - -All fields live at the top level of `.local/connect/workday-da/config.json` -unless noted. A field is written **once** by its owner step and thereafter -read by later steps. Unknown/absent fields are treated as `null`. - -### Connection + tenant (tenant URL captured early by DA-2; API-client fields by DA-3) - -| Field | Type | Owner | Notes | -|-------|------|-------|-------| -| `sidecarDataverseEndpoint` | string | DA-1 | HTTPS Dataverse organization URL hosting the Workday solution, connections, and flows for a native MOS/AgentBuilder agent. Do not copy it into foundation config. | -| `baseUrl` | string | DA-2/DA-3 | Workday web host base URL (e.g. `https://wd2-impl.workday.com`). Captured early by DA-2 when the operator has the URL, else by DA-3. | -| `tenant` | string | DA-2/DA-3 | Workday tenant short name. Captured early by DA-2 to pin the Entra app deterministically, else by DA-3. | -| `tokenHost` | string | DA-2/DA-3 | Services host used to build token / REST URLs. Derived by DA-2 when the URL matches a known pattern, else by DA-3. | -| `oauthTokenUrl` | string | DA-3 | `https://{tokenHost}/ccx/oauth2/{tenant}/token`. | -| `restBaseUrl` | string | DA-3 | REST base, **trimmed to `/api`** — see `shared/connection-fields.md`. | -| `soapBaseUrl` | string | DA-3 | SOAP base (`https://{services-host}/ccx/service`). | -| `domainName` | string | DA-3 | Workday domain name, when discovered. | -| `tenantId` | string | DA-2 | **Entra** tenant ID (GUID) — set during Entra setup. | -| `installPath` | string | DA-3/DA-4 | `"simplified"`. | -| `status` | string | all | `"in-progress"` \| `"configured"` \| `"ready"`. `"configured"` means setup values are recorded but runtime is not proven. Only DA-5 sets `"ready"` after a signed-in Workday scenario succeeds. | -| `verticals` | array[string] | DA-1 | Always `["hr"]` for this release. ESS DA IT is not supported by `/connect workday`. | -| `vertical` | string | DA-1 | Always `"hr"` for this release. | - -### Entra app + OAuth client (owned by DA-2 / DA-3) - -| Field | Type | Owner | Notes | -|-------|------|-------|-------| -| `entraSSO` | boolean | DA-2 | True once the SSO gallery app + connector authorization exist. | -| `entraAppId` | string | DA-2 | Entra app (client) ID. | -| `entraAppObjectId` | string | DA-2 | Entra app object ID (for Graph calls). | -| `entraAppIdUri` / `appIdUri` | string | DA-2 | Application ID URI (`api://{entraAppId}`). `appIdUri` is the documented alias. | -| `scopeGuid` | string | DA-2 | GUID of the exposed `user_impersonation` scope. | -| `oauthClientId` | string | DA-3 | Workday API **client ID** (distinct from `entraAppId`). | -| `tokenEndpoint` | string | DA-3 | OAuth token endpoint captured from the Workday API client view. Mirrors `oauthTokenUrl` when both are present. | - -### Per-step status fields (owned by each step via the checklist-updater) - -Each step records its own checkpoint outcomes under a `setupStatus` object, -keyed by **Step ID** (`DA1.1` … `DA5.1`) from the DA master checklist. This is -the durable record `shared/checklist-updater.md` reads and writes; the -rendered `.local/setup/workday-da/tasks.md` is the human-readable view of the -same data. - -```json -{ - "setupStatus": { - "DA1.1": { - "state": "done", - "checkpoint": "WD-DA-PKG-001", - "gate": "prog", - "verifiedBy": "programmatic", - "evidence": { - "outcome": "PASSED", - "provenance": "flightcheck", - "note": "Required package detected", - "capturedAt": "2026-09-24T10:00:00Z" - }, - "gateEvidence": { - "method": "programmatic", - "outcome": "pass", - "provenance": "role-query", - "note": "Required role confirmed", - "capturedAt": "2026-09-24T09:59:00Z" - } - }, - "DA2.1": { "state": "pending", "checkpoint": "WD-CONN-102", "gate": "manual", "verifiedBy": null } - } -} +```text +.local/connect/workday-da/config.json ``` -- `state` ∈ `pending` \| `in-progress` \| `done` \| `blocked`. -- `gate` ∈ `prog` \| `manual` \| `attest` \| `advisory` (from the DA master - checklist row). -- `verifiedBy` ∈ `programmatic` \| `attested` \| `reviewed` \| `null`. A - `manual`/`attest` row is **never** set to `done` by a flightcheck pass - alone — it needs an explicit user acknowledgement plus captured evidence - (see `shared/checklist-updater.md` and `shared/permission-gate.md`, reused - unchanged from CEA). An `advisory` row (no checkpoint) completes with - `verifiedBy: "reviewed"` once its report has been shown; it never blocks. -- `evidence` is a structured completion record with `outcome`, `provenance`, - `note`, and `capturedAt`. It records why the row reached its current state; - it never replaces scalar `verifiedBy`. -- `gateEvidence` is the optional role-gate record with `method` - (`programmatic` or `attested`), `outcome` (`pass` or `stop`), `provenance` - (`role-query` or `user-attestation`), `note`, and `capturedAt`. Gate evidence - proves authorization only; it does not by itself complete the row. - ---- - -## Power Platform integration state +`scripts/workday_connect_store.py` owns locking, migration, validation, atomic +writes, and phase transitions. Skills must use `scripts/workday_connect.py`; +they must not edit this file directly or create a Markdown state mirror. -DA-4 records programmatic evidence for solution-reference binding and supported -flow activation. Agent connection sharing, topic selection, and firewall -allowlisting remain manual or attested until reliable DA-scoped APIs are -available. It must not reuse CEA checkpoints as proof. DA4.6 uses programmatic -evidence from the checked-in authorization script. - ---- - -## Full example (mid-setup) +## Schema version 5 ```json { - "sidecarDataverseEndpoint": "https://contoso.crm.dynamics.com", - "baseUrl": "https://wd2-impl.workday.com", - "tenant": "acme_dpt1", - "tokenHost": "wd2-impl-services1.workday.com", - "oauthTokenUrl": "https://wd2-impl-services1.workday.com/ccx/oauth2/acme_dpt1/token", - "tokenEndpoint": "https://wd2-impl-services1.workday.com/ccx/oauth2/acme_dpt1/token", - "restBaseUrl": "https://wd2-impl-services1.workday.com/ccx/api", - "soapBaseUrl": "https://wd2-impl-services1.workday.com/ccx/service", - "tenantId": "00000000-0000-0000-0000-000000000000", - "installPath": "simplified", - "verticals": ["hr"], - "vertical": "hr", - "entraSSO": true, - "entraAppId": "11111111-1111-1111-1111-111111111111", - "entraAppObjectId": "22222222-2222-2222-2222-222222222222", - "appIdUri": "api://11111111-1111-1111-1111-111111111111", - "scopeGuid": "33333333-3333-3333-3333-333333333333", - "oauthClientId": "WORKDAY_CLIENT_ID", + "schemaVersion": 5, + "provider": "workday", "status": "in-progress", - "setupStatus": { - "DA1.1": { - "state": "done", - "checkpoint": "WD-DA-PKG-001", - "gate": "prog", - "verifiedBy": "programmatic", - "evidence": { - "outcome": "PASSED", - "provenance": "flightcheck", - "note": "Required package detected", - "capturedAt": "2026-09-24T10:00:00Z" - } - } - } + "scope": {}, + "identifiers": {}, + "endpoints": {}, + "operators": {}, + "tenantFoundation": null, + "phases": {}, + "migration": null, + "updatedAt": "UTC timestamp" } ``` ---- - -## Round-trip contract - -Any step that writes a field listed above must: - -1. **Read** the existing file first (it may already hold values from an - earlier step). -2. **Merge** — set only the fields it owns; never drop fields it doesn't own. -3. **Write** the merged object back. - -A value written by one step must read back identically in a later step (no -re-derivation, no format drift). The trim rules for `restBaseUrl` / -`soapBaseUrl` are defined once in `shared/connection-fields.md`. +- `scope` contains exact agent, Dataverse environment, architecture, package, + Entra tenant, and Workday tenant targeting. +- `identifiers` contains non-secret Entra and Workday identifiers. +- `endpoints` contains validated non-secret Workday endpoints. +- `operators` contains safe account and tenant provenance. +- `tenantFoundation` contains reusable Entra and Workday administrator + evidence scoped to one exact Entra tenant, Workday tenant, application, + signing certificate, and endpoint set. +- `phases` contains exactly the six controller phases. + +Each phase stores status, completed action keys, optional runtime approval, +evidence, current blocker, and updated time. + +## Identifier invariant + +These values are independent and must never be aliases: + +| Field | Meaning | Format | +| --- | --- | --- | +| `identifiers.workdaySamlEntityId` | Workday SAML Service Provider ID and connector resource URL | `http://www.workday.com/{tenant}` | +| `identifiers.entraAppIdUri` | Entra exposed API Application ID URI | `api://{entraAppId}` | + +## Persistence rules + +- Never persist passwords, client secrets, access or refresh tokens, cookies, + certificate bodies, private keys, or employee data. +- Persist account usernames and tenant IDs only as authentication provenance. +- Controller-owned runtime mutations must carry an exact plan hash. +- A relevant scope, identifier, endpoint, or operator change invalidates the + owning deployment phase and downstream state, evidence, blockers, and + approvals. It does not delete a previously captured tenant foundation. +- After a deployment reset, the exact Entra application must be reread. When + that evidence still matches `tenantFoundation`, the Workday administrator + phase is restored without asking the administrator to repeat configuration. +- Tenant-foundation evidence is never reused across a different Entra tenant, + Workday tenant, application, SAML Service Provider ID, signing certificate, + or endpoint set. +- A phase is complete only after the target has been reread and matching + evidence exists for every compact required action. +- The provider status becomes `ready` only when all six phases are complete. + +Schema-v2, schema-v3, schema-v4, or legacy row-based state is backed up to +`config.pre-v5.json` before one-time migration. When Entra and Workday +administrator phases were already complete, migration captures their evidence +as the reusable tenant foundation. A previously complete runtime phase is +reopened when it lacks live Workday topic activation evidence. Legacy Markdown +task files, when present, are historical snapshots and are never rewritten. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/connection-fields.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/connection-fields.md deleted file mode 100644 index 89b8abbc4..000000000 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/connection-fields.md +++ /dev/null @@ -1,143 +0,0 @@ -# Connection Fields — Capture & Validate (DA) - -Centralizes capture and validation of the Workday connection identifiers the -DA Workday setup skills exchange. **DA-3 captures** these (from the Workday -API client view and tenant URL); a later DA extension-pack configuration step -consumes them when it binds the connection. Keeping the rules here means both -steps agree on format — especially the documented **REST-base `/api` trim** -gotcha that silently breaks the connection if it's wrong. - -Forked from the CEA `setup/shared/connection-fields.md` with DA-scoped state -paths. The tenant math (URL derivation, trim rules) is agent-architecture -agnostic and identical to the CEA version — only the persisted file changes. - -Every **Message** block is the exact text to show the user. Copy it verbatim. Do -not rephrase or narrate tool calls. - -**Inputs from the calling file (any that are already known):** -- `WD_TENANT`, `WD_BASE_URL`, `WD_TOKEN_HOST` — captured by DA-3 from the - Workday tenant / API-client screens (or read from - `.local/connect/workday-da/config.json` when an earlier step already stored - them). -- `OAUTH_CLIENT_ID`, `TOKEN_ENDPOINT` — from the Workday "View API Client" - screen (DA-3). -- `APP_ID_URI` — the Entra Application ID URI (`api://{entraAppId}`) from - DA-2. - -**Outputs (written back to `.local/connect/workday-da/config.json`, see -`config-schema.md`):** -- `appIdUri`, `oauthTokenUrl` / `tokenEndpoint`, `oauthClientId`, - `soapBaseUrl`, `restBaseUrl` (trimmed). - ---- - -## C.1 — Application ID URI - -The Application ID URI identifies the Entra app registration itself -(`api://{entraAppId}`). DA-3 exposes it for the SAML token audience and the -connector's API pre-authorization. It is **not** the connection's "Microsoft -Entra resource URL" — see the note below. - -- Expected form: `api://{entraAppId}` (the GUID, not the object ID). -- If `APP_ID_URI` is missing, derive it from `entraAppId`: - `api://{entraAppId}`. -- **Validate:** must start with `api://` and contain a GUID. If it instead looks - like a full URL (`https://...`) or is empty, re-prompt: - -```json -[ - { - "header": "Application ID URI", - "question": "What's the Application ID URI of the Entra app? It looks like api://." - } -] -``` - -Save as `appIdUri`. - -> **Not the connection resource URL.** The Workday connection asks for a -> **Microsoft Entra resource URL** — the Workday SAML identifier -> `http://www.workday.com/{tenant}` (matching the Entra app's Identifier / -> Entity ID and Workday's SAML Service Provider ID), **not** this `api://…` App -> ID URI. - ---- - -## C.2 — OAuth token URL - -- Expected form: `https://{WD_TOKEN_HOST}/ccx/oauth2/{WD_TENANT}/token`. -- If `TOKEN_ENDPOINT` was captured from the API client screen, prefer it but - confirm it matches the derived form's host + tenant; if it diverges, keep the - captured value and note it. -- **Validate:** must be `https://`, contain `/ccx/oauth2/`, and end with `/token`. - -Save as `oauthTokenUrl` (and `tokenEndpoint` when captured from the API client). - ---- - -## C.3 — Client ID - -- `OAUTH_CLIENT_ID` is the **Workday API client ID** shown on the "View API - Client" screen. It is **not** the Entra `entraAppId` — do not conflate them. -- **Validate:** non-empty. If the user pastes something that is obviously the - Entra app GUID already stored as `entraAppId`, warn and re-ask — they are - distinct identities. - -Save as `oauthClientId`. - ---- - -## C.4 — SOAP base URL - -The SOAP base is derived from the Workday **services** host (the same host as -`WD_TOKEN_HOST`, so `https://{WD_TOKEN_HOST}/ccx/service` is equivalent): - -- `impl.workday.com` → `https://wd2-impl-services1.workday.com/ccx/service` -- `wd5.myworkday.com` → `https://wd5-services1.myworkday.com/ccx/service` -- `{dcN}.myworkday.com` → `https://{dcN}-services1.myworkday.com/ccx/service` - -- Expected form: `https://{services-host}/ccx/service` (no tenant suffix, no - trailing slash). -- **Validate:** must be `https://`, contain `/ccx/service`, and **not** end in a - trailing `/`. If `WD_BASE_URL` didn't match a known pattern, fall back to - asking the user for the SOAP base URL. - -Save as `soapBaseUrl`. - ---- - -## C.5 — REST base URL — trimmed to `/api` *(silent-failure gotcha)* - -This is the field that most often breaks the simplified-path connection. The -Workday screens and copy/paste sources frequently include extra trailing -segments. **Copy as displayed, then trim** so the value ends at `/api`. - -- Canonical form: `https://{WD_TOKEN_HOST}/ccx/api`. -- **Trim procedure** — starting from whatever was captured: - 1. Strip any trailing slash. - 2. If it ends with a version segment (`/v1`, `/v2`, …), remove it. - 3. If it ends with the tenant name or any path **after** `/ccx/api`, remove - everything after `/ccx/api`. - 4. The result must end exactly with `/ccx/api` (or `/api` for hosts that omit - `/ccx`). -- **Validate:** must be `https://`, contain `/api`, and have **nothing** after - the `/api` segment. If anything follows `/api`, trim it and show the user the - corrected value: - -**Message:** - -I trimmed the Workday REST base URL to **{restBaseUrl}** — the connection -fails silently if anything is appended after `/api`, so it has to end there. - -**End message.** - -Save the trimmed value as `restBaseUrl`. - ---- - -## C.6 — Persist - -Read `.local/connect/workday-da/config.json`, merge the validated fields above -(never dropping fields owned by other steps), and write it back — per the -round-trip contract in `config-schema.md`. Return the saved values to the -calling file. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/permission-gate.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/permission-gate.md deleted file mode 100644 index ad02a0a40..000000000 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/permission-gate.md +++ /dev/null @@ -1,165 +0,0 @@ -# Permission Gate (Shared) - -A reusable **role check → specific named error → stop** routine. Every Workday -DA connect skill step applies this fragment before it performs role-restricted -work, so no step duplicates inline role logic. - -Forked from the CEA `setup/shared/permission-gate.md` — the role-gating logic -is identical; only the persisted-state path differs. - -Every **Message** block is the exact text to show the user. Copy it verbatim. -Do not rephrase, add commentary, or tell the user what tools you are calling. - -**Inputs from the calling file:** -- `REQUIRED_ROLE` — the human-readable role name to require (e.g. - `"Workday Administrator"`, `"Power Platform Administrator"`, - `"Application Administrator"`). -- `GATE_MODE` — `"programmatic"` or `"attested"` (see "Choosing a mode" below). -- `STEP_ID` — the master-checklist Step ID this gate protects (e.g. `"DA3.1"`), - used only to record evidence. -- `ROLE_QUERY` — *(programmatic mode only)* the command/check that proves the - caller holds the role (the calling file supplies it; examples below). - -**Outputs to the calling file:** -- `GATE_RESULT` — `"pass"` or `"stop"`. On `"stop"`, the calling file must halt. -- `GATE_EVIDENCE` — an object recording how the gate was satisfied; the caller - passes it to `checklist-updater.md`, which merges it under - `setupStatus["{STEP_ID}"].gateEvidence` in - `.local/connect/workday-da/config.json` (see `config-schema.md`): - - `method` ∈ `"programmatic"` \| `"attested"`. - - `outcome` ∈ `"pass"` \| `"stop"`. - - `provenance` ∈ `"role-query"` \| `"user-attestation"`. - - `note` — short free text (e.g. the role-query result, or the user's - attestation timestamp/identity). - - `capturedAt` — current UTC timestamp. - ---- - -## Choosing a mode - -The gating mechanism differs by role because not every role has a queryable -directory: - -| Role family | Mode | How verified | -|-------------|------|--------------| -| Entra roles (App Admin, Cloud App Admin, Global Admin, Priv Role Admin) | `programmatic` | Microsoft Graph role / privilege query | -| Power Platform Admin | `programmatic` | Power Platform admin API | -| Dataverse maker / system roles | `programmatic` | Dataverse security-role query | -| **Workday Administrator** | `attested` | No directory here → explicit named-role attestation + captured evidence | -| **InfoSec / IT** (firewall allowlisting) | `attested` | No directory here → explicit named-role attestation + captured evidence | - -The calling file picks `GATE_MODE` from this table. **Never** silently pass an -attested role — always require the explicit confirmation in section G.2. - ---- - -## G.1 — Programmatic gate - -Use when `GATE_MODE` is `"programmatic"`. - -Run the `ROLE_QUERY` the calling file supplied. Examples of what a caller passes: - -- **Entra role (Graph):** - ``` - az rest --method GET --url "https://graph.microsoft.com/v1.0/me/memberOf/microsoft.graph.directoryRole?%24select=displayName,roleTemplateId" --query "value[].{displayName:displayName,roleTemplateId:roleTemplateId}" -o json - ``` - (OData options are percent-encoded — `%24select` not `$select` — so the URL - survives PowerShell/bash `$`-expansion and runs first-try on every shell.) - Pass only when the returned `roleTemplateId` equals one of the stable, - caller-approved built-in role template IDs. The - `microsoft.graph.directoryRole` cast excludes ordinary groups; display names - are diagnostic only and must never determine authorization. -- **Power Platform Admin / Dataverse role:** the caller supplies the specific - admin-API or Dataverse query and the expected value. - -**If the query proves the role is held:** -- Set `GATE_RESULT = "pass"`. -- Set `GATE_EVIDENCE = { "method": "programmatic", "outcome": "pass", "provenance": "role-query", "note": "", "capturedAt": "" }`. -- Return to the calling file. - -**If the query proves the role is NOT held** (or returns an -`Insufficient privileges` / `Authorization_RequestDenied` error — mirror the -existing pattern in `connect/azure/app-registration.md` section B.2): - -**Message:** - -This step requires the **{REQUIRED_ROLE}** role, and your account doesn't -have it. Ask your administrator to grant this role, then come back and run -this step again. - -**End message.** - -- Set `GATE_RESULT = "stop"`. -- Return to the calling file. **The caller must halt — do not proceed.** - -**If the query itself fails** for an unrelated reason (network, not logged in): -retry once. If it still fails, **fail closed**: - -**Message:** - -I couldn't verify the **{REQUIRED_ROLE}** role, so I can't safely continue this -step. Sign in again or ask a verified administrator to run it, then retry. - -**End message.** - -- Set `GATE_RESULT = "stop"`. -- Set `GATE_EVIDENCE` with `method: "programmatic"`, `outcome: "stop"`, - `provenance: "role-query"`, the query error in `note`, and the current UTC - timestamp in `capturedAt`. -- Return to the calling file. Never downgrade a programmatic privileged-role - gate to self-attestation. - ---- - -## G.2 — Attestation gate - -Use only when `GATE_MODE` is `"attested"` (Workday Administrator, InfoSec/IT). -Programmatic privileged-role checks never fall back to this section. - -**Message:** - -This step requires the **{REQUIRED_ROLE}** role. I can't verify that -automatically for this system, so I need you to confirm you (or the person -doing this step) hold that role before we continue. - -**End message.** - -Use the `vscode_askQuestions` tool: - -```json -[ - { - "header": "Confirm role", - "question": "Do you have the {REQUIRED_ROLE} role to perform this step?", - "options": [ - { "label": "Yes, I have this role", "recommended": true }, - { "label": "No / not sure" } - ], - "allowFreeformInput": false - } -] -``` - -**If the user chose "Yes, I have this role":** -- Set `GATE_RESULT = "pass"`. -- Set `GATE_EVIDENCE = { "method": "attested", "outcome": "pass", "provenance": "user-attestation", "note": "user attested {REQUIRED_ROLE} for {STEP_ID}", "capturedAt": "" }`. -- Return to the calling file. - -**If the user chose "No / not sure":** - -**Message:** - -No problem — this step needs the **{REQUIRED_ROLE}** role. Ask whoever holds -that role to run it, then come back and continue. - -**End message.** - -- Set `GATE_RESULT = "stop"`. -- Set `GATE_EVIDENCE` with `method: "attested"`, `outcome: "stop"`, - `provenance: "user-attestation"`, a safe note, and the current UTC timestamp. -- Return to the calling file. **The caller must halt — do not proceed.** - -> An attested `"pass"` records that the role was **claimed**, not directory-proven. -> It satisfies the *gate*, but it does **not** by itself complete the checklist row -> — the row still needs its own captured evidence/acknowledgement per -> `checklist-updater.md`. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/tasks.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/tasks.md deleted file mode 100644 index 57c5894ff..000000000 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/tasks.md +++ /dev/null @@ -1,114 +0,0 @@ - -# Workday Connect — Checklist (template) - -The single, trackable checklist spanning the five Workday connect steps for the -**ESS HR agent**. This file is the -**canonical row source**: on first run the skill renders it to the working copy -`.local/setup/workday-da/tasks.md` and then updates **only its own items** -through the shared -[`shared/checklist-updater.md`](shared/checklist-updater.md). The durable -mirror of each item's status is `setupStatus` in -`.local/connect/workday-da/config.json` (see -[`shared/config-schema.md`](shared/config-schema.md)). - -> Do not hand-edit the working copy's checkboxes — let the checklist-updater -> write them so the **MANUAL / attestation rule** is enforced in one place. - -This checklist assumes your Employee Self-Service HR agent is already -installed (via `/setup`). If it isn't, DA-1 below detects that and points you -there first. - -## How to read this checklist - -Each item is a plain checkbox with a short description of what it achieves — -that is what the user sees: - -- `- [ ]` — not done yet. -- `- [x]` — done. - -The technical details the tooling needs (the stable **Step ID**, the -flightcheck **checkpoint(s)** that verify the item, and the completion -**gate**) live in the HTML comment directly under each item. Those comments are -invisible in the rendered checklist; only the checklist-updater reads them. -**Never surface a Step ID or checkpoint ID to the user** — show the checkbox -and its description only. - -**Gate** — how an item reaches done: - -| Gate | Meaning | -|------|---------| -| `prog` | A programmatic flightcheck pass completes the item. | -| `manual` | Explicit user action + re-verify; a flightcheck pass alone never completes it. | -| `attest` | Attestation + captured evidence (no queryable directory); never auto-completed. | -| `advisory` | Informational; completes once its output has been shown, regardless of findings. | - -The hidden `status:` field carries the full four-state value -(`pending` \| `in-progress` \| `done` \| `blocked`) that a single checkbox can't -express; all items start `pending`. - -## Checklist - -### 1. Workday extension package - -- [ ] **Install the Workday extension package** — Add the Workday extension package to your ESS HR agent so it can talk to Workday. If the HR base agent isn't installed yet, this step sends you to `/setup` first. - - -### 2. Connect Microsoft Entra sign-in to Workday - -- [ ] **Set up Workday sign-in** — Create the Microsoft Entra application Workday uses to recognize signed-in employees. - -- [ ] **Allow Power Platform to call Workday** — Add the permission used by the Workday connector and the Microsoft Graph permissions needed for sign-in. - -- [ ] **Approve the sign-in permissions** — Grant organization-wide consent for the permissions the Workday connection needs. - -- [ ] **Choose who can use Workday** — Assign the employees or groups allowed to use the Workday application, or confirm assignment is not required. - -- [ ] **Match the signed-in employee** — Configure the sign-in identifier Workday uses to find the current employee. - -- [ ] **Sign the Workday sign-in response** — Turn on "Sign SAML response and assertion" so Workday trusts the sign-in response. - -- [ ] **Confirm the correct Microsoft Entra tenant** — Verify Workday is connected to this environment's Microsoft Entra tenant. - - -### 3. Workday tenant configuration - -- [ ] **Register the Workday API client** — In Workday, register the API client for the agent, including the functional areas and Workday-owned scope. - -- [ ] **Capture your Workday connection details** — Record the client ID, token endpoint, REST and SOAP base URLs, and tenant name needed to connect. - -- [ ] **Verify employee SAML sign-in policy** — Confirm an active Workday authentication rule allows SAML for the intended employees, or have the Workday administrator review and activate the required change. - -- [ ] **Match the signing certificate** — Confirm the Workday-side signing certificate matches the one in Entra (validity dates, or an externally-computed SHA-1 — Workday shows no thumbprint). - - -### 4. Power Platform and agent integration - -- [ ] **Create the Workday connection** — Create the signed-in employee Workday connection with the captured Workday endpoints. - -- [ ] **Create the Microsoft Dataverse connection** — Create or select an active Dataverse connection owned by the maker in this environment. - -- [ ] **Bind the extension connections** — Attach the Workday and Dataverse connections to the installed Workday runtime references. - -- [ ] **Turn on the Workday cloud flows** — Enable every Workday runtime flow after its connections are bound. - -- [ ] **Connect Workday to the agent** — Connect each Workday flow in Copilot Studio and allow it to share the connection parameters used for signed-in employee access. - -- [ ] **Authorize the agent to use the Workday flows** — Preview and apply the delegated authorization and workflow sharing required by the ESS HR agent. - -- [ ] **Configure employee context and topics** — Use the Workday package's V2 signed-in-user context and enable the Workday topics selected for this agent. - -- [ ] **Allow Workday through the firewall** — Allow the Workday REST and SOAP hosts used by the Power Platform managed connectors. - - -### 5. Validate Workday readiness - -- [ ] **Validate a signed-in Workday scenario** — Run a Workday topic as a signed-in employee and confirm the agent returns real data before marking the environment ready. - - -> An item backed by an **attest** or **manual** gate is **never** auto-completed -> by its checkpoint — it requires an explicit user acknowledgement plus -> captured evidence (see [`shared/checklist-updater.md`](shared/checklist-updater.md)). - -DA-scoped APIs are not available for every Power Platform surface. Those rows -remain manual or attested rather than being falsely completed by CEA-specific -checks. The final row requires runtime evidence from a signed-in user. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md index 659dbd76d..d30124b89 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md @@ -1,93 +1,97 @@ -# DA-5 — Validate Workday Readiness - -Role: **Environment Maker** with a signed-in Workday test user. This step -re-confirms the extension package, reviews every setup area, and requires a -real Workday scenario before the environment is marked ready. It owns -master-checklist row **DA5.1**. - -Every **Message** block is the exact text to show the user. Copy it verbatim. Do -not rephrase, add commentary, or tell the user what tools you are calling or what -files you are reading. - ---- - -## DA5.1 — Validate a signed-in Workday scenario - -**Re-confirm the extension package.** - +# Phase 6 - Employee validation + +This phase requires a real signed-in employee scenario. Configuration checks +alone cannot complete it. + +The skill cannot publish the agent, impersonate an employee, or perform this +scenario on the employee's behalf. It guides the maker through the test and +records only the safe outcome. + +Ask the maker to: + +1. publish the ESS HR agent; +2. start a new conversation; +3. sign in as a test employee assigned to the Workday Entra application and + authorized in Workday; +4. run one enabled read-only scenario, such as checking a vacation balance; +5. confirm the agent identifies the signed-in employee and returns real + Workday data without an unexpected repeated sign-in. + +Use `vscode_askQuestions` for the test result: + +```json +[ + { + "header": "Employee test result", + "question": "What happened in the signed-in employee Workday test?", + "options": [ + { "label": "Passed - employee identified and Workday data returned" }, + { "label": "Failed - repeated sign-in" }, + { "label": "Failed - connector error" }, + { "label": "Failed - flow error" }, + { "label": "Failed - employee mismatch" }, + { "label": "Failed - network error" } + ], + "allowFreeformInput": false + } +] ``` -python scripts/flightcheck/cli.py --checkpoint WD-DA-PKG-001 --connect-config ".local/connect/workday-da/config.json" -``` - -Show the result per [`shared/checklist-updater.md`](shared/checklist-updater.md) -§U.0. - -If the current result is not `PASSED`, do not continue from the persisted -DA1.1 state: - -- `FAILED` → update DA1.1 with `GATE="prog"`, - `CHECKPOINT_RESULT="FAILED"` so it becomes `blocked`. -- `WARNING` / `SKIPPED` → update DA1.1 with `GATE="prog"` and that result so - it becomes `in-progress`. - -Tell the user the package must be restored or reverified, then return to the -orchestrator. Do not complete DA5.1. - -**Summarize the Entra and tenant configuration recorded so far.** Read -`.local/connect/workday-da/config.json` and render what's known: -**Message:** - -Here's where your Workday connection stands: - -| Area | Status | -| --- | --- | -| Workday extension package | {✅/❌ from WD-DA-PKG-001} | -| Workday single sign-on (Entra) | {✅ if DA2.1–DA2.7 are all `done`, else "in progress"} | -| Workday tenant configuration | {✅ if DA3.1–DA3.4 are all `done`, else "in progress"} | -| Power Platform and agent integration | {✅ if DA4.1–DA4.8 are all `done`, else "in progress"} | - -**End message.** - -If any of DA1.1, DA2.1–DA2.7, DA3.1–DA3.4, or DA4.1–DA4.8 is not `done`, -tell the user which step to finish and stop here — do not present the -connection as ready. - -**Message:** - -The configuration checklist is complete. Now validate the actual employee -path: - -1. Publish the ESS HR agent. -2. Use a test employee who is assigned to the Workday Entra application and - has valid Workday access. -3. Start a new conversation so stale user-flow state is not reused. -4. Run one enabled Workday scenario, such as checking a vacation balance. -5. Confirm the agent identifies the signed-in employee and returns real - Workday data without asking for another unexpected sign-in. - -Did the scenario complete successfully? +Leave the result unset and do not mark the passing outcome as recommended. If +the test passed, ask for the scenario with this separate structured choice: + +```json +[ + { + "header": "Tested scenario", + "question": "Which read-only Workday scenario did the test employee run?", + "options": [ + { "label": "Check vacation balance" }, + { "label": "View employment information" }, + { "label": "View compensation" }, + { "label": "View organization or manager information" }, + { "label": "Another read-only Workday scenario" } + ], + "allowFreeformInput": true + } +] +``` -**End message.** +On success, record only the scenario name, test-user category, timestamp, and +outcome in `.local/connect/workday-da/employee-validation.json`. Write the +object using a structured file-write tool rather than a generated shell +command, then run: -On success, record the scenario, test user category (never credentials), time, -and result as evidence. First merge provider `status: "ready"` into the -provider config, then update **DA5.1** with `GATE="manual"`, `ACK=true`. This -write order ensures an interruption cannot leave a completed row while the -public readiness signal is missing. Return to the orchestrator. +```powershell +python scripts/workday_connect.py record-validation --evidence-file ".local\connect\workday-da\employee-validation.json" +``` -On failure, leave DA5.1 `in-progress`. Run -`python scripts/flightcheck/cli.py --scope workdayda --connect-config ".local/connect/workday-da/config.json"` -to recheck the environment and DA package. That scope does not prove the live -connection, flow authorization, employee-context wiring, or topic execution, -so also revisit the DA4 connection, flow, authorization, topic, and firewall -evidence. If connection parameters recently changed, reconnect the Workday -connection and retry with a fresh conversation or test user. +Provide only `scenarioName`, `testUserCategory`, `timestamp`, and a passed or +verified `outcome`. Use a non-maker employee category and a +timezone-qualified ISO-8601 timestamp. The controller rejects additional +fields. Never record employee data or credentials. ---- +On failure, record only a safe `failureCategory`, a timezone-qualified +ISO-8601 `timestamp`, and a concise non-sensitive `remediation` in +`.local/connect/workday-da/employee-validation-failure.json`, then run: -## Done +```powershell +python scripts/workday_connect.py record-validation-failure --evidence-file ".local\connect\workday-da\employee-validation-failure.json" +``` -Return control to the orchestrator (`SKILL.md`) — every configuration row -should now be `done`. +This marks Employee validation blocked and persists one current blocker while +keeping completed prerequisite phases intact. Use the failing surface to +choose the next check: + +- sign-in loop -> identify which credential store prompted and whether the + account or tenant differs; +- connector error -> inspect the exact Workday connection status and resource + URL; +- flow error -> inspect the exact flow run and delegated-authorization + evidence; +- employee mismatch -> inspect NameID and User Context V2 evidence; +- network error -> inspect the exact Workday REST or SOAP host. + +After remediation, retry with a new conversation. Do not reset completed +phases. diff --git a/tests/flightcheck/checks/test_workday_extension.py b/tests/flightcheck/checks/test_workday_extension.py index dbb97f857..1f94ecf01 100644 --- a/tests/flightcheck/checks/test_workday_extension.py +++ b/tests/flightcheck/checks/test_workday_extension.py @@ -26,8 +26,10 @@ from __future__ import annotations from dataclasses import dataclass, field +import json from typing import Any +import pytest import responses from tests.conftest import require_validated_mock @@ -443,19 +445,65 @@ def test_absent_url_not_configured(self): # ───────────────────────────────────────────────────────────────────── +def _write_component_map( + tmp_path, + agent: str, + *, + setup_file: str = "Setusercontext.mcs.yml", + setup_path: str | None = None, + target_file: str = "WorkdaySystemGetUserContextV2.mcs.yml", + target_path: str | None = None, + dialog: str | None = None, +): + agent_dir = tmp_path / "workspace" / "agents" / agent + agent_dir.mkdir(parents=True, exist_ok=True) + component_map_path = agent_dir / ".component-map.json" + component_map = ( + json.loads(component_map_path.read_text(encoding="utf-8")) + if component_map_path.exists() + else {} + ) + component_map.update({ + setup_path or f"topics/{setup_file}": { + "componentKind": "DialogComponent", + "displayName": "[Admin] - User Context - Setup", + "schemaName": "contoso.topic.Setusercontext", + } + }) + if dialog: + component_map[target_path or f"topics/{target_file}"] = { + "componentKind": "DialogComponent", + "displayName": "Workday [System] - 1: Set User Context V2", + "schemaName": dialog, + } + component_map_path.write_text( + json.dumps(component_map), + encoding="utf-8", + ) + + def _write_topic(tmp_path, agent: str, body: str): topics = tmp_path / "workspace" / "agents" / agent / "topics" topics.mkdir(parents=True, exist_ok=True) - (topics / "user-context-setup.mcs.yml").write_text(body, encoding="utf-8") + (topics / "Setusercontext.mcs.yml").write_text(body, encoding="utf-8") + _write_component_map(tmp_path, agent) -def _write_installed_topic(tmp_path, agent: str, dialog: str): - topics = tmp_path / ".local" / "agents" / agent / "topics" - topics.mkdir(parents=True, exist_ok=True) - (topics / "workday-user-context.mcs.yml").write_text( - f"schemaName: {dialog}\nkind: AdaptiveDialog\n", - encoding="utf-8", - ) +def _write_installed_topic( + tmp_path, + agent: str, + dialog: str, + *, + create_file: bool = True, +): + _write_component_map(tmp_path, agent, dialog=dialog) + if create_file: + topics = tmp_path / "workspace" / "agents" / agent / "topics" + topics.mkdir(parents=True, exist_ok=True) + (topics / "WorkdaySystemGetUserContextV2.mcs.yml").write_text( + "kind: AdaptiveDialog\n", + encoding="utf-8", + ) class TestUserContextRedirect: @@ -481,13 +529,18 @@ def test_agents_dir_but_no_topic_file_fails(self, tmp_path, monkeypatch): (tmp_path / "workspace" / "agents" / "acme" / "topics").mkdir( parents=True ) + _write_component_map( + tmp_path, + "acme", + dialog="cr123_WorkdaySystemGetUserContextV3", + ) runner = _Runner(config={}, agent_slug="acme") r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-002"] assert r.status == Status.FAILED.value - assert "No user-context-setup.mcs.yml found" in r.result + assert "No mapped admin user-context topic found" in r.result assert "selected agent 'acme'" in r.result - assert "installed Workday user-context" in r.remediation + assert "mapped Workday user-context" in r.remediation def test_topic_missing_redirect_fails(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) @@ -511,8 +564,9 @@ def test_wired_topic_passes(self, tmp_path, monkeypatch): tmp_path, "acme", "kind: AdaptiveDialog\n" - " - kind: BeginDialog\n" - " dialog: cr123_WorkdaySystemGetUserContextV3\n", + "beginDialog:\n" + " kind: BeginDialog\n" + " dialog: cr123_WorkdaySystemGetUserContextV3\n", ) _write_installed_topic( tmp_path, @@ -526,13 +580,150 @@ def test_wired_topic_passes(self, tmp_path, monkeypatch): assert "WorkdaySystemGetUserContextV3" in r.result assert "acme" in r.result + def test_missing_mapped_target_topic_fails(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write_topic( + tmp_path, + "acme", + "kind: AdaptiveDialog\n" + "beginDialog:\n" + " kind: BeginDialog\n" + " dialog: cr123_WorkdaySystemGetUserContextV3\n", + ) + _write_installed_topic( + tmp_path, + "acme", + "cr123_WorkdaySystemGetUserContextV3", + create_file=False, + ) + runner = _Runner(config={}, agent_slug="acme") + + r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-002"] + + assert r.status == Status.FAILED.value + assert "No mapped Workday User Context V2 topic found" in r.result + assert "mapped Workday user-context" in r.remediation + + def test_unsafe_mapped_target_topic_path_fails( + self, + tmp_path, + monkeypatch, + ): + monkeypatch.chdir(tmp_path) + _write_topic( + tmp_path, + "acme", + "kind: AdaptiveDialog\n", + ) + _write_component_map( + tmp_path, + "acme", + target_file="../outside.mcs.yml", + dialog="cr123_WorkdaySystemGetUserContextV3", + ) + runner = _Runner(config={}, agent_slug="acme") + + r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-002"] + + assert r.status == Status.FAILED.value + assert "Workday User Context V2 topic path is unsafe" in r.result + assert "mapped Workday user-context" in r.remediation + + @pytest.mark.parametrize( + "mapped_path", + [ + r"\outside.mcs.yml", + r"D:outside.mcs.yml", + ], + ) + def test_windows_mapped_setup_topic_escape_fails( + self, + tmp_path, + monkeypatch, + mapped_path, + ): + monkeypatch.chdir(tmp_path) + _write_component_map( + tmp_path, + "acme", + setup_path=mapped_path, + dialog="cr123_WorkdaySystemGetUserContextV3", + ) + runner = _Runner(config={}, agent_slug="acme") + + r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-002"] + + assert r.status == Status.FAILED.value + assert "admin user-context topic path is unsafe" in r.result + assert "mapped Workday user-context" in r.remediation + + @pytest.mark.parametrize( + "mapped_path", + [ + r"\outside.mcs.yml", + r"D:outside.mcs.yml", + ], + ) + def test_windows_mapped_target_topic_escape_fails( + self, + tmp_path, + monkeypatch, + mapped_path, + ): + monkeypatch.chdir(tmp_path) + _write_topic( + tmp_path, + "acme", + "kind: AdaptiveDialog\n", + ) + _write_component_map( + tmp_path, + "acme", + target_path=mapped_path, + dialog="cr123_WorkdaySystemGetUserContextV3", + ) + runner = _Runner(config={}, agent_slug="acme") + + r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-002"] + + assert r.status == Status.FAILED.value + assert "Workday User Context V2 topic path is unsafe" in r.result + assert "mapped Workday user-context" in r.remediation + + def test_comment_or_unrelated_field_does_not_count_as_redirect( + self, + tmp_path, + monkeypatch, + ): + monkeypatch.chdir(tmp_path) + _write_topic( + tmp_path, + "acme", + "kind: AdaptiveDialog\n" + "# kind: BeginDialog\n" + "# dialog: cr123_WorkdaySystemGetUserContextV3\n" + "description: cr123_WorkdaySystemGetUserContextV3\n", + ) + _write_installed_topic( + tmp_path, + "acme", + "cr123_WorkdaySystemGetUserContextV3", + ) + runner = _Runner(config={}, agent_slug="acme") + + r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-002"] + + assert r.status == Status.FAILED.value + def test_selected_agent_ignores_wired_sibling(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) _write_topic( tmp_path, "wired", - " - kind: BeginDialog\n" - " dialog: cr123_WorkdaySystemGetUserContextV3\n", + "kind: AdaptiveDialog\n" + "beginDialog:\n" + " kind: BeginDialog\n" + " dialog: cr123_WorkdaySystemGetUserContextV3\n", ) _write_topic(tmp_path, "broken", "kind: AdaptiveDialog\n") _write_installed_topic( @@ -559,8 +750,10 @@ def test_missing_selected_agent_does_not_scan_siblings( _write_topic( tmp_path, "wired", - " - kind: BeginDialog\n" - " dialog: cr123_WorkdaySystemGetUserContextV3\n", + "kind: AdaptiveDialog\n" + "beginDialog:\n" + " kind: BeginDialog\n" + " dialog: cr123_WorkdaySystemGetUserContextV3\n", ) _write_installed_topic( tmp_path, @@ -581,7 +774,10 @@ def test_active_agent_scope_ignores_unrelated_unwired_agent( _write_topic( tmp_path, "active", - " - kind: BeginDialog\n dialog: WorkdaySystemGetUserContextV2\n", + "kind: AdaptiveDialog\n" + "beginDialog:\n" + " kind: BeginDialog\n" + " dialog: WorkdaySystemGetUserContextV2\n", ) _write_installed_topic( tmp_path, @@ -604,7 +800,10 @@ def test_active_agent_scope_does_not_pass_from_other_agent( _write_topic( tmp_path, "other", - " - kind: BeginDialog\n dialog: WorkdaySystemGetUserContextV2\n", + "kind: AdaptiveDialog\n" + "beginDialog:\n" + " kind: BeginDialog\n" + " dialog: WorkdaySystemGetUserContextV2\n", ) _write_installed_topic( tmp_path, diff --git a/tests/flightcheck/checks/test_workday_tenant.py b/tests/flightcheck/checks/test_workday_tenant.py index 3a6246eef..8953b0500 100644 --- a/tests/flightcheck/checks/test_workday_tenant.py +++ b/tests/flightcheck/checks/test_workday_tenant.py @@ -52,7 +52,7 @@ def get(self, *_a: Any, **_k: Any): "tenant": "acme_dpt1", "restBaseUrl": "https://wd2-impl-services1.workday.com/ccx/api", "soapBaseUrl": "https://wd2-impl-services1.workday.com/ccx/service", - "appIdUri": "api://11111111-1111-1111-1111-111111111111", + "workdaySamlEntityId": "http://www.workday.com/acme_dpt1", } @@ -109,10 +109,11 @@ def test_tenant_echoes_connection_fields(self): "acme_dpt1", _FULL_CONFIG["restBaseUrl"], _FULL_CONFIG["soapBaseUrl"], - _FULL_CONFIG["appIdUri"], + _FULL_CONFIG["workdaySamlEntityId"], ): assert value in tenant.result assert "Service Provider ID" in tenant.result + assert "api://" not in tenant.result assert "Tenant Setup - Security" in tenant.remediation assert "intended employees" in tenant.remediation assert "Do not invent an OAuth-client condition" in tenant.remediation diff --git a/tests/flightcheck/test_cli_single_checkpoint.py b/tests/flightcheck/test_cli_single_checkpoint.py index 4e9e784d1..457a3d6f1 100644 --- a/tests/flightcheck/test_cli_single_checkpoint.py +++ b/tests/flightcheck/test_cli_single_checkpoint.py @@ -246,6 +246,58 @@ def test_connect_config_only_merges_provider_owned_fields( "url": "https://foundation.example" } + @pytest.mark.parametrize("schema_version", [2, 3, 4, 5]) + def test_connect_config_flattens_workday_state( + self, tmp_path: Path, schema_version: int + ) -> None: + overlay = tmp_path / "provider.json" + overlay.write_text( + json.dumps( + { + "schemaVersion": schema_version, + "scope": { + "workdayTenant": "acme_impl", + "entraTenantId": "tenant-id", + "dataverseUrl": "https://acme.crm.dynamics.com", + }, + "identifiers": { + "entraAppId": "app-id", + "entraAppIdUri": "api://app-id", + "workdaySamlEntityId": ( + "http://www.workday.com/acme_impl" + ), + }, + "endpoints": { + "restBaseUrl": ( + "https://wd2-impl-services1.workday.com/ccx/api" + ), + "oauthTokenUrl": ( + "https://wd2-impl-services1.workday.com/" + "ccx/oauth2/acme_impl/token" + ), + }, + } + ), + encoding="utf-8", + ) + + merged = cli._merge_connect_config({}, str(overlay)) + + assert merged["tenant"] == "acme_impl" + assert merged["tenantId"] == "tenant-id" + assert merged["dataverseEndpoint"] == ( + "https://acme.crm.dynamics.com" + ) + assert merged["entraAppId"] == "app-id" + assert merged["appIdUri"] == "api://app-id" + assert merged["workdaySamlEntityId"] == ( + "http://www.workday.com/acme_impl" + ) + assert merged["tokenEndpoint"] == ( + "https://wd2-impl-services1.workday.com/" + "ccx/oauth2/acme_impl/token" + ) + @pytest.mark.parametrize( "agent_slug", ( diff --git a/tests/flightcheck/test_registry.py b/tests/flightcheck/test_registry.py index e8882af57..3da191705 100644 --- a/tests/flightcheck/test_registry.py +++ b/tests/flightcheck/test_registry.py @@ -73,9 +73,14 @@ def test_exact_fixed_id(self): assert registry.resolve("WD-PKG-001").key == "WD-PKG-001" def test_exact_beats_family(self): - # WD-CONN-010 / -012 / -102 are fixed entries that must NOT collapse + # These fixed entries must NOT collapse # into the WD-CONN family even though that family exists. - for fixed in ("WD-CONN-010", "WD-CONN-012", "WD-CONN-102"): + for fixed in ( + "WD-CONN-010", + "WD-CONN-012", + "WD-CONN-013", + "WD-CONN-102", + ): assert registry.resolve(fixed).key == fixed assert registry.resolve(fixed).is_family is False @@ -133,6 +138,13 @@ def test_entra_only_checkpoint_needs_no_dataverse(self): # Only the Workday owning function runs (no prereqs). assert [label for label, _ in plan.ordered_fns] == ["Workday"] + def test_obo_sharing_checkpoint_needs_only_dataverse(self): + plan = registry.transitive_requirements("WD-CONN-013") + assert plan.clients == frozenset({registry.DATAVERSE}) + assert registry.PP_ADMIN not in plan.clients + assert plan.requires_dataverse_endpoint is True + assert [label for label, _ in plan.ordered_fns] == ["Workday"] + def test_closure_unions_clients_across_prereqs(self): # WD-CONN-012 itself declares only dataverse, but pulls pp_admin in via # its WD-001 prerequisite — naive one-level resolution would miss it. diff --git a/tests/scripts/test_agentbuilder_object_model.py b/tests/scripts/test_agentbuilder_object_model.py index ed9bbd74e..dd64ac17f 100644 --- a/tests/scripts/test_agentbuilder_object_model.py +++ b/tests/scripts/test_agentbuilder_object_model.py @@ -201,11 +201,27 @@ def CreateOptions(_indent: bool) -> object: class _FakeYamlSerializer: + class _Deserialize: + def __getitem__(self, _element_type: object) -> object: + return lambda _payload: _FakeDialog() + + Deserialize = _Deserialize() + @staticmethod def Serialize(_element: object) -> str: return "kind: AdaptiveDialog" +class _FakeSerialize: + def __getitem__(self, _element_type: object) -> object: + return lambda _element, _options: '{"$kind":"AdaptiveDialog"}' + + +class _FakeBidirectionalJsonSerializer: + Deserialize = _FakeDeserialize() + Serialize = _FakeSerialize() + + def test_validate_object_model_runtime_loads_dependencies( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -242,3 +258,26 @@ def test_object_models_to_yaml_preserves_result_contract( "yaml": "kind: AdaptiveDialog", } ] + + +def test_yaml_to_object_models_preserves_result_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + types = converter._ObjectModelTypes( + bot_element=object, + element_serializer=_FakeElementSerializer, + json_serializer=_FakeBidirectionalJsonSerializer, + yaml_serializer=_FakeYamlSerializer, + ) + monkeypatch.setattr(converter, "_load_object_model", lambda: types) + + assert converter.yaml_to_object_models( + [{"key": "topic", "yaml": "kind: AdaptiveDialog"}] + ) == [ + { + "key": "topic", + "success": True, + "elementType": "FakeDialog", + "objectModel": {"$kind": "AdaptiveDialog"}, + } + ] diff --git a/tests/scripts/test_flow_authorization.py b/tests/scripts/test_flow_authorization.py index 60d2343a0..d3405063c 100644 --- a/tests/scripts/test_flow_authorization.py +++ b/tests/scripts/test_flow_authorization.py @@ -3,8 +3,9 @@ """Structural contracts for the Dataverse flow-authorization tooling.""" +import importlib.util from pathlib import Path - +import sys _REPO_ROOT = Path(__file__).resolve().parents[2] _ALM_DIR = ( @@ -57,7 +58,51 @@ def test_token_fallback_uses_the_kit_authentication_helper() -> None: assert "get_dataverse_token.py" in script assert script.count("Test-DataverseToken -Resource $Resource -Token $tok") == 2 assert "returned a token that was rejected" in script - assert "auth.authenticate(args.environment.rstrip(\"/\"))" in helper + assert "token = auth.authenticate(" in helper + assert 'args.environment.rstrip("/")' in helper + assert "preferred_username=args.preferred_username" in helper + + +def test_token_helper_rejects_mismatched_identity_without_emitting_token( + monkeypatch, + capsys, +) -> None: + spec = importlib.util.spec_from_file_location( + "get_dataverse_token_test", + _ALM_DIR / "get_dataverse_token.py", + ) + assert spec and spec.loader + helper = importlib.util.module_from_spec(spec) + spec.loader.exec_module(helper) + monkeypatch.setattr( + helper.auth, + "authenticate", + lambda *_args, **_kwargs: "secret-token", + ) + monkeypatch.setattr( + helper, + "require_identity", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + helper.WorkdayConnectIdentityError("different account") + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "get_dataverse_token.py", + "--environment", + "https://example.crm.dynamics.com", + "--preferred-username", + "maker@example.com", + ], + ) + + assert helper.main() == 1 + captured = capsys.readouterr() + assert "different account" in captured.err + assert "secret-token" not in captured.out + assert "secret-token" not in captured.err def test_candidate_tokens_are_attached_to_dataverse_requests() -> None: diff --git a/tests/scripts/test_install_workday_da_extension.py b/tests/scripts/test_install_workday_da_extension.py index 99a6776ff..f851852bc 100644 --- a/tests/scripts/test_install_workday_da_extension.py +++ b/tests/scripts/test_install_workday_da_extension.py @@ -31,12 +31,14 @@ def test_parse_profiles_reads_cloud_and_active_marker(): { "index": "1", "active": False, + "username": "user@contoso.com", "cloud": "Public", "environment_url": None, }, { "index": "2", "active": True, + "username": "user@contoso.com", "cloud": "Preprod", "environment_url": "https://org.crm10.dynamics.com", }, @@ -59,7 +61,7 @@ def runner(command, *, capture_output, timeout): ) return _result() - schema = m.install_workday_package( + result = m.install_workday_package( "https://org.crm10.dynamics.com", "runtime", ring="preprod", @@ -67,7 +69,8 @@ def runner(command, *, capture_output, timeout): runner=runner, ) - assert schema == "msdyn_EssWorkdayRuntime" + assert result["schemaName"] == "msdyn_EssWorkdayRuntime" + assert result["authenticatedAccount"] == "user@contoso.com" assert calls[-1][0] == [ "pac.exe", "application", @@ -262,6 +265,74 @@ def runner(command, *, capture_output, timeout): ) +def test_preprod_auth_selects_exact_environment_and_username(): + import install_workday_da_extension as m + + calls = [] + auth_lists = iter( + [ + ( + "[1] user1@contoso.com Preprod " + "https://org.crm10.dynamics.com\n" + "[2] user2@contoso.com Preprod " + "https://org.crm10.dynamics.com/\n" + ), + ( + "[1] user1@contoso.com Preprod " + "https://org.crm10.dynamics.com\n" + "[2] * user2@contoso.com Preprod " + "https://org.crm10.dynamics.com/\n" + ), + ] + ) + + def runner(command, *, capture_output, timeout): + calls.append([str(part) for part in command]) + if command[1:3] == ["auth", "list"]: + return _result(stdout=next(auth_lists)) + return _result() + + profile = m.ensure_pac_auth( + Path("pac.exe"), + ring="preprod", + environment_url="https://org.crm10.dynamics.com", + preferred_username="user2@contoso.com", + runner=runner, + ) + + assert calls[1] == ["pac.exe", "auth", "select", "--index", "2"] + assert profile["username"] == "user2@contoso.com" + assert profile["active"] is True + + +def test_preprod_auth_rejects_wrong_account_after_profile_creation(): + import install_workday_da_extension as m + + auth_lists = iter( + [ + "", + ( + "[1] * wrong@contoso.com Preprod " + "https://org.crm10.dynamics.com\n" + ), + ] + ) + + def runner(command, *, capture_output, timeout): + if command[1:3] == ["auth", "list"]: + return _result(stdout=next(auth_lists)) + return _result() + + with pytest.raises(m.PacCliError, match="does not match the requested"): + m.ensure_pac_auth( + Path("pac.exe"), + ring="preprod", + environment_url="https://org.crm10.dynamics.com", + preferred_username="maker@contoso.com", + runner=runner, + ) + + def test_legacy_da_uses_targeted_appsource_application(): import install_workday_da_extension as m @@ -273,7 +344,7 @@ def runner(command, *, capture_output, timeout): return _result(stdout="[1] * user@contoso.com Public\n") return _result() - schema = m.install_workday_package( + result = m.install_workday_package( "https://org.crm.dynamics.com", "legacy-da", ring="prod", @@ -281,7 +352,7 @@ def runner(command, *, capture_output, timeout): runner=runner, ) - assert schema == "msdyn_EssDAHRWorkday" + assert result["schemaName"] == "msdyn_EssDAHRWorkday" assert calls[-1][-1] == "msdyn_EssDAHRWorkdayHCM" diff --git a/tests/scripts/test_minimalbot_detection.py b/tests/scripts/test_minimalbot_detection.py index 274c75eb9..ad9922ddd 100644 --- a/tests/scripts/test_minimalbot_detection.py +++ b/tests/scripts/test_minimalbot_detection.py @@ -15,6 +15,8 @@ from __future__ import annotations +import json +from types import SimpleNamespace from typing import Any import pytest @@ -266,7 +268,12 @@ def _minimalbot_config(folder: str) -> dict[str, Any]: return { "powerPlatformApiEndpoint": TEST_ENDPOINT, "environmentId": ENV_ID, - "agent": {"botId": BOT_ID, "folder": folder, "releaseLine": "da"}, + "agent": { + "botId": BOT_ID, + "folder": folder, + "releaseLine": "da", + "schemaName": "contoso", + }, } @@ -391,9 +398,11 @@ def __init__(self): self.signed_in_username = "tester@example.com" self.authenticated = False self.real_push = False + self.topic_updates = [] - def authenticate(self): + def authenticate(self, preferred_username=None): self.authenticated = True + self.preferred_username = preferred_username def push_agent_evaluations(self, agent_dir, *, dry_run=False, only_globs=None): if dry_run: @@ -406,6 +415,13 @@ def push_agent_evaluations(self, agent_dir, *, dry_run=False, only_globs=None): "testSetId": "real-id", "cases": "1"}], "componentCount": 2, "verifiedComponents": 2} + def update_dialog_components(self, updates): + self.topic_updates = updates + return { + "updatedComponents": len(updates), + "verifiedComponents": len(updates), + } + def _patch_client(monkeypatch, fake): monkeypatch.setattr( @@ -466,3 +482,578 @@ def _no_input(*a, **k): # pragma: no cover - must never be reached assert "Dry run — no changes pushed" in out assert fake.authenticated is False assert fake.real_push is False + + +def _write_existing_topic_change(root): + baseline = root / ".baseline" / "topics" + working = root / "topics" + baseline.mkdir(parents=True) + working.mkdir(parents=True) + baseline_body = "kind: AdaptiveDialog\nbeginDialog:\n kind: OnRedirect\n" + working_body = ( + f"{baseline_body}" + " actions:\n" + " - kind: BeginDialog\n" + " dialog: contoso.topic.WorkdaySystemGetUserContextV2\n" + ) + baseline.joinpath("Setusercontext.mcs.yml").write_text( + baseline_body, + encoding="utf-8", + ) + working.joinpath("Setusercontext.mcs.yml").write_text( + working_body, + encoding="utf-8", + ) + root.joinpath(".component-map.json").write_text( + json.dumps( + { + "topics/Setusercontext.mcs.yml": { + "componentKind": "DialogComponent", + "componentId": "setup-topic", + "schemaName": "contoso.topic.Setusercontext", + "displayName": "[Admin] - User Context - Setup", + } + } + ), + encoding="utf-8", + ) + + +def _fake_topic_conversion(items): + return [ + { + "key": item["key"], + "success": True, + "elementType": "AdaptiveDialog", + "objectModel": { + "$kind": "AdaptiveDialog", + "source": item["yaml"], + }, + } + for item in items + ] + + +def _write_workday_topics(root, *, changed=False): + baseline = root / ".baseline" / "topics" + working = root / "topics" + baseline.mkdir(parents=True) + working.mkdir(parents=True) + component_map = {} + for index in (1, 2): + path = f"topics/workday-{index}.mcs.yml" + baseline_body = f"kind: AdaptiveDialog\nvalue: before-{index}\n" + working_body = ( + f"kind: AdaptiveDialog\nvalue: after-{index}\n" + if changed and index == 1 + else baseline_body + ) + baseline.joinpath(f"workday-{index}.mcs.yml").write_text( + baseline_body, + encoding="utf-8", + ) + working.joinpath(f"workday-{index}.mcs.yml").write_text( + working_body, + encoding="utf-8", + ) + component_map[path] = { + "componentKind": "DialogComponent", + "componentId": f"workday-{index}", + "schemaName": f"contoso.topic.WorkdayTopic{index}", + "displayName": f"Workday Topic {index}", + } + root.joinpath(".component-map.json").write_text( + json.dumps(component_map), + encoding="utf-8", + ) + return sorted(component_map) + + +def test_scoped_minimalbot_topic_dry_run_is_non_mutating( + tmp_path, monkeypatch, capsys +): + _write_existing_topic_change(tmp_path) + fake = _RecordingClient() + _patch_client(monkeypatch, fake) + monkeypatch.setattr(push, "yaml_to_object_models", _fake_topic_conversion) + + push._minimalbot_push( + _minimalbot_config(str(tmp_path)), + dry_run=True, + only_globs=["topics/Setusercontext.mcs.yml"], + ) + + out = capsys.readouterr().out + assert "Would update 1 existing topic" in out + assert fake.authenticated is False + assert fake.topic_updates == [] + + +def test_scoped_minimalbot_topic_push_pins_account_and_updates_baseline( + tmp_path, monkeypatch +): + _write_existing_topic_change(tmp_path) + fake = _RecordingClient() + _patch_client(monkeypatch, fake) + monkeypatch.setattr(push, "yaml_to_object_models", _fake_topic_conversion) + + push._minimalbot_push( + _minimalbot_config(str(tmp_path)), + auto_yes=True, + only_globs=["topics/Setusercontext.mcs.yml"], + preferred_username="maker@contoso.com", + ) + + assert fake.authenticated is True + assert fake.preferred_username == "maker@contoso.com" + assert fake.topic_updates[0]["componentId"] == "setup-topic" + assert ( + tmp_path / ".baseline" / "topics" / "Setusercontext.mcs.yml" + ).read_text(encoding="utf-8") == ( + tmp_path / "topics" / "Setusercontext.mcs.yml" + ).read_text(encoding="utf-8") + + +def test_scoped_minimalbot_topic_activation_does_not_require_content_diff( + tmp_path, +): + paths = _write_workday_topics(tmp_path) + + plan = push._minimalbot_topic_update_plan( + str(tmp_path), + paths, + activate_topics=True, + agent_schema="contoso", + ) + + assert len(plan) == 2 + assert all(entry["state"] == "Active" for entry in plan) + assert all(entry["status"] == "Active" for entry in plan) + assert all("requireCleanDiagnostics" not in entry for entry in plan) + assert all("dialog" not in entry for entry in plan) + + +def test_minimalbot_activation_rejects_non_workday_topic(tmp_path): + _write_existing_topic_change(tmp_path) + + with pytest.raises( + mbe.MinimalBotEvaluationError, + match="No mapped Workday dialog topics", + ): + push._minimalbot_topic_update_plan( + str(tmp_path), + ["topics/Setusercontext.mcs.yml"], + activate_topics=True, + agent_schema="contoso", + ) + + +def test_workday_topic_resolution_enforces_reviewed_ess_hr_count(tmp_path): + _write_workday_topics(tmp_path) + component_map_path = tmp_path / ".component-map.json" + component_map = json.loads(component_map_path.read_text(encoding="utf-8")) + for entry in component_map.values(): + entry["schemaName"] = entry["schemaName"].replace( + "contoso", + "gptagent_copilotforemployeeselfservicehr", + ) + component_map_path.write_text( + json.dumps(component_map), + encoding="utf-8", + ) + + with pytest.raises( + mbe.MinimalBotEvaluationError, + match="expected 21", + ): + mbe.resolve_workday_dialogs( + tmp_path, + "gptagent_copilotforemployeeselfservicehr", + ) + + +def test_minimalbot_activation_rejects_local_topic_content_changes( + tmp_path, + monkeypatch, +): + paths = _write_workday_topics(tmp_path, changed=True) + baseline_workflow = tmp_path / ".baseline" / "workflows" / "flow.json" + working_workflow = tmp_path / "workflows" / "flow.json" + baseline_workflow.parent.mkdir(parents=True) + working_workflow.parent.mkdir(parents=True) + baseline_workflow.write_text('{"state":"before"}', encoding="utf-8") + working_workflow.write_text('{"state":"unpushed"}', encoding="utf-8") + fake = _RecordingClient() + _patch_client(monkeypatch, fake) + monkeypatch.setattr(push, "yaml_to_object_models", _fake_topic_conversion) + + with pytest.raises( + SystemExit, + ): + push._minimalbot_push( + _minimalbot_config(str(tmp_path)), + auto_yes=True, + only_globs=["*"], + activate_topics=True, + preferred_username="maker@contoso.com", + ) + + assert paths + assert fake.topic_updates == [] + assert baseline_workflow.read_text(encoding="utf-8") == ( + '{"state":"before"}' + ) + + +def test_minimalbot_dialog_update_uses_update_envelope_and_verifies( + monkeypatch, +): + client = _mb_client() + client._token = "token" + before_component = { + "$kind": "DialogComponent", + "id": "setup-topic", + "schemaName": "contoso.topic.Setusercontext", + "dialog": {"$kind": "AdaptiveDialog", "state": "before"}, + } + desired_dialog = {"$kind": "AdaptiveDialog", "state": "after"} + after_component = { + **before_component, + "dialog": { + **desired_dialog, + "diagnostics": [{"$kind": "Informational"}], + }, + } + reads = iter( + ( + { + "changeToken": "token-1", + "botComponentChanges": [ + { + "$kind": "BotComponentInsert", + "component": before_component, + } + ], + }, + { + "changeToken": "token-2", + "botComponentChanges": [ + { + "$kind": "BotComponentInsert", + "component": after_component, + } + ], + }, + ) + ) + monkeypatch.setattr(client, "read_components", lambda: next(reads)) + captured = {} + + def request(method, url, *, body, operation): + captured.update( + method=method, + url=url, + body=body, + operation=operation, + ) + return SimpleNamespace(status_code=200), {} + + monkeypatch.setattr(client, "_request", request) + + result = client.update_dialog_components( + [ + { + "componentId": "setup-topic", + "schemaName": "contoso.topic.Setusercontext", + "expectedDialog": before_component["dialog"], + "dialog": desired_dialog, + } + ] + ) + + assert result["verifiedComponents"] == 1 + assert captured["method"] == "PUT" + assert captured["body"]["changeToken"] == "token-1" + assert captured["body"]["botComponentChanges"][0]["$kind"] == ( + "BotComponentUpdate" + ) + + +def test_minimalbot_dialog_activation_preserves_content(monkeypatch): + client = _mb_client() + client._token = "token" + dialog = {"$kind": "AdaptiveDialog"} + before_component = { + "$kind": "DialogComponent", + "id": "setup-topic", + "schemaName": "contoso.topic.Setusercontext", + "state": "Inactive", + "status": "Inactive", + "dialog": dialog, + } + after_component = { + **before_component, + "state": "Active", + "status": "Active", + } + reads = iter( + ( + { + "changeToken": "token-1", + "botComponentChanges": [ + { + "$kind": "BotComponentInsert", + "component": before_component, + } + ], + }, + { + "changeToken": "token-2", + "botComponentChanges": [ + { + "$kind": "BotComponentInsert", + "component": after_component, + } + ], + }, + ) + ) + monkeypatch.setattr(client, "read_components", lambda: next(reads)) + captured = {} + + def request(_method, _url, *, body, operation): + captured["body"] = body + captured["operation"] = operation + return SimpleNamespace(status_code=200), {} + + monkeypatch.setattr(client, "_request", request) + + result = client.update_dialog_components( + [ + { + "componentId": "setup-topic", + "schemaName": "contoso.topic.Setusercontext", + "state": "Active", + "status": "Active", + } + ] + ) + + updated = captured["body"]["botComponentChanges"][0]["component"] + assert result["verifiedComponents"] == 1 + assert updated["dialog"] == dialog + assert updated["state"] == "Active" + assert updated["status"] == "Active" + + +def test_minimalbot_activation_rejects_blocking_diagnostics(monkeypatch): + client = _mb_client() + client._token = "token" + component = { + "$kind": "DialogComponent", + "id": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + "state": "Active", + "status": "Active", + "dialog": { + "$kind": "AdaptiveDialog", + "diagnostics": [ + { + "$kind": "InvalidReferenceError", + "errorCode": "NotFound", + "errorMessage": "CloudFlow not found", + } + ], + }, + } + monkeypatch.setattr( + client, + "read_components", + lambda: { + "changeToken": "token-1", + "botComponentChanges": [ + { + "$kind": "BotComponentInsert", + "component": component, + } + ], + }, + ) + + with pytest.raises( + mbe.MinimalBotEvaluationError, + match="blocking diagnostics", + ): + client.verify_dialog_components( + [ + { + "componentId": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + "state": "Active", + "status": "Active", + "requireCleanDiagnostics": True, + } + ] + ) + + +@pytest.mark.parametrize( + "components", + [ + [], + [ + { + "$kind": "DialogComponent", + "id": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + }, + { + "$kind": "DialogComponent", + "id": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + }, + ], + ], +) +def test_minimalbot_verification_rejects_missing_or_duplicate_ids( + monkeypatch, + components, +): + client = _mb_client() + client._token = "token" + monkeypatch.setattr( + client, + "read_components", + lambda: { + "changeToken": "token-1", + "botComponentChanges": [ + {"$kind": "BotComponentInsert", "component": component} + for component in components + ], + }, + ) + + with pytest.raises( + mbe.MinimalBotEvaluationError, + match="missing or duplicate component ID", + ): + client.verify_dialog_components( + [ + { + "componentId": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + } + ] + ) + + +@pytest.mark.parametrize( + "component", + [ + { + "$kind": "UnexpectedComponent", + "id": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + }, + { + "$kind": "DialogComponent", + "id": "workday-topic", + "schemaName": "contoso.topic.OtherTopic", + }, + ], +) +def test_minimalbot_verification_rejects_kind_or_schema_drift( + monkeypatch, + component, +): + client = _mb_client() + client._token = "token" + monkeypatch.setattr( + client, + "read_components", + lambda: { + "changeToken": "token-1", + "botComponentChanges": [ + { + "$kind": "BotComponentInsert", + "component": component, + } + ], + }, + ) + + with pytest.raises( + mbe.MinimalBotEvaluationError, + match="verification failed", + ): + client.verify_dialog_components( + [ + { + "componentId": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + } + ] + ) + + +def test_minimalbot_activation_reports_diagnostics_without_rejecting( + monkeypatch, +): + client = _mb_client() + client._token = "token" + component = { + "$kind": "DialogComponent", + "id": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + "state": "Active", + "status": "Active", + "dialog": { + "$kind": "AdaptiveDialog", + "diagnostics": [ + { + "$kind": "InvalidReferenceError", + "errorCode": "NotFound", + "errorMessage": "CloudFlow not found", + } + ], + }, + } + monkeypatch.setattr( + client, + "read_components", + lambda: { + "changeToken": "token-1", + "botComponentChanges": [ + { + "$kind": "BotComponentInsert", + "component": component, + } + ], + }, + ) + + result = client.verify_dialog_components( + [ + { + "componentId": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + "state": "Active", + "status": "Active", + } + ] + ) + + assert result["verifiedComponents"] == 1 + assert result["activeComponents"] == 1 + assert result["blockingDiagnostics"] == [ + { + "path": "$.dialog.diagnostics[0]", + "kind": "InvalidReferenceError", + "errorCode": "NotFound", + "message": "CloudFlow not found", + "referenceType": "", + "referenceId": "", + "componentId": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + } + ] diff --git a/tests/scripts/test_workday_connect_agent.py b/tests/scripts/test_workday_connect_agent.py new file mode 100644 index 000000000..065dd3d81 --- /dev/null +++ b/tests/scripts/test_workday_connect_agent.py @@ -0,0 +1,681 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for live native Workday agent completion evidence.""" + +from __future__ import annotations + +import json +from pathlib import Path +import sys +from types import SimpleNamespace + + +def _state(): + from workday_connect_model import default_state + + state = default_state() + state["scope"].update( + { + "environmentId": "environment-id", + "dataverseUrl": "https://example.crm.dynamics.com", + "agent": { + "slug": "ess-hr", + "botId": "bot-id", + "schemaName": "contoso", + }, + } + ) + state["operators"]["powerPlatformMaker"] = {"username": "maker@example.com"} + return state + + +def _write_workspace(root: Path) -> None: + agent = root / "workspace" / "agents" / "ess-hr" + topics = agent / "topics" + topics.mkdir(parents=True) + component_map = {} + for index in (1, 2): + path = f"topics/workday-{index}.mcs.yml" + topics.joinpath(f"workday-{index}.mcs.yml").write_text( + "kind: AdaptiveDialog\n", + encoding="utf-8", + ) + component_map[path] = { + "componentKind": "DialogComponent", + "componentId": f"topic-{index}", + "schemaName": f"contoso.topic.WorkdayTopic{index}", + "displayName": f"Workday Topic {index}", + } + agent.joinpath(".component-map.json").write_text( + json.dumps(component_map), + encoding="utf-8", + ) + local = root / ".local" + local.mkdir() + local.joinpath("config.json").write_text( + json.dumps( + { + "releaseLine": "da", + "environmentId": "environment-id", + "activeAgent": "ess-hr", + "powerPlatformApiEndpoint": ("https://api.test.powerplatform.com"), + "agent": { + "slug": "ess-hr", + "botId": "bot-id", + "schemaName": "contoso", + "folder": str(agent), + "releaseLine": "da", + }, + "agents": [ + { + "slug": "ess-hr", + "botId": "bot-id", + "schemaName": "contoso", + "folder": str(agent), + } + ], + } + ), + encoding="utf-8", + ) + + +class _VerifiedClient: + def __init__(self): + self.signed_in_username = "" + self.expectations = [] + + def authenticate(self, preferred_username=None): + self.signed_in_username = preferred_username + + def verify_dialog_components(self, expectations): + self.expectations = expectations + return { + "verifiedComponents": len(expectations), + "activeComponents": len(expectations), + "blockingDiagnostics": [], + } + + +def test_agent_binding_is_built_from_live_checks_and_components( + tmp_path: Path, +) -> None: + from workday_connect_agent import verify_agent_binding + + _write_workspace(tmp_path) + client = _VerifiedClient() + calls = [] + + def checkpoint(_root, _state, checkpoint_id, *, preferred_username): + calls.append((checkpoint_id, preferred_username)) + return "Passed" + + evidence = verify_agent_binding( + tmp_path, + _state(), + checkpoint_verifier=checkpoint, + client_factory=lambda _config: client, + ) + + assert calls == [ + ("WD-REST-002", "maker@example.com"), + ("WD-CONN-013", "maker@example.com"), + ] + assert evidence["workdayTopics"] == { + "expected": 2, + "verified": 2, + "active": 2, + "blockingDiagnostics": [], + } + assert all( + expectation["requireCleanDiagnostics"] is False + for expectation in client.expectations + ) + + +def test_topic_activation_skips_checkpoints_and_allows_diagnostics( + tmp_path: Path, +) -> None: + from workday_connect_agent import verify_topic_activation + + _write_workspace(tmp_path) + client = _VerifiedClient() + + def verify(expectations): + client.expectations = expectations + return { + "verifiedComponents": len(expectations), + "activeComponents": len(expectations), + "blockingDiagnostics": [ + { + "errorCode": "NotFound", + "errorMessage": "CloudFlow not found", + "referenceType": "CloudFlow", + } + ], + } + + client.verify_dialog_components = verify + + evidence = verify_topic_activation( + tmp_path, + _state(), + client_factory=lambda _config: client, + ) + + assert evidence["checkpoints"] == {} + assert evidence["workdayTopics"]["active"] == 2 + assert evidence["workdayTopics"]["blockingDiagnostics"] + assert all( + expectation["requireCleanDiagnostics"] is False + for expectation in client.expectations + ) + + +def test_agent_binding_rejects_environment_drift(tmp_path: Path) -> None: + import pytest + + from workday_connect_agent import ( + WorkdayConnectAgentError, + verify_agent_binding, + ) + + _write_workspace(tmp_path) + state = _state() + state["scope"]["environmentId"] = "other-environment" + + with pytest.raises(WorkdayConnectAgentError, match="different environments"): + verify_agent_binding( + tmp_path, + state, + checkpoint_verifier=lambda *_args, **_kwargs: "Passed", + client_factory=lambda _config: _VerifiedClient(), + ) + + +def test_controller_persists_phase_blocker( + tmp_path: Path, + monkeypatch, +) -> None: + import pytest + + import workday_connect + from workday_connect_store import WorkdayConnectStore + + store = WorkdayConnectStore(tmp_path) + store.initialize() + for action in ("verify-target", "verify-package"): + store.complete_action( + "preflight", + action, + evidence={"outcome": "verified"}, + ) + store.set_phase_status("preflight", "complete") + monkeypatch.setattr( + sys, + "argv", + [ + "workday_connect.py", + "--root", + str(tmp_path), + "set-workday-tenant", + "--tenant", + "", + ], + ) + + with pytest.raises(SystemExit) as exc: + workday_connect.main() + + assert exc.value.code == 1 + status = WorkdayConnectStore(tmp_path).status() + assert status["nextPhaseId"] == "entra" + assert status["blocker"]["operation"] == "set-workday-tenant" + + +def test_controller_surfaces_blocker_persistence_failure( + tmp_path: Path, + monkeypatch, + capsys, +) -> None: + import pytest + + import workday_connect + from workday_connect_store import WorkdayConnectStore + + def fail_persistence(*_args, **_kwargs): + raise OSError("state is read-only") + + monkeypatch.setattr( + WorkdayConnectStore, + "set_phase_status", + fail_persistence, + ) + monkeypatch.setattr( + sys, + "argv", + [ + "workday_connect.py", + "--root", + str(tmp_path), + "set-workday-tenant", + "--tenant", + "", + ], + ) + + with pytest.raises(SystemExit) as exc: + workday_connect.main() + + assert exc.value.code == 1 + error = capsys.readouterr().err + assert '"blockerPersistenceError": "state is read-only"' in error + + +def test_controller_emits_structured_runtime_error_details( + tmp_path: Path, + monkeypatch, + capsys, +) -> None: + import pytest + + import workday_connect + from workday_connect_runtime import WorkdayConnectRuntimeError + + details = { + "connector": "shared_workdaysoap", + "candidateConnections": [ + { + "connectionId": "connection-id", + "displayName": "Workday", + } + ], + } + + def raise_ambiguity(_args, _store): + raise WorkdayConnectRuntimeError( + "Select one connected Workday connection.", + details=details, + ) + + monkeypatch.setitem( + workday_connect._COMMAND_HANDLERS, + "status", + raise_ambiguity, + ) + monkeypatch.setattr( + sys, + "argv", + [ + "workday_connect.py", + "--root", + str(tmp_path), + "status", + ], + ) + + with pytest.raises(SystemExit) as exc: + workday_connect.main() + + assert exc.value.code == 1 + error = capsys.readouterr().err + payload = json.loads( + error.split(workday_connect.ERROR_MARKER, maxsplit=1)[1] + ) + assert payload["details"] == details + + +def test_record_connections_uses_live_verification( + tmp_path: Path, + monkeypatch, +) -> None: + import workday_connect + import workday_connect_model as model + from workday_connect_store import WorkdayConnectStore + + store = WorkdayConnectStore(tmp_path) + store.initialize() + for phase_id in ("preflight", "entra", "workday-admin"): + for action in model.PHASE_REQUIRED_ACTIONS[phase_id]: + store.complete_action( + phase_id, + action, + evidence={"outcome": "verified"}, + ) + store.set_phase_status(phase_id, "complete") + monkeypatch.setattr( + workday_connect, + "verify_physical_connections", + lambda *_args, **_kwargs: { + "makerUsername": "maker@example.com", + "connectionIds": { + "workday": "workday-id", + "dataverse": "dataverse-id", + }, + "connections": [ + { + "connector": "shared_workdaysoap", + "displayName": "Workday", + }, + { + "connector": "shared_commondataserviceforapps", + "displayName": "Dataverse", + }, + ], + }, + ) + + result = workday_connect._record_connections( + SimpleNamespace( + evidence_json=None, + workday_connection_id=None, + dataverse_connection_id=None, + confirm_workday_target=True, + ), + store, + ) + + connections = store.load()["phases"]["connections"] + assert result["verified"] is True + assert connections["status"] == "complete" + assert connections["completedActions"] == ["physical-connections-verified"] + assert connections["evidence"][0]["connectionIds"] == { + "workday": "workday-id", + "dataverse": "dataverse-id", + } + + +def test_record_connections_previews_before_target_confirmation( + tmp_path: Path, + monkeypatch, +) -> None: + import workday_connect + from workday_connect_store import WorkdayConnectStore + + store = WorkdayConnectStore(tmp_path) + store.initialize() + store.merge_section( + "identifiers", + { + "workdaySamlEntityId": "http://www.workday.com/contoso", + "oauthClientId": "client-id", + }, + ) + store.merge_section( + "endpoints", + {"oauthTokenUrl": "https://example.workday.com/oauth/token"}, + ) + monkeypatch.setattr( + workday_connect, + "verify_physical_connections", + lambda *_args, **_kwargs: { + "makerUsername": "maker@example.com", + "connectionIds": { + "workday": "workday-id", + "dataverse": "dataverse-id", + }, + "connections": [ + { + "connector": "shared_workdaysoap", + "displayName": "Workday", + }, + { + "connector": "shared_commondataserviceforapps", + "displayName": "Dataverse", + }, + ], + }, + ) + + result = workday_connect._record_connections( + SimpleNamespace( + evidence_json=None, + workday_connection_id=None, + dataverse_connection_id=None, + confirm_workday_target=False, + ), + store, + ) + + assert result["requiresConfirmation"] is True + assert result["workdayTarget"] == { + "resourceUrl": "http://www.workday.com/contoso", + "oauthTokenUrl": "https://example.workday.com/oauth/token", + "oauthClientId": "client-id", + } + assert store.load()["phases"]["connections"]["status"] == "pending" + + +def test_record_agent_binding_completes_only_from_verifier_output( + tmp_path: Path, + monkeypatch, +) -> None: + import workday_connect + import workday_connect_model as model + from workday_connect_store import WorkdayConnectStore + + store = WorkdayConnectStore(tmp_path) + store.initialize() + store.merge_section( + "scope", + { + "environmentId": "environment-id", + "packageFlavor": "runtime", + "agent": { + "slug": "ess-hr", + "botId": "bot-id", + "schemaName": "contoso", + }, + }, + ) + store.merge_section( + "operators", + {"powerPlatformMaker": {"username": "maker@example.com"}}, + ) + for phase_id in ("preflight", "entra", "workday-admin", "connections"): + for action in model.PHASE_REQUIRED_ACTIONS[phase_id]: + store.complete_action( + phase_id, + action, + evidence={"outcome": "verified"}, + ) + store.set_phase_status(phase_id, "complete") + for action in ( + "connection-references-bound", + "runtime-flows-active", + "delegated-authorization-configured", + ): + store.complete_action( + "runtime", + action, + evidence={"outcome": "verified"}, + ) + monkeypatch.setattr( + workday_connect, + "verify_agent_binding", + lambda *_args, **_kwargs: { + "environmentId": "environment-id", + "botId": "bot-id", + "makerUsername": "maker@example.com", + "checkpoints": { + "WD-REST-002": "Passed", + "WD-CONN-013": "Passed", + }, + "workdayTopics": { + "expected": 21, + "verified": 21, + "active": 21, + "blockingDiagnostics": [ + { + "errorCode": "NotFound", + "errorMessage": "CloudFlow not found", + "referenceType": "CloudFlow", + } + ], + }, + }, + ) + attachment_file = tmp_path / "attachment.json" + attachment_file.write_text( + json.dumps( + { + "outcome": "maker-confirmed", + "botId": "bot-id", + "flowNames": ["ESS Workday Runtime REST Execution"], + "parameterSharingOutcome": ( + "enabled-for-exposed-connections" + ), + } + ), + encoding="utf-8", + ) + + result = workday_connect._record_agent_binding( + SimpleNamespace(attachment_file=attachment_file), + store, + ) + + runtime = store.load()["phases"]["runtime"] + assert result["verified"] is True + assert runtime["status"] == "complete" + assert "workday-topics-activated" in runtime["completedActions"] + topic_evidence = next( + item + for item in runtime["evidence"] + if item["action"] == "workday-topics-activated" + ) + assert topic_evidence["blockingDiagnostics"] == [ + { + "errorCode": "NotFound", + "errorMessage": "CloudFlow not found", + "referenceType": "CloudFlow", + } + ] + + +def test_record_topic_activation_does_not_infer_runtime_failure_from_diagnostics( + tmp_path: Path, + monkeypatch, +) -> None: + import workday_connect + import workday_connect_model as model + from workday_connect_store import WorkdayConnectStore + + store = WorkdayConnectStore(tmp_path) + store.initialize() + for phase_id in ( + "preflight", + "entra", + "workday-admin", + "connections", + ): + for action in model.PHASE_REQUIRED_ACTIONS[phase_id]: + store.complete_action( + phase_id, + action, + evidence={"outcome": "verified"}, + ) + store.set_phase_status(phase_id, "complete") + monkeypatch.setattr( + workday_connect, + "verify_topic_activation", + lambda *_args, **_kwargs: { + "environmentId": "environment-id", + "botId": "bot-id", + "makerUsername": "maker@example.com", + "checkpoints": {}, + "workdayTopics": { + "expected": 21, + "verified": 21, + "active": 21, + "blockingDiagnostics": [ + { + "errorCode": "NotFound", + "errorMessage": "CloudFlow not found", + "referenceType": "CloudFlow", + } + ], + }, + }, + ) + + result = workday_connect._record_topic_activation( + SimpleNamespace(), + store, + ) + + runtime = store.load()["phases"]["runtime"] + assert result["verified"] is True + assert result["diagnosticsObserved"] == 1 + assert "workday-topics-activated" in runtime["completedActions"] + assert runtime["status"] == "active" + assert runtime["blocker"] is None + + +def test_record_agent_binding_rejects_manual_boolean_evidence( + tmp_path: Path, +) -> None: + import pytest + + import workday_connect + from workday_connect_store import ( + WorkdayConnectStore, + WorkdayConnectStoreError, + ) + + with pytest.raises( + WorkdayConnectStoreError, + match="requires a JSON input file", + ): + workday_connect._record_agent_binding( + SimpleNamespace(attachment_file=None), + WorkdayConnectStore(tmp_path), + ) + + +def test_record_validation_failure_blocks_employee_phase( + tmp_path: Path, +) -> None: + import workday_connect + import workday_connect_model as model + from workday_connect_store import WorkdayConnectStore + + store = WorkdayConnectStore(tmp_path) + store.initialize() + for phase_id in ( + "preflight", + "entra", + "workday-admin", + "connections", + "runtime", + ): + for action in model.PHASE_REQUIRED_ACTIONS[phase_id]: + store.complete_action( + phase_id, + action, + evidence={"outcome": "verified"}, + ) + store.set_phase_status(phase_id, "complete") + evidence_file = tmp_path / "employee-failure.json" + evidence_file.write_text( + json.dumps( + { + "failureCategory": "workday-access-denied", + "timestamp": "2026-09-25T00:00:00Z", + "remediation": "Verify the employee's Workday access.", + } + ), + encoding="utf-8", + ) + + result = workday_connect._record_validation_failure( + SimpleNamespace(evidence_file=evidence_file), + store, + ) + + assert result["recorded"] is True + phase = store.load()["phases"]["employee-validation"] + assert phase["status"] == "blocked" + assert phase["blocker"]["errorType"] == "workday-access-denied" + assert phase["blocker"]["capturedAt"] == "2026-09-25T00:00:00Z" diff --git a/tests/scripts/test_workday_connect_auth.py b/tests/scripts/test_workday_connect_auth.py new file mode 100644 index 000000000..ec6322383 --- /dev/null +++ b/tests/scripts/test_workday_connect_auth.py @@ -0,0 +1,52 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for Workday connect identity continuity.""" + +from __future__ import annotations + +import base64 +import json + +import pytest + + +def _token(username: str, tenant_id: str = "tenant-id") -> str: + payload = base64.urlsafe_b64encode( + json.dumps( + {"preferred_username": username, "tid": tenant_id} + ).encode() + ).decode().rstrip("=") + return f"header.{payload}.signature" + + +def test_token_identity_returns_only_safe_provenance() -> None: + import workday_connect_auth as auth + + assert auth.token_identity(_token("maker@example.com")) == { + "username": "maker@example.com", + "tenantId": "tenant-id", + } + + +def test_require_identity_rejects_wrong_account() -> None: + import workday_connect_auth as auth + + with pytest.raises(auth.WorkdayConnectIdentityError, match="different account"): + auth.require_identity( + _token("other@example.com"), + preferred_username="maker@example.com", + ) + + +def test_authentication_plan_distinguishes_credential_stores() -> None: + import workday_connect_auth as auth + + stores = {item["store"] for item in auth.authentication_plan()} + + assert stores == { + "azure-cli-graph", + "pac", + "dataverse-msal", + "workday-connector", + } diff --git a/tests/scripts/test_workday_connect_contracts.py b/tests/scripts/test_workday_connect_contracts.py new file mode 100644 index 000000000..8effa9062 --- /dev/null +++ b/tests/scripts/test_workday_connect_contracts.py @@ -0,0 +1,885 @@ +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path +import sys + +import pytest + + +SCRIPTS = ( + Path(__file__).resolve().parents[2] / "solutions" / "ess-maker-skills" / "scripts" +) +sys.path.insert(0, str(SCRIPTS)) + +from workday_connect_contracts import ( # noqa: E402 + WorkdayConnectContractError, + build_entra_handoff, + build_workday_admin_packet, + validate_agent_binding_evidence, + validate_employee_evidence, + validate_employee_failure_evidence, + validate_entra_verification, + validate_workday_admin_response, +) +import workday_connect_contracts as contracts # noqa: E402 +from workday_connect_model import default_state # noqa: E402 + + +def _state(): + state = default_state() + state["scope"].update( + { + "entraTenantId": "00000000-0000-0000-0000-000000000000", + "workdayTenant": "contoso_impl", + "packageFlavor": "runtime", + } + ) + state["phases"]["preflight"]["status"] = "complete" + return state + + +def _entra_checks(): + return { + "samlMode": { + "outcome": "verified", + "provenance": "microsoft-graph", + }, + "signingCertificate": { + "outcome": "verified", + "provenance": "microsoft-graph", + }, + "connectorPreauthorized": { + "outcome": "verified", + "provenance": "microsoft-graph", + }, + "graphDelegatedPermissions": { + "outcome": "verified", + "provenance": "microsoft-graph", + }, + "adminConsent": { + "outcome": "verified", + "provenance": "microsoft-graph", + }, + "userAssignment": { + "outcome": "verified", + "provenance": "microsoft-graph", + }, + "nameId": { + "outcome": "verified", + "provenance": "microsoft-graph", + "observedValue": "user.userPrincipalName", + }, + "samlSigningOption": { + "outcome": "confirmed", + "provenance": "administrator-attestation", + "observedValue": "Sign SAML response and assertion", + }, + } + + +def test_entra_handoff_selects_only_exact_service_provider_id(): + handoff = build_entra_handoff( + _state(), + { + "applications": [ + { + "displayName": "Workday wrong", + "appId": "11111111-1111-1111-1111-111111111111", + "objectId": "22222222-2222-2222-2222-222222222222", + "servicePrincipalId": "33333333-3333-3333-3333-333333333333", + "identifierUris": ["http://www.workday.com/contoso_impl_old"], + }, + { + "displayName": "Workday exact", + "appId": "44444444-4444-4444-4444-444444444444", + "objectId": "55555555-5555-5555-5555-555555555555", + "servicePrincipalId": "66666666-6666-6666-6666-666666666666", + "identifierUris": ["http://www.workday.com/contoso_impl/"], + }, + ] + }, + ) + + assert handoff["target"]["displayName"] == "Workday exact" + assert handoff["identifiers"]["workdaySamlEntityId"] == ( + "http://www.workday.com/contoso_impl" + ) + assert handoff["identifiers"]["entraAppIdUri"] == ( + "api://44444444-4444-4444-4444-444444444444" + ) + assert ( + handoff["identifiers"]["workdaySamlEntityId"] + != (handoff["identifiers"]["entraAppIdUri"]) + ) + assert "planHash" not in handoff + + +def test_entra_handoff_rejects_missing_exact_discovery(): + with pytest.raises(WorkdayConnectContractError, match="No exact"): + build_entra_handoff(_state(), {"applications": []}) + + +def test_entra_handoff_can_request_explicit_creation(): + handoff = build_entra_handoff( + _state(), + {"applications": [], "allowCreate": True}, + ) + + assert handoff["target"]["mode"] == "create" + assert handoff["identifiers"]["entraAppIdUri"] is None + assert handoff["requiresRediscovery"] is True + + +def test_entra_handoff_reuses_matching_tenant_foundation(): + state = _state() + app_id = "44444444-4444-4444-4444-444444444444" + state["tenantFoundation"] = { + "scope": { + "entraTenantId": state["scope"]["entraTenantId"], + "workdayTenant": state["scope"]["workdayTenant"], + }, + "identifiers": {"entraAppId": app_id}, + "endpoints": {}, + "phases": { + "entra": { + "completedActions": [], + "evidence": [], + }, + "workday-admin": { + "completedActions": [], + "evidence": [], + }, + }, + "capturedAt": "2026-09-26T00:00:00Z", + } + + handoff = build_entra_handoff( + state, + { + "applications": [ + { + "displayName": "Workday exact", + "appId": app_id, + "objectId": "55555555-5555-5555-5555-555555555555", + "servicePrincipalId": ("66666666-6666-6666-6666-666666666666"), + "identifierUris": ["http://www.workday.com/contoso_impl"], + } + ] + }, + ) + + assert handoff["foundationReuse"]["eligible"] is True + assert set(handoff["foundationReuse"]) == {"eligible"} + assert "Reread" in handoff["actions"][0] + + +def test_entra_verification_requires_all_expected_graph_evidence(): + app_id = "44444444-4444-4444-4444-444444444444" + result = validate_entra_verification( + _state(), + { + "tenantId": "00000000-0000-0000-0000-000000000000", + "application": { + "displayName": "Workday exact", + "appId": app_id, + "objectId": "55555555-5555-5555-5555-555555555555", + "servicePrincipalId": ("66666666-6666-6666-6666-666666666666"), + "identifierUris": [ + "http://www.workday.com/contoso_impl", + f"api://{app_id}", + ], + }, + "scopeGuid": "77777777-7777-7777-7777-777777777777", + "certificate": { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + }, + "checks": _entra_checks(), + }, + ) + + assert result["identifiers"]["entraAppId"] == app_id + assert result["identifiers"]["entraAppIdUri"] == f"api://{app_id}" + assert result["evidence"]["checks"]["samlSigningOption"] == { + "outcome": "confirmed", + "provenance": "administrator-attestation", + "observedValue": "Sign SAML response and assertion", + } + + +def test_saml_signing_option_records_the_exact_required_value(): + with pytest.raises( + WorkdayConnectContractError, + match="Sign SAML response and assertion", + ): + contracts._normalize_entra_check( + "samlSigningOption", + { + "outcome": "confirmed", + "provenance": "administrator-attestation", + "observedValue": "Sign SAML assertion only", + }, + ) + + +def test_entra_verification_rejects_a_different_graph_tenant(): + app_id = "44444444-4444-4444-4444-444444444444" + with pytest.raises( + WorkdayConnectContractError, + match="does not match", + ): + validate_entra_verification( + _state(), + { + "tenantId": "99999999-9999-9999-9999-999999999999", + "application": { + "displayName": "Workday exact", + "appId": app_id, + "objectId": "55555555-5555-5555-5555-555555555555", + "servicePrincipalId": ("66666666-6666-6666-6666-666666666666"), + "identifierUris": [ + "http://www.workday.com/contoso_impl", + f"api://{app_id}", + ], + }, + "scopeGuid": "77777777-7777-7777-7777-777777777777", + "certificate": { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + }, + "checks": _entra_checks(), + }, + ) + + +def test_entra_verification_rejects_provenance_free_checks(): + app_id = "44444444-4444-4444-4444-444444444444" + checks = _entra_checks() + checks["nameId"] = {"outcome": "verified"} + + with pytest.raises( + WorkdayConnectContractError, + match="lacks provenance", + ): + validate_entra_verification( + _state(), + { + "tenantId": "00000000-0000-0000-0000-000000000000", + "application": { + "displayName": "Workday exact", + "appId": app_id, + "objectId": "55555555-5555-5555-5555-555555555555", + "servicePrincipalId": ("66666666-6666-6666-6666-666666666666"), + "identifierUris": [ + "http://www.workday.com/contoso_impl", + f"api://{app_id}", + ], + }, + "scopeGuid": "77777777-7777-7777-7777-777777777777", + "certificate": { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + }, + "checks": checks, + }, + ) + + +def test_entra_verification_rejects_unused_check_fields(): + app_id = "44444444-4444-4444-4444-444444444444" + checks = _entra_checks() + checks["nameId"]["details"] = "not part of the evidence contract" + + with pytest.raises( + WorkdayConnectContractError, + match="unsupported fields", + ): + validate_entra_verification( + _state(), + { + "tenantId": "00000000-0000-0000-0000-000000000000", + "application": { + "displayName": "Workday exact", + "appId": app_id, + "objectId": "55555555-5555-5555-5555-555555555555", + "servicePrincipalId": ("66666666-6666-6666-6666-666666666666"), + "identifierUris": [ + "http://www.workday.com/contoso_impl", + f"api://{app_id}", + ], + }, + "scopeGuid": "77777777-7777-7777-7777-777777777777", + "certificate": { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + }, + "checks": checks, + }, + ) + + +def test_entra_verification_rejects_attestation_for_graph_check(): + app_id = "44444444-4444-4444-4444-444444444444" + checks = _entra_checks() + checks["adminConsent"] = { + "outcome": "confirmed", + "provenance": "administrator-attestation", + } + + with pytest.raises( + WorkdayConnectContractError, + match="must be proven by Microsoft Graph", + ): + validate_entra_verification( + _state(), + { + "tenantId": "00000000-0000-0000-0000-000000000000", + "application": { + "displayName": "Workday exact", + "appId": app_id, + "objectId": "55555555-5555-5555-5555-555555555555", + "servicePrincipalId": ("66666666-6666-6666-6666-666666666666"), + "identifierUris": [ + "http://www.workday.com/contoso_impl", + f"api://{app_id}", + ], + }, + "scopeGuid": "77777777-7777-7777-7777-777777777777", + "certificate": { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + }, + "checks": checks, + }, + ) + + +def test_workday_packet_uses_service_provider_id_not_app_id_uri(): + state = _state() + state["identifiers"].update( + { + "workdaySamlEntityId": "http://www.workday.com/contoso_impl", + "entraAppIdUri": ("api://44444444-4444-4444-4444-444444444444"), + "signingCertificate": { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + }, + } + ) + packet = build_workday_admin_packet(state) + + assert packet["referenceValues"]["serviceProviderId"] == ( + "http://www.workday.com/contoso_impl" + ) + assert packet["referenceValues"]["entraApplicationIdUri"].startswith("api://") + assert packet["referenceValues"]["expectedIdentityProviderIssuer"] == ( + "https://sts.windows.net/00000000-0000-0000-0000-000000000000/" + ) + assert packet["referenceValues"]["certificateValidFrom"] == "2026-01-01" + assert packet["referenceValues"]["certificateValidTo"] == "2027-01-01" + provider_question = packet["identityProviderQuestion"] + assert "sign-in provider" in provider_question["question"] + assert any("Microsoft Entra ID" in option for option in provider_question["options"]) + assert any("Okta" in option for option in provider_question["options"]) + assert any("Ping Identity" in option for option in provider_question["options"]) + assert "Another sign-in provider" in provider_question["options"] + assert "No enabled SAML row" in provider_question["options"] + assert "I'm not sure" in provider_question["options"] + certificate_question = packet["certificateSelectionQuestion"] + assert "Which certificate is selected" in certificate_question["question"] + assert ( + "The new certificate created from the Entra Base64 file" + in certificate_question["options"] + ) + assert ( + "A different existing Workday certificate" + in certificate_question["options"] + ) + assert "No certificate is selected" in certificate_question["options"] + assert "I'm not sure" in certificate_question["options"] + assert "exactly match" in packet["issuerConfirmationQuestion"]["question"] + assert "both dates match exactly" in ( + packet["certificateValidityQuestion"]["options"][0] + ) + assert "certificateName" not in packet["responseForm"]["required"] + assert "client secrets" in packet["responseForm"]["note"] + + +def test_workday_packet_rejects_identifier_aliasing(): + state = _state() + state["identifiers"].update( + { + "workdaySamlEntityId": "http://www.workday.com/contoso_impl", + "entraAppIdUri": "http://www.workday.com/contoso_impl", + "signingCertificate": { + "validFrom": "2026-01-01", + "validTo": "2027-01-01", + }, + } + ) + + with pytest.raises(WorkdayConnectContractError, match="remain distinct"): + build_workday_admin_packet(state) + + +def test_workday_packet_rejects_tenant_drift(): + state = _state() + state["identifiers"].update( + { + "workdaySamlEntityId": "http://www.workday.com/other", + "entraAppIdUri": ("api://44444444-4444-4444-4444-444444444444"), + "signingCertificate": { + "validFrom": "2026-01-01", + "validTo": "2027-01-01", + }, + } + ) + + with pytest.raises(WorkdayConnectContractError, match="does not match"): + build_workday_admin_packet(deepcopy(state)) + + +def test_workday_admin_response_validates_exact_endpoints(): + state = _state() + state["identifiers"]["signingCertificate"] = { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + } + result = validate_workday_admin_response( + state, + { + "identityProviderOutcome": "verified-entra-issuer", + "enabledServiceProviderId": ("http://www.workday.com/contoso_impl"), + "certificateSelectionOutcome": "entra-signing-certificate-selected", + "certificateValidityOutcome": ( + "matches-verified-entra-certificate" + ), + "oauthClientId": "safe-client-id", + "oauthTokenUrl": ( + "https://example.workday.com/ccx/oauth2/contoso_impl/token" + ), + "restBaseUrl": "https://example.workday.com/ccx/api", + "soapBaseUrl": ("https://example.workday.com/ccx/service/contoso_impl"), + "authenticationPolicyOutcome": "existing-active-policy", + "networkReadinessOutcome": "confirmed-hosts-allowed", + }, + ) + + assert result["endpoints"]["restBaseUrl"].endswith("/ccx/api") + assert result["evidence"]["activeIdentityProviderIssuer"] == ( + "https://sts.windows.net/00000000-0000-0000-0000-000000000000/" + ) + assert "certificateName" not in result["evidence"] + assert result["evidence"]["networkReadinessOutcome"] == ("confirmed-hosts-allowed") + + +def test_workday_admin_response_rejects_certificate_date_drift(): + state = _state() + state["identifiers"]["signingCertificate"] = { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + } + + with pytest.raises( + WorkdayConnectContractError, + match="conflicts", + ): + validate_workday_admin_response( + state, + { + "identityProviderOutcome": "verified-entra-issuer", + "enabledServiceProviderId": ("http://www.workday.com/contoso_impl"), + "certificateSelectionOutcome": ( + "entra-signing-certificate-selected" + ), + "certificateValidityOutcome": ( + "matches-verified-entra-certificate" + ), + "certificateValidFrom": "2026-01-01", + "certificateValidTo": "2028-01-01", + "oauthClientId": "safe-client-id", + "oauthTokenUrl": ( + "https://example.workday.com/ccx/oauth2/contoso_impl/token" + ), + "restBaseUrl": "https://example.workday.com/ccx/api", + "soapBaseUrl": ("https://example.workday.com/ccx/service/contoso_impl"), + "authenticationPolicyOutcome": "existing-active-policy", + "networkReadinessOutcome": "confirmed-hosts-allowed", + }, + ) + + +def test_workday_admin_response_rejects_conflicting_confirmed_defaults(): + state = _state() + state["identifiers"]["signingCertificate"] = { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + } + response = { + "identityProviderOutcome": "verified-entra-issuer", + "enabledServiceProviderId": "http://www.workday.com/contoso_impl", + "certificateSelectionOutcome": "entra-signing-certificate-selected", + "certificateValidityOutcome": "matches-verified-entra-certificate", + "oauthClientId": "safe-client-id", + "oauthTokenUrl": ( + "https://example.workday.com/ccx/oauth2/contoso_impl/token" + ), + "restBaseUrl": "https://example.workday.com/ccx/api", + "soapBaseUrl": "https://example.workday.com/ccx/service/contoso_impl", + "authenticationPolicyOutcome": "existing-active-policy", + "networkReadinessOutcome": "confirmed-hosts-allowed", + } + + with pytest.raises(WorkdayConnectContractError, match="Issuer conflicts"): + validate_workday_admin_response( + state, + { + **response, + "activeIdentityProviderIssuer": "https://sts.windows.net/other/", + }, + ) + + with pytest.raises(WorkdayConnectContractError, match="Valid To date conflicts"): + validate_workday_admin_response( + state, + { + **response, + "certificateValidTo": "2028-01-01", + }, + ) + + +def test_workday_admin_rejects_unused_response_fields(): + state = _state() + state["identifiers"]["signingCertificate"] = { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + } + + with pytest.raises( + WorkdayConnectContractError, + match="unsupported fields", + ): + validate_workday_admin_response( + state, + { + "activeIdentityProviderIssuer": "https://sts.example/", + "enabledServiceProviderId": ("http://www.workday.com/contoso_impl"), + "certificateName": "ESS Workday Entra signing certificate", + "certificateValidFrom": "2026-01-01", + "certificateValidTo": "2027-01-01", + "oauthClientId": "safe-client-id", + "oauthTokenUrl": ( + "https://example.workday.com/ccx/oauth2/contoso_impl/token" + ), + "restBaseUrl": "https://example.workday.com/ccx/api", + "soapBaseUrl": ("https://example.workday.com/ccx/service/contoso_impl"), + "authenticationPolicyOutcome": "existing-active-policy", + "networkReadinessOutcome": "confirmed-hosts-allowed", + "notes": "not part of the evidence contract", + }, + ) + + +def test_agent_binding_and_employee_evidence_are_strict(): + state = _state() + state["scope"].update( + { + "environmentId": "environment-id", + "agent": {"botId": "bot-id"}, + } + ) + state["operators"]["powerPlatformMaker"] = {"username": "maker@example.com"} + assert ( + validate_agent_binding_evidence( + state, + { + "environmentId": "environment-id", + "botId": "bot-id", + "makerUsername": "maker@example.com", + "checkpoints": { + "WD-REST-002": "Passed", + "WD-CONN-013": "Passed", + }, + "workdayTopics": { + "expected": 21, + "verified": 21, + "active": 21, + "blockingDiagnostics": [], + }, + "flowAttachment": { + "outcome": "maker-confirmed", + "botId": "bot-id", + "flowNames": ["ESS Workday Runtime REST Execution"], + "parameterSharingOutcome": ( + "enabled-for-exposed-connections" + ), + }, + }, + )["workdayTopics"]["active"] + == 21 + ) + + diagnostic_evidence = validate_agent_binding_evidence( + state, + { + "environmentId": "environment-id", + "botId": "bot-id", + "makerUsername": "maker@example.com", + "checkpoints": { + "WD-REST-002": "Passed", + "WD-CONN-013": "Passed", + }, + "workdayTopics": { + "expected": 21, + "verified": 21, + "active": 21, + "blockingDiagnostics": [{"errorCode": "NotFound"}], + }, + "flowAttachment": { + "outcome": "maker-confirmed", + "botId": "bot-id", + "flowNames": ["ESS Workday Runtime REST Execution"], + "parameterSharingOutcome": ( + "enabled-for-exposed-connections" + ), + }, + }, + ) + assert diagnostic_evidence["workdayTopics"]["blockingDiagnostics"] == [ + {"errorCode": "NotFound"} + ] + + with pytest.raises(WorkdayConnectContractError, match="unsupported fields"): + validate_employee_evidence( + { + "scenarioName": "Read-only scenario", + "testUserCategory": "standard employee", + "timestamp": "2026-09-25T00:00:00Z", + "outcome": "passed", + "employeeName": "not allowed", + } + ) + + +def test_entra_check_outcome_must_match_provenance(): + app_id = "44444444-4444-4444-4444-444444444444" + checks = _entra_checks() + checks["nameId"] = { + "outcome": "confirmed", + "provenance": "microsoft-graph", + } + + with pytest.raises( + WorkdayConnectContractError, + match="does not match its provenance", + ): + validate_entra_verification( + _state(), + { + "tenantId": "00000000-0000-0000-0000-000000000000", + "application": { + "displayName": "Workday exact", + "appId": app_id, + "objectId": "object-id", + "servicePrincipalId": "service-principal-id", + "identifierUris": [ + "http://www.workday.com/contoso_impl", + f"api://{app_id}", + ], + }, + "scopeGuid": "scope-guid", + "certificate": { + "thumbprint": "ABC123", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + }, + "checks": checks, + }, + ) + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ( + "oauthTokenUrl", + "https://example.workday.com/ccx/oauth2/other/token", + "OAuth token URL must end exactly", + ), + ( + "restBaseUrl", + "https://example.workday.com/prefix/ccx/api", + "REST base URL must end exactly", + ), + ( + "soapBaseUrl", + "https://example.workday.com/ccx/service/other", + "SOAP base URL must end exactly", + ), + ], +) +def test_workday_admin_rejects_endpoint_path_drift( + field, + value, + message, +): + state = _state() + state["identifiers"]["signingCertificate"] = { + "thumbprint": "ABC123", + "validFrom": "2026-01-01", + "validTo": "2027-01-01", + } + response = { + "identityProviderOutcome": "verified-entra-issuer", + "enabledServiceProviderId": ("http://www.workday.com/contoso_impl"), + "certificateSelectionOutcome": ( + "entra-signing-certificate-selected" + ), + "certificateValidityOutcome": ( + "matches-verified-entra-certificate" + ), + "oauthClientId": "safe-client-id", + "oauthTokenUrl": ("https://example.workday.com/ccx/oauth2/contoso_impl/token"), + "restBaseUrl": "https://example.workday.com/ccx/api", + "soapBaseUrl": ("https://example.workday.com/ccx/service/contoso_impl"), + "authenticationPolicyOutcome": "existing-active-policy", + "networkReadinessOutcome": "confirmed-hosts-allowed", + } + response[field] = value + + with pytest.raises(WorkdayConnectContractError, match=message): + validate_workday_admin_response(state, response) + + +def test_workday_admin_rejects_legacy_identity_provider_evidence(): + state = _state() + state["identifiers"]["signingCertificate"] = { + "thumbprint": "ABC123", + "validFrom": "2026-01-01", + "validTo": "2027-01-01", + } + + with pytest.raises( + WorkdayConnectContractError, + match="identityProviderOutcome", + ): + validate_workday_admin_response( + state, + { + "activeIdentityProviderIssuer": "https://wrong.example/", + "enabledServiceProviderId": ( + "http://www.workday.com/contoso_impl" + ), + "certificateName": "Unrelated certificate", + "certificateValidFrom": "2026-01-01", + "certificateValidTo": "2027-01-01", + "oauthClientId": "safe-client-id", + "oauthTokenUrl": ( + "https://example.workday.com/ccx/oauth2/" + "contoso_impl/token" + ), + "restBaseUrl": "https://example.workday.com/ccx/api", + "soapBaseUrl": ( + "https://example.workday.com/ccx/service/contoso_impl" + ), + "authenticationPolicyOutcome": "existing-active-policy", + "networkReadinessOutcome": "confirmed-hosts-allowed", + }, + ) + + +def test_workday_admin_rejects_non_workday_or_mixed_endpoint_hosts(): + state = _state() + state["identifiers"]["signingCertificate"] = { + "thumbprint": "ABC123", + "validFrom": "2026-01-01", + "validTo": "2027-01-01", + } + response = { + "identityProviderOutcome": "verified-entra-issuer", + "enabledServiceProviderId": "http://www.workday.com/contoso_impl", + "certificateSelectionOutcome": "entra-signing-certificate-selected", + "certificateValidityOutcome": "matches-verified-entra-certificate", + "oauthClientId": "safe-client-id", + "oauthTokenUrl": ( + "https://attacker.example/ccx/oauth2/contoso_impl/token" + ), + "restBaseUrl": "https://example.workday.com/ccx/api", + "soapBaseUrl": "https://example.workday.com/ccx/service/contoso_impl", + "authenticationPolicyOutcome": "existing-active-policy", + "networkReadinessOutcome": "confirmed-hosts-allowed", + } + + with pytest.raises(WorkdayConnectContractError, match="Workday-owned"): + validate_workday_admin_response(state, response) + + response["oauthTokenUrl"] = ( + "https://other.workday.com/ccx/oauth2/contoso_impl/token" + ) + with pytest.raises(WorkdayConnectContractError, match="same verified"): + validate_workday_admin_response(state, response) + + response["oauthTokenUrl"] = ( + "https://example.workday.com:notaport/ccx/oauth2/" + "contoso_impl/token" + ) + with pytest.raises(WorkdayConnectContractError, match="HTTPS URL"): + validate_workday_admin_response(state, response) + + +def test_employee_evidence_rejects_maker_and_invalid_timestamp(): + with pytest.raises(WorkdayConnectContractError, match="non-maker"): + validate_employee_evidence( + { + "scenarioName": "Read-only scenario", + "testUserCategory": "Environment Maker", + "timestamp": "2026-09-25T00:00:00Z", + "outcome": "passed", + } + ) + + +def test_employee_failure_evidence_requires_safe_structured_fields(): + assert validate_employee_failure_evidence( + { + "failureCategory": "workday-access-denied", + "timestamp": "2026-09-25T00:00:00+00:00", + "remediation": "Ask a Workday administrator to verify access.", + } + ) == { + "failureCategory": "workday-access-denied", + "timestamp": "2026-09-25T00:00:00Z", + "remediation": "Ask a Workday administrator to verify access.", + } + + with pytest.raises(WorkdayConnectContractError, match="unsupported fields"): + validate_employee_failure_evidence( + { + "failureCategory": "workday-access-denied", + "timestamp": "2026-09-25T00:00:00Z", + "remediation": "Investigate.", + "accessToken": "must-not-be-recorded", + } + ) + + with pytest.raises(WorkdayConnectContractError, match="ISO-8601"): + validate_employee_evidence( + { + "scenarioName": "Read-only scenario", + "testUserCategory": "non-maker employee", + "timestamp": "not-a-time", + "outcome": "passed", + } + ) diff --git a/tests/scripts/test_workday_connect_model.py b/tests/scripts/test_workday_connect_model.py new file mode 100644 index 000000000..83367d590 --- /dev/null +++ b/tests/scripts/test_workday_connect_model.py @@ -0,0 +1,122 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for the compact Workday connect lifecycle model.""" + +from __future__ import annotations + +import pytest + + +def test_default_state_has_six_primary_phases() -> None: + import workday_connect_model as model + + state = model.default_state() + + assert list(state["phases"]) == [ + "preflight", + "entra", + "workday-admin", + "connections", + "runtime", + "employee-validation", + ] + assert model.next_phase_id(state) == "preflight" + assert state["status"] == "in-progress" + assert state["schemaVersion"] == 5 + assert state["tenantFoundation"] is None + + +def test_workday_saml_entity_id_is_not_the_entra_app_uri() -> None: + import workday_connect_model as model + + entity_id = model.workday_saml_entity_id("contoso_prod") + + assert entity_id == "http://www.workday.com/contoso_prod" + assert not entity_id.startswith("api://") + + +@pytest.mark.parametrize("tenant", ["", "https://tenant", "tenant value", "api://id"]) +def test_workday_saml_entity_id_rejects_invalid_tenant(tenant: str) -> None: + import workday_connect_model as model + + with pytest.raises(model.WorkdayConnectModelError): + model.workday_saml_entity_id(tenant) + + +def test_plan_hash_is_stable_and_ignores_embedded_hash() -> None: + import workday_connect_model as model + + plan = { + "phase": "entra", + "scope": {"tenantId": "tenant", "applicationId": "app"}, + "actions": ["configure-saml", "configure-scope"], + } + observed = model.plan_hash(plan) + + assert observed == model.plan_hash({**plan, "planHash": observed}) + assert observed != model.plan_hash({**plan, "actions": ["configure-saml"]}) + + +def test_sensitive_fields_are_rejected() -> None: + import workday_connect_model as model + + with pytest.raises(model.WorkdayConnectModelError, match="must not be persisted"): + model.reject_sensitive_data({"nested": {"access_token": "secret"}}) + + +def test_progress_text_is_a_visible_phase_roadmap() -> None: + import workday_connect_model as model + + state = model.default_state() + state["phases"]["preflight"]["status"] = "complete" + state["phases"]["entra"]["status"] = "active" + + assert model.progress_text(state) == ( + "### Workday connection progress\n" + "\n" + "| # | Phase | Status |\n" + "|---:|---|---|\n" + "| 1 | Preflight | Complete |\n" + "| 2 | Microsoft Entra | In progress |\n" + "| 3 | Workday administrator | Pending |\n" + "| 4 | Connections | Pending |\n" + "| 5 | Runtime configuration | Pending |\n" + "| 6 | Employee validation | Pending |" + ) + assert model.next_phase_summary(state) == { + "id": "entra", + "title": "Microsoft Entra", + "whatHappens": [ + ( + "Find the exact Workday enterprise application in the selected " + "Microsoft Entra tenant." + ), + ( + "Guide an Entra administrator through the required SAML, " + "permission, consent, assignment, and employee sign-in settings." + ), + "Verify the application and signing-certificate configuration.", + ], + } + + +def test_pending_current_phase_is_marked_next() -> None: + import workday_connect_model as model + + state = model.default_state() + + assert "| 1 | Preflight | Next |" in model.progress_text(state) + + +def test_blocked_phase_requires_complete_blocker_evidence() -> None: + import workday_connect_model as model + + state = model.default_state() + state["phases"]["preflight"]["status"] = "blocked" + + with pytest.raises( + model.WorkdayConnectModelError, + match="complete blocker", + ): + model.validate_state(state) diff --git a/tests/scripts/test_workday_connect_preflight.py b/tests/scripts/test_workday_connect_preflight.py new file mode 100644 index 000000000..48d0ff364 --- /dev/null +++ b/tests/scripts/test_workday_connect_preflight.py @@ -0,0 +1,783 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for identity-aware Workday connect preflight.""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess + +import pytest + + +BOT_ID = "00000000-0000-4000-8000-000000000001" +ENV_URL = "https://target.crm.dynamics.com" + + +def _write_foundation( + root: Path, + *, + dataverse_url: str | None = None, + schema_name: str = "gptagent_copilotforemployeeselfservicehr", +) -> None: + local = root / ".local" + local.mkdir(parents=True, exist_ok=True) + config = { + "configVersion": 1, + "setup": "complete", + "releaseLine": "da", + "environmentId": "agent-environment", + "powerPlatformApiEndpoint": "https://api.powerplatform.com", + "ring": "prod", + "activeAgent": "ess-hr", + "agent": { + "slug": "ess-hr", + "botId": BOT_ID, + "schemaName": schema_name, + "name": "Employee Self-Service HR", + }, + "agents": [ + { + "slug": "ess-hr", + "botId": BOT_ID, + "schemaName": schema_name, + "name": "Employee Self-Service HR", + } + ], + } + if dataverse_url: + config["dataverseEndpoint"] = dataverse_url + (local / "config.json").write_text(json.dumps(config), encoding="utf-8") + setup = local / "setup" + setup.mkdir() + (setup / "config.json").write_text( + json.dumps( + { + "schema_version": 4, + "agents": { + BOT_ID: { + "agent": { + "id": BOT_ID, + "workspace_slug": "ess-hr", + }, + "steps": {"SETUP-07": {"state": "done"}}, + } + }, + } + ), + encoding="utf-8", + ) + + +def test_resolve_target_rejects_url_that_differs_from_setup( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + _write_foundation(tmp_path, dataverse_url=ENV_URL) + + with pytest.raises( + preflight.WorkdayConnectPreflightError, + match="does not match", + ): + preflight.resolve_target( + tmp_path, + dataverse_url="https://other.crm.dynamics.com", + state=model.default_state(), + ) + + +def test_resolve_target_accepts_exact_url_without_inventory_lookup( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + _write_foundation(tmp_path) + + target = preflight.resolve_target( + tmp_path, + dataverse_url=ENV_URL, + state=model.default_state(), + pac_resolver=lambda: Path("pac.exe"), + pac_runner=lambda command, **_kwargs: subprocess.CompletedProcess( + command, + 0, + stdout=json.dumps( + { + "EnvironmentId": "agent-environment", + "OrgUrl": ENV_URL, + } + ), + stderr="", + ), + ) + + assert target.dataverse_url == ENV_URL + + +def test_resolve_target_accepts_case_insensitive_https_scheme( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + uppercase_scheme_url = "HTTPS://target.crm.dynamics.com" + _write_foundation(tmp_path, dataverse_url=uppercase_scheme_url) + + target = preflight.resolve_target( + tmp_path, + dataverse_url=uppercase_scheme_url, + state=model.default_state(), + ) + + assert target.dataverse_url == uppercase_scheme_url + + +def test_resolve_target_ignores_stale_url_from_different_environment( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + _write_foundation(tmp_path) + state = model.default_state() + state["scope"].update( + { + "environmentId": "old-environment", + "dataverseUrl": "https://old.crm.dynamics.com", + } + ) + inventory = ( + tmp_path / ".local" / "setup" / "environment-list-prod.json" + ) + inventory.write_text( + json.dumps( + { + "environments": [ + { + "id": "agent-environment", + "url": ENV_URL, + } + ] + } + ), + encoding="utf-8", + ) + + target = preflight.resolve_target( + tmp_path, + dataverse_url=None, + state=state, + ) + + assert target.environment_id == "agent-environment" + assert target.dataverse_url == ENV_URL + + +def test_resolve_target_reuses_setup_environment_inventory( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + _write_foundation(tmp_path) + inventory = ( + tmp_path / ".local" / "setup" / "environment-list-prod.json" + ) + inventory.write_text( + json.dumps( + { + "environments": [ + { + "id": "agent-environment", + "displayName": "ESS HR", + "url": ENV_URL, + } + ] + } + ), + encoding="utf-8", + ) + + target = preflight.resolve_target( + tmp_path, + dataverse_url=None, + state=model.default_state(), + ) + + assert target.dataverse_url == ENV_URL + + +def test_resolve_target_rejects_conflicting_setup_urls( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + _write_foundation(tmp_path, dataverse_url=ENV_URL) + inventory = ( + tmp_path / ".local" / "setup" / "environment-list-prod.json" + ) + inventory.write_text( + json.dumps( + { + "environments": [ + { + "id": "agent-environment", + "url": "https://different.crm.dynamics.com", + } + ] + } + ), + encoding="utf-8", + ) + + with pytest.raises( + preflight.WorkdayConnectPreflightError, + match="disagree", + ): + preflight.resolve_target( + tmp_path, + dataverse_url=None, + state=model.default_state(), + ) + + +def test_resolve_target_reuses_exact_pac_environment(tmp_path: Path) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + _write_foundation(tmp_path) + + def runner(command, **_kwargs): + assert command[-3:] == [ + "--environment", + "agent-environment", + "--json", + ] + return subprocess.CompletedProcess( + command, + 0, + stdout=json.dumps( + { + "EnvironmentId": "agent-environment", + "OrgUrl": f"{ENV_URL}/", + } + ), + stderr="", + ) + + target = preflight.resolve_target( + tmp_path, + dataverse_url=None, + state=model.default_state(), + pac_resolver=lambda: Path("pac.exe"), + pac_runner=runner, + ) + + assert target.dataverse_url == ENV_URL + + +def test_resolve_target_rejects_mismatched_pac_environment( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + _write_foundation(tmp_path) + + with pytest.raises( + preflight.WorkdayConnectPreflightError, + match="different environment", + ): + preflight.resolve_target( + tmp_path, + dataverse_url=None, + state=model.default_state(), + pac_resolver=lambda: Path("pac.exe"), + pac_runner=lambda command, **_kwargs: subprocess.CompletedProcess( + command, + 0, + stdout=json.dumps( + { + "EnvironmentId": "other-environment", + "OrgUrl": ENV_URL, + } + ), + stderr="", + ), + ) + + +def test_resolve_target_rejects_classic_da(tmp_path: Path) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + _write_foundation( + tmp_path, + schema_name="msdyn_copilotforemployeeselfservicedahr", + ) + + with pytest.raises( + preflight.WorkdayConnectPreflightError, + match="native ESS HR agent only", + ): + preflight.resolve_target( + tmp_path, + dataverse_url=ENV_URL, + state=model.default_state(), + ) + + +def test_preflight_skips_install_when_package_exists(tmp_path: Path) -> None: + import workday_connect_preflight as preflight + import workday_connect_store as store_module + + _write_foundation(tmp_path, dataverse_url=ENV_URL) + installer_calls = [] + + def query(_url, _token, entity_set, _select, _filter): + assert entity_set == "solutions" + return [{"uniquename": "msdyn_EssWorkdayRuntime"}] + + result = preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username="maker@example.com", + store=store_module.WorkdayConnectStore(tmp_path), + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=lambda *_args, **_kwargs: { + "username": "maker@example.com", + "tenantId": "tenant-id", + }, + query=query, + installer=lambda *_args, **_kwargs: installer_calls.append(True), + ) + + assert installer_calls == [] + assert result["package"]["action"] == "unchanged" + assert result["status"]["nextPhaseId"] == "entra" + assert result["scope"]["entraTenantId"] == "tenant-id" + + +def test_preflight_carries_verified_tenant_into_entra_handoff( + tmp_path: Path, +) -> None: + import workday_connect_contracts as contracts + import workday_connect_preflight as preflight + import workday_connect_store as store_module + + _write_foundation(tmp_path, dataverse_url=ENV_URL) + store = store_module.WorkdayConnectStore(tmp_path) + preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username="maker@example.com", + store=store, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=lambda *_args, **_kwargs: { + "username": "maker@example.com", + "tenantId": "tenant-id", + }, + query=lambda *_args, **_kwargs: [ + {"uniquename": "msdyn_EssWorkdayRuntime"} + ], + ) + state = store.merge_section( + "scope", + {"workdayTenant": "contoso_impl"}, + ) + + handoff = contracts.build_entra_handoff( + state, + { + "applications": [ + { + "displayName": "Workday exact", + "appId": "44444444-4444-4444-4444-444444444444", + "objectId": "55555555-5555-5555-5555-555555555555", + "servicePrincipalId": ( + "66666666-6666-6666-6666-666666666666" + ), + "identifierUris": [ + "http://www.workday.com/contoso_impl" + ], + } + ] + }, + ) + + assert handoff["scope"]["entraTenantId"] == "tenant-id" + + +def test_preflight_installs_and_reverifies_with_same_account( + tmp_path: Path, +) -> None: + import workday_connect_preflight as preflight + import workday_connect_store as store_module + + _write_foundation(tmp_path, dataverse_url=ENV_URL) + store = store_module.WorkdayConnectStore(tmp_path) + installed = False + + def query(*_args, **_kwargs): + return ( + [{"uniquename": "msdyn_EssWorkdayRuntime"}] + if installed + else [] + ) + + plan_result = preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username="maker@example.com", + store=store, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=lambda *_args, **_kwargs: { + "username": "maker@example.com", + "tenantId": "tenant-id", + }, + query=query, + ) + assert plan_result["requiresApproval"] is True + assert plan_result["plan"]["scope"]["agent"] == { + "slug": "ess-hr", + "botId": BOT_ID, + "schemaName": "gptagent_copilotforemployeeselfservicehr", + } + assert store.status()["nextPhaseId"] == "preflight" + _state, approved_hash = store.approve_plan( + "preflight", + plan_result["plan"], + ) + + def installer(*_args, **kwargs): + nonlocal installed + installed = True + return { + "schemaName": "msdyn_EssWorkdayRuntime", + "authenticatedAccount": kwargs["preferred_username"], + } + + result = preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username="maker@example.com", + store=store, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=lambda *_args, **_kwargs: { + "username": "maker@example.com", + "tenantId": "tenant-id", + }, + query=query, + installer=installer, + approved_install_hash=approved_hash, + plan_verifier=lambda plan, value: store.verify_plan( + "preflight", + plan, + value, + ), + ) + + assert result["package"]["action"] == "installed" + assert result["operator"]["username"] == "maker@example.com" + assert result["operator"]["credentialStores"]["pac"] == "verified" + + +def test_preflight_reuses_persisted_maker_identity(tmp_path: Path) -> None: + import workday_connect_preflight as preflight + import workday_connect_store as store_module + + _write_foundation(tmp_path, dataverse_url=ENV_URL) + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + store.merge_section( + "operators", + { + "powerPlatformMaker": { + "username": "maker@example.com", + "tenantId": "tenant-id", + } + }, + ) + observed = {} + + def token_provider(_url, *, preferred_username): + observed["preferred"] = preferred_username + return "token" + + preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username=None, + store=store, + token_provider=token_provider, + identity_provider=lambda _token, *, preferred_username: { + "username": preferred_username, + "tenantId": "tenant-id", + }, + query=lambda *_args, **_kwargs: [ + {"uniquename": "msdyn_EssWorkdayRuntime"} + ], + ) + + assert observed["preferred"] == "maker@example.com" + + +def test_preflight_apply_reuses_approved_plan_maker_identity( + tmp_path: Path, +) -> None: + import workday_connect_preflight as preflight + import workday_connect_store as store_module + + _write_foundation(tmp_path, dataverse_url=ENV_URL) + store = store_module.WorkdayConnectStore(tmp_path) + installed = False + + def query(*_args, **_kwargs): + return ( + [{"uniquename": "msdyn_EssWorkdayRuntime"}] + if installed + else [] + ) + + plan_result = preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username="maker@example.com", + store=store, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=lambda *_args, **_kwargs: { + "username": "maker@example.com", + "tenantId": "tenant-id", + }, + query=query, + ) + _state, approved_hash = store.approve_plan( + "preflight", + plan_result["plan"], + ) + observed = {} + + def token_provider(_url, *, preferred_username): + observed["preferred"] = preferred_username + return "token" + + def installer(*_args, **kwargs): + nonlocal installed + installed = True + return { + "schemaName": "msdyn_EssWorkdayRuntime", + "authenticatedAccount": kwargs["preferred_username"], + } + + preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username=None, + store=store, + token_provider=token_provider, + identity_provider=lambda _token, *, preferred_username: { + "username": preferred_username, + "tenantId": "tenant-id", + }, + query=query, + installer=installer, + approved_install_hash=approved_hash, + plan_verifier=lambda plan, value: store.verify_plan( + "preflight", + plan, + value, + ), + ) + + assert observed["preferred"] == "maker@example.com" + + +def test_preflight_identity_mismatch_is_structured(tmp_path: Path) -> None: + import workday_connect_auth as auth + import workday_connect_preflight as preflight + import workday_connect_store as store_module + + _write_foundation(tmp_path, dataverse_url=ENV_URL) + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + + def mismatch(_token, *, preferred_username): + raise auth.WorkdayConnectIdentityError( + "Dataverse authentication used a different account from the " + "selected Environment Maker." + ) + + with pytest.raises( + preflight.WorkdayConnectPreflightError, + match="different account", + ): + preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username="maker@example.com", + store=store, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=mismatch, + query=lambda *_args, **_kwargs: [], + ) + + +def test_preflight_rejects_unproven_pac_account(tmp_path: Path) -> None: + import workday_connect_preflight as preflight + + _write_foundation(tmp_path, dataverse_url=ENV_URL) + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + plan_result = preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username="maker@example.com", + store=store, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=lambda *_args, **_kwargs: { + "username": "maker@example.com", + "tenantId": "tenant-id", + }, + query=lambda *_args, **_kwargs: [], + ) + _state, approved_hash = store.approve_plan( + "preflight", + plan_result["plan"], + ) + + with pytest.raises( + preflight.WorkdayConnectPreflightError, + match="did not prove", + ): + preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username="maker@example.com", + store=store, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=lambda *_args, **_kwargs: { + "username": "maker@example.com", + "tenantId": "tenant-id", + }, + query=lambda *_args, **_kwargs: [], + installer=lambda *_args, **_kwargs: { + "schemaName": "msdyn_EssWorkdayRuntime", + "authenticatedAccount": "other@example.com", + }, + approved_install_hash=approved_hash, + plan_verifier=lambda plan, value: store.verify_plan( + "preflight", + plan, + value, + ), + ) + + +def test_preflight_rejects_supplied_url_not_proven_for_environment( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + _write_foundation(tmp_path) + + with pytest.raises( + preflight.WorkdayConnectPreflightError, + match="does not match", + ): + preflight.resolve_target( + tmp_path, + dataverse_url="https://wrong.crm.dynamics.com", + state=model.default_state(), + pac_resolver=lambda: Path("pac.exe"), + pac_runner=lambda command, **_kwargs: subprocess.CompletedProcess( + command, + 0, + stdout=json.dumps( + { + "EnvironmentId": "agent-environment", + "OrgUrl": ENV_URL, + } + ), + stderr="", + ), + ) + + +def test_repeated_preflight_preserves_verified_pac_evidence( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + import workday_connect_store as store_module + + _write_foundation(tmp_path, dataverse_url=ENV_URL) + store = store_module.WorkdayConnectStore(tmp_path) + installed = False + + def query(*_args, **_kwargs): + return ( + [{"uniquename": "msdyn_EssWorkdayRuntime"}] + if installed + else [] + ) + + common_kwargs = { + "dataverse_url": ENV_URL, + "maker_username": "maker@example.com", + "store": store, + "token_provider": lambda *_args, **_kwargs: "token", + "identity_provider": lambda *_args, **_kwargs: { + "username": "maker@example.com", + "tenantId": "tenant-id", + }, + "query": query, + } + plan_result = preflight.run_preflight(tmp_path, **common_kwargs) + _state, approved_hash = store.approve_plan( + "preflight", + plan_result["plan"], + ) + + def installer(*_args, **kwargs): + nonlocal installed + installed = True + return { + "schemaName": "msdyn_EssWorkdayRuntime", + "authenticatedAccount": kwargs["preferred_username"], + } + + preflight.run_preflight( + tmp_path, + **common_kwargs, + installer=installer, + approved_install_hash=approved_hash, + plan_verifier=lambda plan, value: store.verify_plan( + "preflight", + plan, + value, + ), + ) + for phase_id in ("entra", "workday-admin"): + for action in model.PHASE_REQUIRED_ACTIONS[phase_id]: + store.complete_action( + phase_id, + action, + evidence={"outcome": "verified"}, + ) + store.set_phase_status(phase_id, "complete") + + result = preflight.run_preflight( + tmp_path, + **common_kwargs, + ) + + assert result["operator"]["credentialStores"]["pac"] == "verified" + assert store.load()["phases"]["entra"]["status"] == "complete" diff --git a/tests/scripts/test_workday_connect_runtime.py b/tests/scripts/test_workday_connect_runtime.py new file mode 100644 index 000000000..d741c08dd --- /dev/null +++ b/tests/scripts/test_workday_connect_runtime.py @@ -0,0 +1,714 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +import json +import sys + +import pytest + + +SCRIPTS = ( + Path(__file__).resolve().parents[2] / "solutions" / "ess-maker-skills" / "scripts" +) +sys.path.insert(0, str(SCRIPTS)) + +import workday_connect_runtime as runtime # noqa: E402 +from workday_connect_model import default_state # noqa: E402 + + +BOT_ID = "11111111-1111-1111-1111-111111111111" +WORKDAY_CONNECTION = "22222222-2222-2222-2222-222222222222" +DATAVERSE_CONNECTION = "33333333-3333-3333-3333-333333333333" + + +def _state(): + state = default_state() + state["scope"].update( + { + "dataverseUrl": "https://org.crm.dynamics.com", + "packageFlavor": "runtime", + "ring": "prod", + "agent": {"botId": BOT_ID}, + } + ) + state["operators"]["powerPlatformMaker"] = {"username": "maker@contoso.com"} + state["phases"]["connections"]["status"] = "complete" + return state + + +def _connections(): + return { + "value": [ + { + "name": WORKDAY_CONNECTION, + "properties": { + "apiId": "/providers/Microsoft.PowerApps/apis/shared_workdaysoap", + "displayName": "Workday", + "statuses": [{"status": "Connected"}], + }, + }, + { + "name": DATAVERSE_CONNECTION, + "properties": { + "apiId": ( + "/providers/Microsoft.PowerApps/apis/" + "shared_commondataserviceforapps" + ), + "displayName": "Dataverse", + "statuses": [{"status": "Connected"}], + }, + }, + ] + } + + +def _pac_runner(command, **_kwargs): + if command[1:3] == ["auth", "list"]: + return SimpleNamespace( + returncode=0, + stdout="[1] * maker@contoso.com Public\n", + stderr="", + ) + if command[1:3] == ["connectivity", "list-connections"]: + return SimpleNamespace( + returncode=0, + stdout=json.dumps(_connections()), + stderr="", + ) + raise AssertionError(command) + + +def _records(): + catalog = runtime.load_catalog() + logical_names = [ + catalog["connectionReferences"]["workday"]["logicalName"], + catalog["connectionReferences"]["dataverse"]["logicalName"], + ] + flow_names = catalog["packages"]["runtime"]["flowNames"] + flows = { + name: { + "workflowid": f"44444444-4444-4444-4444-{index:012d}", + "name": name, + "statecode": 0, + "statuscode": 1, + "category": 5, + } + for index, name in enumerate(flow_names, start=1) + } + return { + "references": { + logical_names[0]: { + "connectionreferenceid": "ref-workday", + "connectionreferencelogicalname": logical_names[0], + "connectionid": None, + }, + logical_names[1]: { + "connectionreferenceid": "ref-dataverse", + "connectionreferencelogicalname": logical_names[1], + "connectionid": None, + }, + "agent-workday": { + "connectionreferencelogicalname": ( + "contoso.55555555-5555-5555-5555-555555555555.shared_workdaysoap" + ), + "connectionreferencedisplayname": "ESS HR Workday", + "connectionid": "agent-workday-connection", + "connectionparametersetconfig": '{"values":{}}', + }, + "agent-dataverse": { + "connectionreferencelogicalname": ( + "contoso." + "66666666-6666-6666-6666-666666666666." + "shared_commondataserviceforapps" + ), + "connectionreferencedisplayname": "Microsoft Dataverse", + "connectionid": "agent-dataverse-connection", + "connectionparametersconfig": '{"values":{}}', + }, + }, + "solution": { + "solutionid": "77777777-7777-7777-7777-777777777777", + "uniquename": "msdyn_EssWorkdayRuntime", + }, + "flows": flows, + } + + +def _query_for(records): + def query(_url, _token, entity_set, _select, filter_expr=None): + if entity_set == "connectionreferences": + return list(records["references"].values()) + if entity_set == "solutions": + return [records["solution"]] + if entity_set == "solutioncomponents": + return [ + {"objectid": flow["workflowid"], "componenttype": 29} + for flow in records["flows"].values() + ] + if entity_set == "workflows": + return list(records["flows"].values()) + raise AssertionError(entity_set) + + return query + + +def _discovery_dependencies(records): + return { + "query": _query_for(records), + "pac_resolver": lambda: Path("pac.exe"), + "runner": _pac_runner, + } + + +def _identity(_token, *, preferred_username): + return { + "username": preferred_username, + "tenantId": "tenant-id", + } + + +def test_runtime_plan_uses_one_dataverse_token_and_exact_targets(): + records = _records() + token_calls = [] + + result = runtime.run_runtime_operation( + _state(), + apply=False, + token_provider=lambda url, preferred_username: ( + token_calls.append((url, preferred_username)) or "token" + ), + identity_provider=_identity, + **_discovery_dependencies(records), + ) + + assert token_calls == [("https://org.crm.dynamics.com", "maker@contoso.com")] + bindings = result["plan"]["connectionBindings"] + assert {value["connectionId"] for value in bindings.values()} == { + WORKDAY_CONNECTION, + DATAVERSE_CONNECTION, + } + assert result["approvalSummary"]["connections"] == [ + "Workday", + "Dataverse", + ] + serialized_summary = __import__("json").dumps( + result["approvalSummary"], + sort_keys=True, + ) + assert WORKDAY_CONNECTION not in serialized_summary + assert DATAVERSE_CONNECTION not in serialized_summary + assert len(result["plan"]["flows"]) == 3 + assert "userContext" not in result["plan"] + assert "token" not in json.dumps(result).casefold() + + +def test_runtime_plan_requires_verified_physical_connections(): + state = _state() + state["phases"]["connections"]["status"] = "pending" + + with pytest.raises( + runtime.WorkdayConnectRuntimeError, + match="connection sign-ins", + ): + runtime.run_runtime_operation( + state, + apply=False, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=_identity, + **_discovery_dependencies(_records()), + ) + + +def test_physical_connection_verification_returns_selected_connection_ids(): + result = runtime.verify_physical_connections( + _state(), + pac_resolver=lambda: Path("pac.exe"), + runner=_pac_runner, + ) + + assert result == { + "makerUsername": "maker@contoso.com", + "connectionIds": { + "workday": WORKDAY_CONNECTION, + "dataverse": DATAVERSE_CONNECTION, + }, + "connections": [ + { + "connector": "shared_workdaysoap", + "displayName": "Workday", + }, + { + "connector": "shared_commondataserviceforapps", + "displayName": "Dataverse", + }, + ], + } + + +@pytest.mark.parametrize( + ("connector_name", "duplicate_id", "display_name"), + [ + ( + "shared_workdaysoap", + "88888888-8888-8888-8888-888888888888", + "Workday alternate", + ), + ( + "shared_commondataserviceforapps", + "99999999-9999-9999-9999-999999999999", + "Dataverse alternate", + ), + ], +) +def test_physical_connection_ambiguity_exposes_structured_candidates( + connector_name: str, + duplicate_id: str, + display_name: str, +): + payload = _connections() + payload["value"].append( + { + "name": duplicate_id, + "properties": { + "apiId": ( + f"/providers/Microsoft.PowerApps/apis/{connector_name}" + ), + "displayName": display_name, + "statuses": [{"status": "Connected"}], + }, + } + ) + + def runner(command, **_kwargs): + if command[1:3] == ["auth", "list"]: + return SimpleNamespace( + returncode=0, + stdout="[1] * maker@contoso.com Public\n", + stderr="", + ) + if command[1:3] == ["connectivity", "list-connections"]: + return SimpleNamespace( + returncode=0, + stdout=json.dumps(payload), + stderr="", + ) + raise AssertionError(command) + + with pytest.raises(runtime.WorkdayConnectRuntimeError) as raised: + runtime.verify_physical_connections( + _state(), + pac_resolver=lambda: Path("pac.exe"), + runner=runner, + ) + + details = raised.value.details + candidates = details["candidateConnections"] + assert details["connector"] == connector_name + assert {value["connectionId"] for value in candidates} == { + duplicate_id, + ( + WORKDAY_CONNECTION + if connector_name == "shared_workdaysoap" + else DATAVERSE_CONNECTION + ), + } + assert duplicate_id not in str(raised.value) + + +def test_runtime_plan_reuses_recorded_connection_ids(): + state = _state() + state["phases"]["connections"]["evidence"] = [ + { + "action": "physical-connections-verified", + "outcome": "verified", + "connectionIds": { + "workday": WORKDAY_CONNECTION, + "dataverse": DATAVERSE_CONNECTION, + }, + } + ] + duplicate_workday = "99999999-9999-9999-9999-999999999999" + payload = _connections() + payload["value"].append( + { + "name": duplicate_workday, + "properties": { + "apiId": ( + "/providers/Microsoft.PowerApps/apis/shared_workdaysoap" + ), + "displayName": "Workday duplicate", + "statuses": [{"status": "Connected"}], + }, + } + ) + + def runner(command, **_kwargs): + if command[1:3] == ["auth", "list"]: + return SimpleNamespace( + returncode=0, + stdout="[1] * maker@contoso.com Public\n", + stderr="", + ) + if command[1:3] == ["connectivity", "list-connections"]: + return SimpleNamespace( + returncode=0, + stdout=json.dumps(payload), + stderr="", + ) + raise AssertionError(command) + + result = runtime.run_runtime_operation( + state, + apply=False, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=_identity, + runner=runner, + query=_query_for(_records()), + pac_resolver=lambda: Path("pac.exe"), + ) + + bindings = result["plan"]["connectionBindings"].values() + assert next( + binding["connectionId"] + for binding in bindings + if binding["connector"] == "shared_workdaysoap" + ) == WORKDAY_CONNECTION + + +def test_runtime_plan_rejects_connection_id_drift_after_verification(): + state = _state() + state["phases"]["connections"]["evidence"] = [ + { + "action": "physical-connections-verified", + "outcome": "verified", + "connectionIds": { + "workday": WORKDAY_CONNECTION, + "dataverse": DATAVERSE_CONNECTION, + }, + } + ] + + with pytest.raises( + runtime.WorkdayConnectRuntimeError, + match="differs from the connection verified", + ): + runtime.run_runtime_operation( + state, + apply=False, + workday_connection_id=( + "99999999-9999-9999-9999-999999999999" + ), + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=_identity, + **_discovery_dependencies(_records()), + ) + + +def test_runtime_apply_verifies_all_mutations(monkeypatch): + records = _records() + verified_hashes = [] + recorded_stages = [] + + def updater(_url, _token, entity_set, record_id, data): + if entity_set == "connectionreferences": + for value in records["references"].values(): + if value["connectionreferenceid"] == record_id: + value.update(data) + return True + if entity_set == "workflows": + for value in records["flows"].values(): + if value["workflowid"] == record_id: + value.update(data) + return True + raise AssertionError((entity_set, record_id, data)) + + monkeypatch.setattr(runtime.shutil, "which", lambda _name: "pwsh.exe") + + def authorization_runner(command, **_kwargs): + assert command[command.index("-PreferredUsername") + 1] == ("maker@contoso.com") + assert command[-2] == "-WorkflowId" + return SimpleNamespace( + returncode=0, + stdout="Dataverse authorization is in place.", + stderr="", + ) + + result = runtime.run_runtime_operation( + _state(), + apply=True, + approved_hash="approved", + verifier=lambda plan, approved_hash: verified_hashes.append( + (plan["planHash"], approved_hash) + ), + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=_identity, + updater=updater, + authorization_runner=authorization_runner, + stage_recorder=lambda action, evidence: recorded_stages.append( + (action, evidence["outcome"]) + ), + **_discovery_dependencies(records), + ) + + assert len(verified_hashes) == 1 + assert len(verified_hashes[0][0]) == 64 + assert verified_hashes[0][1] == "approved" + assert "plan" not in result + assert result["applied"]["verified"] is True + assert recorded_stages == [ + ("connection-references-bound", "verified"), + ("runtime-flows-active", "verified"), + ("delegated-authorization-configured", "verified"), + ] + assert all(value["connectionid"] for value in records["references"].values()) + assert all( + value["statecode"] == 1 and value["statuscode"] == 2 + for value in records["flows"].values() + ) + + +def test_runtime_records_verified_stages_before_later_failure(monkeypatch): + records = _records() + recorded_stages = [] + + def updater(_url, _token, entity_set, record_id, data): + collections = { + "connectionreferences": records["references"].values(), + "workflows": records["flows"].values(), + } + if entity_set in collections: + id_key = ( + "connectionreferenceid" + if entity_set == "connectionreferences" + else "workflowid" + ) + for value in collections[entity_set]: + if value[id_key] == record_id: + value.update(data) + return True + raise AssertionError((entity_set, record_id, data)) + + monkeypatch.setattr(runtime.shutil, "which", lambda _name: "pwsh.exe") + + with pytest.raises( + runtime.WorkdayConnectRuntimeError, + match="authorization failed", + ): + runtime.run_runtime_operation( + _state(), + apply=True, + approved_hash="approved", + verifier=lambda *_args: None, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=_identity, + updater=updater, + authorization_runner=lambda *_args, **_kwargs: SimpleNamespace( + returncode=1, + stdout="[FAIL] denied", + stderr="", + ), + stage_recorder=lambda action, _evidence: recorded_stages.append(action), + **_discovery_dependencies(records), + ) + + assert recorded_stages == [ + "connection-references-bound", + "runtime-flows-active", + ] + assert all(value["connectionid"] for value in records["references"].values()) + assert all( + value["statecode"] == 1 and value["statuscode"] == 2 + for value in records["flows"].values() + ) + + +def test_runtime_requires_completed_connection_phase(): + state = _state() + state["phases"]["connections"]["status"] = "active" + + with pytest.raises( + runtime.WorkdayConnectRuntimeError, + match="connection sign-ins", + ): + runtime.run_runtime_operation( + state, + apply=True, + approved_hash="approved", + verifier=lambda *_args: None, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=_identity, + **_discovery_dependencies(_records()), + ) + + +def test_runtime_plan_rejects_same_named_flow_outside_package(): + records = _records() + first_flow = next(iter(records["flows"].values())) + original_id = first_flow["workflowid"] + + def query(_url, _token, entity_set, _select, filter_expr=None): + if entity_set == "solutioncomponents": + return [ + {"objectid": flow["workflowid"], "componenttype": 29} + for flow in records["flows"].values() + if flow["workflowid"] != original_id + ] + return _query_for(records)( + _url, + _token, + entity_set, + _select, + filter_expr, + ) + + with pytest.raises( + runtime.WorkdayConnectRuntimeError, + match="outside the selected installed package", + ): + runtime.run_runtime_operation( + _state(), + apply=False, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=_identity, + query=query, + pac_resolver=lambda: Path("pac.exe"), + runner=_pac_runner, + ) + + +def test_runtime_connection_inventory_timeout_is_structured(): + def timed_out(command, **_kwargs): + if command[1:3] == ["auth", "list"]: + return SimpleNamespace( + returncode=0, + stdout="[1] * maker@contoso.com Public\n", + stderr="", + ) + raise __import__("subprocess").TimeoutExpired(command, 120) + + with pytest.raises( + runtime.WorkdayConnectRuntimeError, + match="within 2 minutes", + ): + runtime.run_runtime_operation( + _state(), + apply=False, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=_identity, + query=_query_for(_records()), + pac_resolver=lambda: Path("pac.exe"), + runner=timed_out, + ) + + +def test_runtime_ambiguity_reports_only_safe_display_names(): + records = _records() + connections = [ + { + "name": WORKDAY_CONNECTION, + "properties": { + "apiId": ("/providers/Microsoft.PowerApps/apis/shared_workdaysoap"), + "displayName": "Workday Primary", + "statuses": [{"status": "Connected"}], + }, + }, + { + "name": "raw-connection-id-that-must-not-appear", + "properties": { + "apiId": ("/providers/Microsoft.PowerApps/apis/shared_workdaysoap"), + "displayName": "Workday Secondary", + "statuses": [{"status": "Connected"}], + }, + }, + { + "name": DATAVERSE_CONNECTION, + "properties": { + "apiId": ( + "/providers/Microsoft.PowerApps/apis/" + "shared_commondataserviceforapps" + ), + "displayName": "Dataverse", + "statuses": [{"status": "Connected"}], + }, + }, + ] + + def runner(command, **_kwargs): + if command[1:3] == ["auth", "list"]: + return SimpleNamespace( + returncode=0, + stdout="[1] * maker@contoso.com Public\n", + stderr="", + ) + return SimpleNamespace( + returncode=0, + stdout=__import__("json").dumps({"value": connections}), + stderr="", + ) + + with pytest.raises(runtime.WorkdayConnectRuntimeError) as exc_info: + runtime.run_runtime_operation( + _state(), + apply=False, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=_identity, + query=_query_for(records), + pac_resolver=lambda: Path("pac.exe"), + runner=runner, + ) + + message = str(exc_info.value) + assert "Workday Primary" in message + assert "Workday Secondary" in message + assert WORKDAY_CONNECTION not in message + assert "raw-connection-id-that-must-not-appear" not in message + + +def test_runtime_rejects_a_different_dataverse_identity(): + import workday_connect_auth as auth + + def mismatch(_token, *, preferred_username): + raise auth.WorkdayConnectIdentityError( + "Dataverse authentication used a different account from the " + "selected Environment Maker." + ) + + with pytest.raises( + runtime.WorkdayConnectRuntimeError, + match="different account", + ): + runtime.run_runtime_operation( + _state(), + apply=False, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=mismatch, + **_discovery_dependencies(_records()), + ) + + +def test_runtime_authorization_timeout_is_structured(monkeypatch): + plan = { + "scope": { + "dataverseUrl": "https://contoso.crm.dynamics.com", + "makerUsername": "maker@contoso.com", + }, + "delegatedAuthorization": { + "script": "alm/Enable-CosmosDAFlowAuthorization.ps1", + "botId": BOT_ID, + "workflowIds": ["workflow-id"], + }, + } + + def timed_out(command, **_kwargs): + raise __import__("subprocess").TimeoutExpired(command, 600) + + monkeypatch.setattr(runtime.shutil, "which", lambda _name: "pwsh") + with pytest.raises( + runtime.WorkdayConnectRuntimeError, + match="within 10 minutes", + ): + runtime._run_authorization( + plan, + runner=timed_out, + ) diff --git a/tests/scripts/test_workday_connect_store.py b/tests/scripts/test_workday_connect_store.py new file mode 100644 index 000000000..e5aa4311b --- /dev/null +++ b/tests/scripts/test_workday_connect_store.py @@ -0,0 +1,718 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for Workday connect state, migration, and approval safety.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +def _config_path(root: Path) -> Path: + return root / ".local" / "connect" / "workday-da" / "config.json" + + +def test_initialize_creates_only_json_state(tmp_path: Path) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + state = store.initialize() + + assert state["schemaVersion"] == 5 + assert _config_path(tmp_path).exists() + assert not (tmp_path / ".local/connect/workday-da/tasks.md").exists() + assert not (tmp_path / ".local/setup/workday-da/tasks.md").exists() + + +def test_migrates_legacy_rows_without_using_app_uri_as_saml_id( + tmp_path: Path, +) -> None: + import workday_connect_store as store_module + + path = _config_path(tmp_path) + path.parent.mkdir(parents=True) + path.write_text( + json.dumps( + { + "tenant": "contoso_prod", + "tenantId": "entra-tenant", + "entraAdminAccount": "admin@example.com", + "appIdUri": "api://application-id", + "setupStatus": { + "DA1.1": {"state": "done", "verifiedBy": "programmatic"}, + "DA2.1": {"state": "in-progress"}, + }, + } + ), + encoding="utf-8", + ) + + state = store_module.WorkdayConnectStore(tmp_path).initialize() + + assert state["phases"]["preflight"]["status"] == "complete" + assert state["phases"]["entra"]["status"] == "active" + assert state["identifiers"]["entraAppIdUri"] == "api://application-id" + assert ( + state["identifiers"]["workdaySamlEntityId"] + == "http://www.workday.com/contoso_prod" + ) + assert state["migration"]["source"] == "legacy-workday-da-config" + assert state["operators"]["entraAdmin"]["username"] == "admin@example.com" + assert path.with_name("config.pre-v5.json").exists() + + +def test_migration_preserves_existing_tasks_as_snapshot(tmp_path: Path) -> None: + import workday_connect_store as store_module + + path = _config_path(tmp_path) + path.parent.mkdir(parents=True) + path.write_text("{}", encoding="utf-8") + tasks = tmp_path / ".local/setup/workday-da/tasks.md" + connect_tasks = tmp_path / ".local/connect/workday-da/tasks.md" + tasks.parent.mkdir(parents=True) + tasks.write_text("legacy setup checklist", encoding="utf-8") + connect_tasks.write_text("legacy connect checklist", encoding="utf-8") + + store_module.WorkdayConnectStore(tmp_path).initialize() + + assert tasks.read_text(encoding="utf-8") == "legacy setup checklist" + assert connect_tasks.read_text(encoding="utf-8") == ("legacy connect checklist") + + +def test_future_schema_is_rejected_without_rewriting_state( + tmp_path: Path, +) -> None: + import workday_connect_store as store_module + + path = _config_path(tmp_path) + path.parent.mkdir(parents=True) + original = { + "schemaVersion": 999, + "futureField": {"mustRemain": True}, + } + path.write_text(json.dumps(original), encoding="utf-8") + + with pytest.raises( + store_module.WorkdayConnectStoreError, + match="Unsupported Workday connect state schema version", + ): + store_module.WorkdayConnectStore(tmp_path).initialize() + + assert json.loads(path.read_text(encoding="utf-8")) == original + + +def test_legacy_ready_state_reopens_runtime_for_live_topic_proof( + tmp_path: Path, +) -> None: + import workday_connect_store as store_module + + path = _config_path(tmp_path) + path.parent.mkdir(parents=True) + rows = { + row: {"state": "done", "verifiedBy": "legacy"} + for phase_rows in store_module.LEGACY_PHASE_ROWS.values() + for row in phase_rows + } + path.write_text( + json.dumps({"setupStatus": rows}), + encoding="utf-8", + ) + + state = store_module.WorkdayConnectStore(tmp_path).initialize() + + assert state["status"] == "in-progress" + assert state["phases"]["runtime"]["status"] == "active" + assert ( + "workday-topics-activated" not in state["phases"]["runtime"]["completedActions"] + ) + assert state["phases"]["employee-validation"]["status"] == "pending" + + +def test_phase_completion_requires_prerequisite(tmp_path: Path) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + + with pytest.raises( + store_module.WorkdayConnectStoreError, + match="Complete 'preflight'", + ): + store.set_phase_status("entra", "complete") + + +def test_complete_action_is_idempotent(tmp_path: Path) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + store.complete_action( + "preflight", + "verify-target", + evidence={"outcome": "passed"}, + ) + state = store.complete_action( + "preflight", + "verify-target", + evidence={"outcome": "passed"}, + ) + + assert state["phases"]["preflight"]["completedActions"] == ["verify-target"] + assert len(state["phases"]["preflight"]["evidence"]) == 1 + + +def test_complete_action_does_not_regress_completed_phase( + tmp_path: Path, +) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + _complete_phase( + store, + "preflight", + store_module.PHASE_REQUIRED_ACTIONS["preflight"], + ) + + state = store.complete_action( + "preflight", + "verify-target", + evidence={"outcome": "verified"}, + ) + + assert state["phases"]["preflight"]["status"] == "complete" + + +def test_complete_action_reactivates_a_blocked_phase(tmp_path: Path) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + store.set_phase_status( + "preflight", + "blocked", + blocker={ + "operation": "preflight", + "errorType": "TestBlocker", + "message": "Resolve the test blocker.", + }, + ) + + state = store.complete_action( + "preflight", + "verify-target", + evidence={"outcome": "verified"}, + ) + + phase = state["phases"]["preflight"] + assert phase["status"] == "active" + assert phase["blocker"] is None + + +def test_complete_action_requires_completed_prerequisite(tmp_path: Path) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + + with pytest.raises( + store_module.WorkdayConnectStoreError, + match="Complete 'preflight'", + ): + store.complete_action( + "entra", + "exact-application-discovered", + evidence={"outcome": "verified"}, + ) + + +def test_approved_plan_rejects_changed_target(tmp_path: Path) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + for phase_id in ("preflight", "entra", "workday-admin", "connections"): + _complete_phase( + store, + phase_id, + store_module.PHASE_REQUIRED_ACTIONS[phase_id], + ) + plan = { + "phase": "runtime", + "scope": {"tenantId": "tenant-a", "applicationId": "app-a"}, + "actions": ["configure-saml"], + } + _state, approved_hash = store.approve_plan("runtime", plan) + + assert store.verify_plan("runtime", plan, approved_hash) == approved_hash + changed = { + **plan, + "scope": {"tenantId": "tenant-a", "applicationId": "app-b"}, + } + with pytest.raises(store_module.WorkdayConnectPlanChangedError): + store.verify_plan("runtime", changed, approved_hash) + + +def test_approving_completed_runtime_invalidates_employee_validation( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + for definition in model.PHASE_DEFINITIONS: + phase_id = definition.identifier.value + _complete_phase( + store, + phase_id, + set(model.PHASE_REQUIRED_ACTIONS[phase_id]), + ) + + state, _hash = store.approve_plan( + "runtime", + { + "phase": "runtime", + "scope": {"botId": "bot-id"}, + "actions": ["Reapply reviewed runtime bindings"], + }, + ) + + assert state["phases"]["runtime"]["status"] == "active" + assert state["phases"]["employee-validation"]["status"] == "pending" + assert state["phases"]["employee-validation"]["completedActions"] == [] + + +def test_status_returns_progress_roadmap_and_next_phase_summary( + tmp_path: Path, +) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + for action in ("verify-target", "verify-package"): + store.complete_action( + "preflight", + action, + evidence={"outcome": "verified"}, + ) + store.set_phase_status("preflight", "complete") + + status = store.status() + + assert status["nextPhaseId"] == "entra" + assert "| 1 | Preflight | Complete |" in status["progressText"] + assert "| 2 | Microsoft Entra | Next |" in status["progressText"] + assert status["nextPhaseSummary"]["title"] == "Microsoft Entra" + assert len(status["nextPhaseSummary"]["whatHappens"]) == 3 + assert len(status["phases"]) == 6 + + +def test_phase_cannot_complete_without_required_evidence( + tmp_path: Path, +) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + + with pytest.raises( + store_module.WorkdayConnectStoreError, + match="missing required verified actions", + ): + store.set_phase_status("preflight", "complete") + + +def test_scope_change_invalidates_affected_phases(tmp_path: Path) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + store.merge_section("scope", {"agent": {"slug": "agent-a"}}) + for action in ("verify-target", "verify-package"): + store.complete_action( + "preflight", + action, + evidence={"outcome": "verified"}, + ) + store.set_phase_status("preflight", "complete") + + state = store.merge_section("scope", {"agent": {"slug": "agent-b"}}) + + assert state["phases"]["preflight"]["status"] == "pending" + assert state["phases"]["preflight"]["completedActions"] == [] + assert state["phases"]["entra"]["status"] == "pending" + + +def test_regressing_completed_phase_invalidates_downstream( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + for definition in model.PHASE_DEFINITIONS: + phase_id = definition.identifier.value + _complete_phase( + store, + phase_id, + set(model.PHASE_REQUIRED_ACTIONS[phase_id]), + ) + + state = store.set_phase_status( + "runtime", + "blocked", + blocker={ + "operation": "record-topic-activation", + "errorType": "RuntimeVerificationFailed", + "message": "The live topic is not active.", + }, + ) + + assert state["phases"]["runtime"]["status"] == "blocked" + assert state["phases"]["employee-validation"]["status"] == "pending" + assert state["phases"]["employee-validation"]["completedActions"] == [] + + +def _complete_phase(store, phase_id: str, actions: set[str]) -> None: + for action in actions: + store.complete_action( + phase_id, + action, + evidence={"outcome": "verified"}, + ) + store.set_phase_status(phase_id, "complete") + + +def _set_foundation_data(store, *, workday_tenant: str = "contoso") -> None: + store.merge_section( + "identifiers", + { + "entraAppId": "app-id", + "entraAppObjectId": "app-object-id", + "entraServicePrincipalId": "service-principal-id", + "entraAppIdUri": "api://app-id", + "workdaySamlEntityId": (f"http://www.workday.com/{workday_tenant}"), + "scopeGuid": "scope-id", + "signingCertificate": { + "thumbprint": "thumbprint", + "validFrom": "2026-01-01", + "validTo": "2027-01-01", + }, + "oauthClientId": "oauth-client-id", + }, + ) + store.merge_section( + "endpoints", + { + "oauthTokenUrl": "https://example.workday.com/oauth/token", + "restBaseUrl": "https://example.workday.com/ccx/api", + "soapBaseUrl": ( + f"https://example.workday.com/ccx/service/{workday_tenant}" + ), + }, + ) + + +def test_endpoint_change_invalidates_workday_and_downstream_phases( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + for phase_id in ( + "preflight", + "entra", + "workday-admin", + "connections", + "runtime", + "employee-validation", + ): + _complete_phase( + store, + phase_id, + set(model.PHASE_REQUIRED_ACTIONS[phase_id]), + ) + + state = store.merge_section( + "endpoints", + {"restBaseUrl": "https://example.workday.com/ccx/api"}, + ) + + assert state["status"] == "in-progress" + assert state["phases"]["preflight"]["status"] == "complete" + assert state["phases"]["entra"]["status"] == "complete" + assert state["phases"]["workday-admin"]["status"] == "pending" + assert state["phases"]["connections"]["status"] == "pending" + assert state["phases"]["runtime"]["status"] == "pending" + assert state["phases"]["employee-validation"]["status"] == "pending" + + +def test_maker_change_preserves_reusable_tenant_foundation( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + for phase_id in ( + "preflight", + "entra", + "workday-admin", + "connections", + "runtime", + "employee-validation", + ): + _complete_phase( + store, + phase_id, + set(model.PHASE_REQUIRED_ACTIONS[phase_id]), + ) + store.merge_section( + "scope", + { + "entraTenantId": "tenant-id", + "workdayTenant": "contoso", + }, + ) + _set_foundation_data(store) + for phase_id in ("preflight", "entra", "workday-admin"): + _complete_phase( + store, + phase_id, + set(model.PHASE_REQUIRED_ACTIONS[phase_id]), + ) + store.capture_tenant_foundation() + + state = store.merge_section( + "operators", + {"powerPlatformMaker": {"username": "new@example.com"}}, + ) + + assert state["status"] == "in-progress" + assert all(phase["status"] == "pending" for phase in state["phases"].values()) + assert state["tenantFoundation"] is not None + + +def test_v2_state_is_downgraded_when_completion_has_no_evidence( + tmp_path: Path, +) -> None: + import workday_connect_store as store_module + + path = _config_path(tmp_path) + path.parent.mkdir(parents=True) + state = store_module.default_state() + state["schemaVersion"] = 2 + state["phases"]["preflight"]["status"] = "complete" + state["status"] = "in-progress" + path.write_text(json.dumps(state), encoding="utf-8") + + upgraded = store_module.WorkdayConnectStore(tmp_path).initialize() + + assert upgraded["schemaVersion"] == 5 + assert upgraded["phases"]["preflight"]["status"] == "active" + assert upgraded["migration"]["source"] == "workday-connect-state-v2" + + +def test_v3_runtime_completion_is_reopened_for_live_topic_proof( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_store as store_module + + path = _config_path(tmp_path) + path.parent.mkdir(parents=True) + state = model.default_state() + state["schemaVersion"] = 3 + for phase_id in ( + "preflight", + "entra", + "workday-admin", + "connections", + "runtime", + "employee-validation", + ): + actions = set(model.PHASE_REQUIRED_ACTIONS[phase_id]) + actions.discard("workday-topics-activated") + phase = state["phases"][phase_id] + phase["status"] = "complete" + phase["completedActions"] = sorted(actions) + phase["evidence"] = [ + {"action": action, "outcome": "verified"} for action in sorted(actions) + ] + state["status"] = "ready" + path.write_text(json.dumps(state), encoding="utf-8") + + upgraded = store_module.WorkdayConnectStore(tmp_path).initialize() + + assert upgraded["schemaVersion"] == 5 + assert upgraded["status"] == "in-progress" + assert upgraded["phases"]["runtime"]["status"] == "active" + assert upgraded["phases"]["employee-validation"]["status"] == "pending" + assert upgraded["migration"]["source"] == "workday-connect-state-v3" + + +def test_v4_migration_captures_complete_tenant_foundation( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_store as store_module + + path = _config_path(tmp_path) + path.parent.mkdir(parents=True) + state = model.default_state() + state["schemaVersion"] = 4 + state.pop("tenantFoundation") + state["scope"].update( + { + "entraTenantId": "tenant-id", + "workdayTenant": "contoso", + } + ) + state["identifiers"].update( + { + "entraAppId": "app-id", + "entraAppObjectId": "app-object-id", + "entraServicePrincipalId": "service-principal-id", + "entraAppIdUri": "api://app-id", + "workdaySamlEntityId": "http://www.workday.com/contoso", + "scopeGuid": "scope-id", + "signingCertificate": { + "thumbprint": "thumbprint", + "validFrom": "2026-01-01", + "validTo": "2027-01-01", + }, + "oauthClientId": "oauth-client-id", + } + ) + state["endpoints"].update( + { + "oauthTokenUrl": "https://example.workday.com/oauth/token", + "restBaseUrl": "https://example.workday.com/ccx/api", + "soapBaseUrl": "https://example.workday.com/ccx/service/contoso", + } + ) + for phase_id in ("preflight", "entra", "workday-admin"): + actions = sorted(model.PHASE_REQUIRED_ACTIONS[phase_id]) + phase = state["phases"][phase_id] + phase["status"] = "complete" + phase["completedActions"] = actions + phase["evidence"] = [ + {"action": action, "outcome": "verified"} for action in actions + ] + path.write_text(json.dumps(state), encoding="utf-8") + + upgraded = store_module.WorkdayConnectStore(tmp_path).initialize() + + assert upgraded["schemaVersion"] == 5 + assert upgraded["migration"]["source"] == "workday-connect-state-v4" + assert upgraded["tenantFoundation"]["scope"] == { + "entraTenantId": "tenant-id", + "workdayTenant": "contoso", + } + assert path.with_name("config.pre-v5.json").exists() + + +def test_matching_foundation_restores_workday_after_entra_reread( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + store.merge_section( + "scope", + { + "agent": {"slug": "agent-a"}, + "environmentId": "environment-a", + "entraTenantId": "tenant-id", + "workdayTenant": "contoso", + }, + ) + _set_foundation_data(store) + for phase_id in ("preflight", "entra", "workday-admin"): + _complete_phase( + store, + phase_id, + set(model.PHASE_REQUIRED_ACTIONS[phase_id]), + ) + store.capture_tenant_foundation() + + changed = store.merge_section( + "scope", + { + "agent": {"slug": "agent-b"}, + "environmentId": "environment-b", + }, + ) + assert changed["phases"]["workday-admin"]["status"] == "pending" + assert changed["tenantFoundation"] is not None + + store.merge_section( + "identifiers", + { + "signingCertificate": { + "thumbprint": "TH UM BP RI NT", + "validFrom": "2026-01-01T12:00:00+00:00", + "validTo": "2027-01-01T12:00:00+00:00", + } + }, + ) + _complete_phase( + store, + "preflight", + set(model.PHASE_REQUIRED_ACTIONS["preflight"]), + ) + _complete_phase( + store, + "entra", + set(model.PHASE_REQUIRED_ACTIONS["entra"]), + ) + restored, reused = store.restore_workday_foundation() + + assert reused is True + assert restored["phases"]["workday-admin"]["status"] == "complete" + assert restored["phases"]["connections"]["status"] == "pending" + assert any( + evidence["action"] == "tenant-foundation-reused" + for evidence in restored["phases"]["workday-admin"]["evidence"] + ) + + +def test_foundation_is_not_reused_for_a_different_workday_tenant( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + store.merge_section( + "scope", + { + "entraTenantId": "tenant-id", + "workdayTenant": "contoso", + }, + ) + _set_foundation_data(store) + for phase_id in ("preflight", "entra", "workday-admin"): + _complete_phase( + store, + phase_id, + set(model.PHASE_REQUIRED_ACTIONS[phase_id]), + ) + store.capture_tenant_foundation() + + store.merge_section("scope", {"workdayTenant": "fabrikam"}) + restored, reused = store.restore_workday_foundation() + + assert reused is False + assert restored["phases"]["workday-admin"]["status"] == "pending" diff --git a/tests/setup/test_connect_lifecycle.py b/tests/setup/test_connect_lifecycle.py index ca09a366f..272173fbb 100644 --- a/tests/setup/test_connect_lifecycle.py +++ b/tests/setup/test_connect_lifecycle.py @@ -26,7 +26,8 @@ def test_workday_contract_uses_generic_lifecycle() -> None: wiring = contract["phases"][1] assert wiring["mutates"] is True assert wiring["requiredRole"] == "Environment Maker" - assert wiring["rollbackPushGlob"] == "topics/user-context-setup.mcs.yml" + assert wiring["rollbackPushGlobFromAction"] is True + assert "rollbackPushGlob" not in wiring entry = (_CONNECT / "workday" / "SKILL.md").read_text(encoding="utf-8") assert "connect/shared/lifecycle-runner.md" in entry @@ -57,7 +58,8 @@ def test_cea_workday_routing_is_package_gated() -> None: assert "Passed` + full / legacy result" in route assert "Never start a lifecycle from an\n inconclusive package check" in route assert "connect/workday/SKILL.md" in route - assert "Shared provider setup state is not agent connection state" in route + assert ".local/connect/workday-da/config.json" in route + assert "`scope.agent.slug` and `scope.agent.botId` exactly" in route def test_workday_wiring_uses_installed_identity_and_explicit_result() -> None: @@ -65,10 +67,46 @@ def test_workday_wiring_uses_installed_identity_and_explicit_result() -> None: _CONNECT / "workday" / "actions" / "wire-user-context-redirect.md" ).read_text(encoding="utf-8") - assert ".local/agents/{AGENT_SLUG}/topics/" in action - assert "workspace/agents/{AGENT_SLUG}/topics/" in action + assert "workspace/agents/{AGENT_SLUG}/.component-map.json" in action + assert "workspace/agents/{AGENT_SLUG}/{USER_CONTEXT_TOPIC_PATH}" in action assert 'ACTION_RESULT = "cancelled"' in action assert 'ACTION_RESULT = "applied"' in action + assert "ACTION_ROLLBACK_PUSH_GLOB" in action + assert "install-workday-extension-pack.md" not in action + + +def test_connect_contract_action_docs_and_rollback_scopes_are_valid() -> None: + contracts = sorted(_CONNECT.glob("*/contract.json")) + assert contracts + + for contract_path in contracts: + contract = json.loads(contract_path.read_text(encoding="utf-8")) + for phase in contract["phases"]: + action_doc = phase.get("actionDoc") + if action_doc: + assert (_SOLUTION / action_doc).is_file() + + has_static_scope = "rollbackPushGlob" in phase + has_dynamic_scope = phase.get("rollbackPushGlobFromAction") is True + assert not (has_static_scope and has_dynamic_scope) + if phase.get("rollbackLabel"): + assert has_static_scope or has_dynamic_scope + + +def test_dynamic_rollback_scope_is_persisted_and_reused_exactly() -> None: + runner = (_CONNECT / "shared" / "lifecycle-runner.md").read_text( + encoding="utf-8" + ) + schema = (_CONNECT / "shared" / "lifecycle-contract-schema.md").read_text( + encoding="utf-8" + ) + + assert "ACTION_ROLLBACK_PUSH_GLOB" in runner + assert "phases.{id}.rollbackPushGlob" in runner + assert "no wildcard characters" in runner + assert '--only "{ROLLBACK_PUSH_GLOB}"' in runner + assert "rollbackPushGlobFromAction" in schema + assert "ACTION_ROLLBACK_PUSH_GLOB" in schema def test_declarative_agents_do_not_enter_cea_lifecycle() -> None: diff --git a/tests/setup/test_workday_command_discovery.py b/tests/setup/test_workday_command_discovery.py index 274fbb1bb..88bc8843d 100644 --- a/tests/setup/test_workday_command_discovery.py +++ b/tests/setup/test_workday_command_discovery.py @@ -60,3 +60,23 @@ def test_connect_workday_bypasses_runtime_readiness_gate() -> None: assert "typed `/connect` or `/connect-workday`" in normalized assert "even when runtime `connect_ready` is false" in normalized + + +def test_connect_workday_routes_to_architecture_aware_connect() -> None: + instructions = ( + _SOLUTION / ".github" / "copilot-instructions.md" + ).read_text(encoding="utf-8") + hybrid_boundary = ( + _SOLUTION / "src" / "skills" / "setup" / "SKILL.md" + ).read_text(encoding="utf-8") + normalized = _normalize(instructions) + + assert ( + "(`/connect workday` or `/connect-workday`) | " + "`src/skills/connect/SKILL.md`" + ) in normalized + assert ( + "Provision/connect the Workday setup environment (`/connect workday`) " + "| `src/skills/setup/SKILL.md`" + ) not in normalized + assert "immediately read\n`src/skills/connect/SKILL.md`" in hybrid_boundary diff --git a/tests/setup/test_workday_da_foundation.py b/tests/setup/test_workday_da_foundation.py index b147e0765..e12d510ec 100644 --- a/tests/setup/test_workday_da_foundation.py +++ b/tests/setup/test_workday_da_foundation.py @@ -1,10 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""Contracts for the resumable Workday DA setup foundation.""" +"""Structural guards for the simplified Workday DA lifecycle.""" from pathlib import Path -import re _REPO_ROOT = Path(__file__).resolve().parents[2] @@ -17,115 +16,107 @@ / "setup" / "workday-da" ) -_SHARED = _WORKDAY_DA / "shared" -def test_checklist_has_the_complete_unique_step_set() -> None: - tasks = (_WORKDAY_DA / "tasks.md").read_text(encoding="utf-8") - step_ids = re.findall(r"") == len(expected) - -def test_checklist_uses_readable_titles_without_visible_internal_ids() -> None: - tasks = (_WORKDAY_DA / "tasks.md").read_text(encoding="utf-8") - visible_rows = [ - line for line in tasks.splitlines() if line.startswith("- [ ] **") - ] + assert "scripts/workday_connect.py" in skill + assert ".local/connect/workday-da/config.json" in skill + assert "must not edit this file directly" in schema + assert '"schemaVersion": 5' in schema + assert '"tenantFoundation": null' in schema + assert "Markdown state mirror" in schema + assert not (_WORKDAY_DA / "tasks.md").exists() + assert not (_WORKDAY_DA / "shared" / "checklist-updater.md").exists() + assert not (_WORKDAY_DA / "shared" / "permission-gate.md").exists() - assert len(visible_rows) == 21 - assert all(not re.search(r"\bDA\d", line) for line in visible_rows) - assert all("ESS DA" not in line for line in visible_rows) - assert any("Connect Microsoft Entra sign-in to Workday" in line for line in tasks.splitlines()) - assert any("Match the signed-in employee" in line for line in visible_rows) - -def test_orchestrator_renders_canonical_titles_in_canonical_order() -> None: - tasks = (_WORKDAY_DA / "tasks.md").read_text(encoding="utf-8") +def test_orchestrator_exposes_exactly_six_customer_phases() -> None: skill = (_WORKDAY_DA / "SKILL.md").read_text(encoding="utf-8") - canonical_titles = re.findall(r"^- \[ \] \*\*(.+?)\*\*", tasks, re.MULTILINE) - rendered_titles = re.findall(r"^\s+- \{m\} (.+)$", skill, re.MULTILINE) - - assert rendered_titles == canonical_titles - - -def test_state_contract_requires_immediate_durable_updates() -> None: - updater = (_SHARED / "checklist-updater.md").read_text(encoding="utf-8") - schema = (_SHARED / "config-schema.md").read_text(encoding="utf-8") - - assert "A `MANUAL` or attestation-gated row is never" in updater - assert "**Persist immediately — never batch.**" in updater - assert ".local/setup/workday-da/tasks.md" in updater - assert ".local/connect/workday-da/config.json" in updater - assert "Read" in schema and "Merge" in schema and "Write" in schema - assert "sidecarDataverseEndpoint" in schema - assert '"gateEvidence"' in schema - assert '"provenance"' in schema - assert '`reviewed`' in updater - assert "FAILED` or `ERROR` always produces `blocked`" in updater - - -def test_entra_setup_pins_tenant_and_exact_app_identity() -> None: - entra = (_WORKDAY_DA / "provision-entra-app.md").read_text(encoding="utf-8") - gate = (_SHARED / "permission-gate.md").read_text(encoding="utf-8") - - assert "az account show --query tenantId -o tsv" in entra - assert '--tenant "{SETUP_TENANT_ID}"' in entra - assert "normalized **exact equality**" in entra - assert "contains(@, 'workday.com/{tenant}')" not in entra - assert "microsoft.graph.directoryRole" in entra - assert "roleTemplateId" in entra - assert "9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3" in entra - assert "never auto-select by display name" in entra - assert "Never downgrade a programmatic privileged-role" in gate - assert "user_impersonation" in entra - assert "claimsMappingPolicy" in entra - - -def test_workday_tenant_setup_preserves_manual_gates_and_safe_order() -> None: - tasks = (_WORKDAY_DA / "tasks.md").read_text(encoding="utf-8") - tenant = (_WORKDAY_DA / "configure-tenant.md").read_text(encoding="utf-8") - - register = tenant.index("## DA3.1 + DA3.2 — Register the API client") - policy = tenant.index("## DA3.3 — Verify the signed-in employee authentication policy") - - assert register < policy - assert "Single-tenant SAML pre-gate" in tenant - assert "CHECKPOINT_RESULT=\"MANUAL\"" in tenant - assert "ACK=true" in tenant - assert "Workday cert field is not API-reachable" in tenant - assert "checkpoints: WD-CONN-102 | gate: manual" in tasks - assert "checkpoints: WD-API-CLIENT-001 | gate: attest" in tasks - assert ( - "| DA3.2 | `WD-API-CLIENT-001` — Workday connection fields captured" - in tenant + for phase in ( + "Preflight", + "Microsoft Entra", + "Workday administrator", + "Connections", + "Runtime configuration", + "Employee validation", + ): + assert phase in skill + assert "21" not in skill + assert "DA1.1" not in skill + assert "setupStatus" not in skill + + +def test_entra_and_workday_identifiers_remain_distinct() -> None: + entra = (_WORKDAY_DA / "provision-entra-app.md").read_text( + encoding="utf-8" + ) + tenant = (_WORKDAY_DA / "configure-tenant.md").read_text( + encoding="utf-8" + ) + schema = (_WORKDAY_DA / "shared" / "config-schema.md").read_text( + encoding="utf-8" ) - assert "There is no separate domain-to-integration-security-group" in tenant - assert "Do not look for an OAuth-client restriction" in tenant - assert "Existing active policy already allows employee SAML" in tenant - -def test_workday_portal_tasks_start_only_after_the_admin_gate() -> None: - entra = (_WORKDAY_DA / "provision-entra-app.md").read_text(encoding="utf-8") - tenant = (_WORKDAY_DA / "configure-tenant.md").read_text(encoding="utf-8") - normalized_entra = " ".join(entra.split()) + for text in (entra, tenant, schema): + assert "http://www.workday.com/{tenant}" in text or ( + "http://www.workday.com/{workdayTenant}" in text + ) + assert "api://" in text + assert "Never select by display name alone" in entra + assert "Never alias" in schema or "must never be aliases" in schema + assert "entra-handoff" in entra + assert "workday-admin-packet" in tenant + assert "--discovery-file" in entra + assert "--verification-file" in entra + assert "--discovery-json" not in entra + assert "--verification-json" not in entra + assert "exits with code 0" in entra + assert "partial stdout after\na nonzero exit" in entra + assert "legacy `src/skills/setup/workday/` procedure" in entra + assert 'broad "everything is done" confirmation' in entra + assert '"all good", "continue", or\n"proceed"' in entra + + +def test_manual_handoff_is_one_packet_not_row_attestations() -> None: + tenant = (_WORKDAY_DA / "configure-tenant.md").read_text( + encoding="utf-8" + ) - assert "Workday-side issuer, service-provider ID, and certificate" in normalized_entra + assert "one administrator handoff" in tenant + assert "one response form" in tenant + assert "repeated confirmations" in tenant + assert "identityProviderQuestion" in tenant + assert "Microsoft Entra ID" in tenant + assert "Okta" in tenant + assert "Ping Identity" in tenant + assert "Another sign-in provider" in tenant + assert "No enabled SAML row" in tenant + assert "I'm not sure" in tenant + assert "Do not infer a match from the\n provider choice alone" in tenant + assert "certificateSelectionQuestion" in tenant + assert "The new certificate created from the Entra Base64 file" in tenant + assert "A different existing Workday certificate" in tenant + assert "No certificate is selected" in tenant + assert "Never suggest, prefill, or ask the administrator to confirm" in tenant + assert "display name is optional support context" in tenant assert ( - "happens in the next phase, after the Workday-administrator gate" - in normalized_entra - ) - assert "Do not ask the maker to open Workday" in entra - assert tenant.index("## DA3.0 — Workday administrator gate") < tenant.index( - "## DA3.0b — Single-tenant SAML pre-gate" - ) + "exactly one response form using one structured\n" + "`vscode_askQuestions` call" + ) in tenant + assert '"header": "Identity provider"' in tenant + assert '"header": "Authentication policy"' in tenant + assert '"header": "Network readiness"' in tenant + assert "multiline text\nbox" in tenant + assert "Do not add `recommended`" in tenant + assert "free-text request for several numbered answers" in tenant + assert '"all good", "continue", or "proceed"' in tenant + assert "do not move to another field" in tenant + assert "search workspace files" in tenant + assert "CHECKPOINT_RESULT" not in tenant + assert "ACK=true" not in tenant diff --git a/tests/setup/test_workday_da_orchestration.py b/tests/setup/test_workday_da_orchestration.py index 533360dc7..cde773874 100644 --- a/tests/setup/test_workday_da_orchestration.py +++ b/tests/setup/test_workday_da_orchestration.py @@ -1,9 +1,14 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""Contracts for the runnable Workday DA setup orchestration.""" +"""Contracts for the simplified Workday DA orchestration.""" +import argparse +import json from pathlib import Path +import re + +import pytest _REPO_ROOT = Path(__file__).resolve().parents[2] @@ -18,65 +23,418 @@ ) -def test_orchestrator_resumes_durable_state_without_restarting_setup() -> None: +def test_orchestrator_resumes_from_controller_status() -> None: text = (_WORKDAY_DA / "SKILL.md").read_text(encoding="utf-8") normalized = " ".join(text.split()) - assert "pick the first whose state is not `done`" in text - assert "must not** batch those writes" in text - assert 'provider `status` to be `"ready"`' in text - assert "you do not need to run `/setup` again" in normalized - assert "Here's the plan for connecting Workday to your ESS HR agent" in text - assert "- {m} Verify employee SAML sign-in policy" in text - assert "Your ESS HR agent is connected to Workday" in text + assert "python scripts/workday_connect.py status" in text + assert "nextPhaseId" in text + assert "nextPhaseSummary" in text + assert "What happens in this phase:" in text + assert "must not be collapsed into a one-line phase list" in text + assert "Do not\nreplace the phase explanation with only" in text + assert "do not create, copy, update, or infer status from a Markdown" in ( + normalized + ) + assert "resume the same blocker" in text + assert "controller status is `ready`" in text -def test_extension_install_uses_the_ring_aware_runtime_installer() -> None: - text = (_WORKDAY_DA / "install-extension.md").read_text(encoding="utf-8") - normalized = " ".join(text.split()) +def test_every_controller_command_is_documented() -> None: + import workday_connect as controller - assert "install_workday_da_extension.py" in text - assert '--package-flavor "{PACKAGE_FLAVOR}"' in text - assert '--ring "{RING}"' in text - assert "managed-pac.nuget.config" in text - assert "Do not present .NET and PAC as unexplained product setup steps" in normalized - assert "do not restart the Workday checklist" in normalized + guide_text = "\n".join( + path.read_text(encoding="utf-8") + for path in sorted(_WORKDAY_DA.rglob("*.md")) + ) + documented = set( + re.findall(r"workday_connect\.py\s+([a-z][a-z-]*)", guide_text) + ) + assert documented == set(controller._COMMAND_HANDLERS) -def test_connections_are_created_before_binding_and_flow_activation() -> None: - text = (_WORKDAY_DA / "configure-power-platform.md").read_text( - encoding="utf-8" + +def test_workday_guides_use_file_backed_json_inputs() -> None: + guide_text = "\n".join( + path.read_text(encoding="utf-8") + for path in sorted(_WORKDAY_DA.rglob("*.md")) ) - prepare = text.index("## DA4.0 — Prepare the connections page") - bind = text.index("## DA4.3 — Bind the extension connections") + for unsafe_option in ( + "--discovery-json", + "--verification-json", + "--response-json", + "--plan-json", + "--evidence-json", + "--attachment-json", + ): + assert unsafe_option not in guide_text - assert prepare < bind - assert "make.preprod.powerautomate.com" in text - assert "make.powerautomate.com" in text - assert "Workday and Dataverse connections show **Connected**" in text - assert "msdyn_sharedworkdaysoap_workdayruntime" in text - assert "msdyn_sharedcommondataserviceforapps_workdayruntime" in text +def test_workday_forms_do_not_preselect_or_recommend_answers() -> None: + guide_paths = list(_WORKDAY_DA.rglob("*.md")) + list( + ( + _REPO_ROOT + / "solutions" + / "ess-maker-skills" + / "src" + / "skills" + / "connect" + / "workday" + / "actions" + ).glob("*.md") + ) + form_text = "\n".join( + path.read_text(encoding="utf-8") + for path in sorted(guide_paths) + ) -def test_topic_and_authorization_guidance_matches_the_supported_runtime() -> None: - text = (_WORKDAY_DA / "configure-power-platform.md").read_text( - encoding="utf-8" + assert '"recommended": true' not in form_text + assert "(Recommended)" not in form_text + assert "Leave every field and option initially unset" in form_text + assert "do not mark the passing outcome as recommended" in form_text + + +def test_controller_reads_json_payload_from_file(tmp_path: Path) -> None: + import workday_connect as controller + + payload = { + "value": "customer text with 'quotes'; $(not-a-command)", + } + path = tmp_path / "payload.json" + path.write_text(json.dumps(payload), encoding="utf-8") + + assert controller._json_input( + argparse.Namespace(discovery_file=path, discovery_json=None), + "discovery", + "test discovery", + ) == payload + + +@pytest.mark.parametrize( + "command,option", + [ + ("preflight-approve", "--plan-json"), + ("record-agent-binding", "--attachment-json"), + ("record-validation-failure", "--evidence-json"), + ], +) +def test_new_commands_do_not_accept_inline_json( + command: str, + option: str, +) -> None: + import workday_connect as controller + + with pytest.raises(SystemExit): + controller.build_parser().parse_args([command, option, "{}"]) + + +def test_every_phase_dispatch_target_exists_and_schema_is_reference_only() -> None: + skill = (_WORKDAY_DA / "SKILL.md").read_text(encoding="utf-8") + dispatch_targets = re.findall(r"-> read `([^`]+\.md)`", skill) + + assert dispatch_targets + assert all((_WORKDAY_DA / target).is_file() for target in dispatch_targets) + assert "Use `shared/config-schema.md` as the internal state" in skill + assert "not a customer-executed phase" in skill + + +def test_customer_messages_exclude_internal_implementation_terms() -> None: + skill = (_WORKDAY_DA / "SKILL.md").read_text(encoding="utf-8") + activation = ( + _REPO_ROOT + / "solutions" + / "ess-maker-skills" + / "src" + / "skills" + / "connect" + / "workday" + / "actions" + / "activate-workday-topics.md" + ).read_text(encoding="utf-8") + redirect = ( + _REPO_ROOT + / "solutions" + / "ess-maker-skills" + / "src" + / "skills" + / "connect" + / "workday" + / "actions" + / "wire-user-context-redirect.md" + ).read_text(encoding="utf-8") + readiness = skill.split("> Here's who may be needed", 1)[1].split( + "\nRun:", + 1, + )[0] + activation_message = activation.split("**Message:**", 1)[1].split( + "**End message.**", + 1, + )[0] + redirect_message = redirect.split("**Message:**", 1)[1].split( + "**End message.**", + 1, + )[0] + customer_text = readiness + activation_message + redirect_message + + for internal_term in ( + "controller", + "component-map", + "MinimalBot", + "plan hash", + "schema", + "checkpoint", + "CloudFlow", + "delegatedauthorization", + ): + assert internal_term.casefold() not in customer_text.casefold() + assert "Customer-facing language contract" in skill + assert "Never show or narrate them" in skill + + +def test_preflight_is_one_identity_aware_operation() -> None: + text = (_WORKDAY_DA / "install-extension.md").read_text(encoding="utf-8") + + assert "workday_connect.py preflight" in text + assert "pins and verifies the resulting PAC account" in text + assert "verifies the exact Dataverse URL directly" in text + assert "requiresApproval: true" in text + assert "preflight-approve --plan-file" in text + assert "--install-plan-hash" in text + assert "installs it only after exact-plan approval" in text + assert "manual-install instruction" in text + + +def test_connections_are_proven_before_runtime_apply() -> None: + text = (_WORKDAY_DA / "configure-power-platform.md").read_text(encoding="utf-8") + normalized = " ".join(text.split()) + + assert text.index("## Connections") < text.index("## Runtime approval and apply") + assert "runtime-plan" in text + assert "record-connections" in text + assert "record-connections --evidence-json" not in text + assert "manual connection evidence" in text + assert "--confirm-workday-target" in text + assert "Do not begin with a yes/no question" in text + assert text.count("workday_connect.py record-connections") == 2 + assert "`prod` -> `https://make.powerautomate.com`" in text + assert "`preprod` -> `https://make.preprod.powerautomate.com`" in text + assert "`test` -> `https://make.test.powerautomate.com`" in text + assert "Never send a non-production environment to the production maker portal" in text + assert ( + "{POWER_AUTOMATE_ORIGIN}/environments/{ENVIRONMENT_ID}/connections/" + "available/shared_workdaysoap" + ) in text + assert ( + "{POWER_AUTOMATE_ORIGIN}/environments/{ENVIRONMENT_ID}/connections/" + "available/shared_commondataserviceforapps" + ) in text + assert "Create Workday connection" in text + assert "Create Microsoft Dataverse connection" in text + assert "Connections list fallback" in text + assert "Power Apps maker portal" in text + assert "Microsoft Entra ID Integrated" in text + assert "**Microsoft Entra resource URL:**" in text + assert "Do not use the Entra application\n ID URI beginning with `api://`" in text + assert "**Workday OAuth token URL:**" in text + assert "**Client ID:**" in text + assert "not the Microsoft Entra application ID" in text + assert "Do not ask the maker or administrator to provide them again" in normalized + assert "Do not request or collect a Workday password" in text + assert "Reuse a healthy existing connection" in text + assert "Do not open Copilot Studio Connection Settings yet" in text + assert "Do not provide the agent Connection Settings link" in text + assert "Do not diagnose the flow authorization script as failed" in text + assert "keep the customer in the Power Apps **Connections** page" in text + assert "runtime-apply" in text + assert "applied.verified: true" in text + assert "connection-references-bound" in text + assert "runtime-flows-active" in text + assert "delegated-authorization-configured" in text + assert text.index("applied.verified: true") < text.index( + "Only now direct the maker" ) + assert "CLI credential cache and the\nCopilot Studio browser session are separate" in text + assert "show the exact recorded\nPower Platform maker account" in text + assert "record-agent-binding" in text + assert "--attachment-file" in text + assert text.index("runtime-apply") < text.index("record-agent-binding") + assert "--checkpoint WD-CONN-013" not in text + assert "Do not run `WD-CONN-013` separately" in text + assert "confirmation twice" in text + assert "Do not substitute an unscoped" in normalized + assert "--connect-config" in text + assert "one Dataverse token" in normalized + assert "delegated" in text + assert "User Context V2" in text + assert "activate-workday-topics.md" in text + assert text.index("Allow permission") < text.index("activate-workday-topics.md") + + +def test_workday_topic_activation_uses_complete_mapped_scope() -> None: + action = ( + _REPO_ROOT + / "solutions" + / "ess-maker-skills" + / "src" + / "skills" + / "connect" + / "workday" + / "actions" + / "activate-workday-topics.md" + ).read_text(encoding="utf-8") + redirect = ( + _REPO_ROOT + / "solutions" + / "ess-maker-skills" + / "src" + / "skills" + / "connect" + / "workday" + / "actions" + / "wire-user-context-redirect.md" + ).read_text(encoding="utf-8") - assert "Enable all Workday topics" in text - assert "Choose specific Workday topics" in text - assert "legacy ISU/RaaS" not in text - assert "Prefer: return=representation" in text - assert "fails closed on these" in text - assert "Do not rely on the script's exit code" not in text + assert ".component-map.json" in action + assert "{AGENT_SCHEMA}.topic.Workday" in action + assert "all 21 Workday dialog topics" in " ".join(action.split()) + assert "--activate --dry-run" in action + assert "--activate --yes" in action + assert "state` and `status` to `Active`" in action + assert "record-topic-activation" in action + assert "do not treat them as an activation failure" in action + assert "--activate" not in redirect -def test_readiness_requires_a_signed_in_runtime_scenario() -> None: +def test_readiness_requires_real_employee_runtime_evidence() -> None: text = (_WORKDAY_DA / "verify-connection.md").read_text(encoding="utf-8") normalized = " ".join(text.split()) - assert "Run one enabled Workday scenario" in text - assert "returns real Workday data" in normalized - assert 'status: "ready"' in text - assert "does not prove the live" in text + assert "real signed-in employee scenario" in text + assert "returns real" in text + assert "without an unexpected repeated sign-in" in text + assert "Never record employee data or credentials" in normalized + assert "Do not reset completed phases" in normalized + + +def test_capability_claims_match_controller_surface() -> None: + skill = (_WORKDAY_DA / "SKILL.md").read_text(encoding="utf-8") + entra = (_WORKDAY_DA / "provision-entra-app.md").read_text(encoding="utf-8") + tenant = (_WORKDAY_DA / "configure-tenant.md").read_text(encoding="utf-8") + power_platform = (_WORKDAY_DA / "configure-power-platform.md").read_text( + encoding="utf-8" + ) + employee = (_WORKDAY_DA / "verify-connection.md").read_text(encoding="utf-8") + + normalized = { + "skill": " ".join(skill.split()), + "entra": " ".join(entra.split()), + "tenant": " ".join(tenant.split()), + "power_platform": " ".join(power_platform.split()), + "employee": " ".join(employee.split()), + } + + assert "## Capability contract" in skill + assert "## Tenant foundation and deployment scope" in skill + assert ( + "must not by itself require the Entra or Workday administrators" + in (normalized["skill"]) + ) + assert "show this readiness briefing on every invocation" in normalized["skill"] + for required_role in ( + "Power Platform Environment Maker", + "Application Administrator or Cloud Application Administrator", + "Workday Administrator", + "Dataverse System Administrator", + "Workday test employee", + ): + assert required_role in skill + assert ( + "isn't ready until the signed-in Workday scenario succeeds" + in (normalized["skill"]) + ) + assert "Claim an automated change only after" in normalized["skill"] + assert "does not create or modify the Entra application" in (normalized["entra"]) + assert "record-entra" in normalized["entra"] + assert "foundationReuse.eligible" in entra + assert "Expose an API" in entra + assert "Sign SAML response and assertion" in entra + assert "administrator-attestation" in entra + assert "This phase never modifies Workday" in normalized["tenant"] + assert "Create x509 Public Key" in tenant + assert "Client Grant Type" in tenant + assert "Include Workday Owned Scope" in tenant + assert "Confirm network readiness" in tenant + assert ( + "does not create physical connector connections" + in (normalized["power_platform"]) + ) + assert "reruns `WD-REST-002` and `WD-CONN-013`" in (normalized["power_platform"]) + assert ( + "do not treat them alone as proof of a broken package" + in (normalized["power_platform"]) + ) + assert ( + "signed-in employee scenario remains the functional confirmation" + in (normalized["power_platform"]) + ) + assert "These are real automated changes" in (normalized["power_platform"]) + assert "The skill cannot publish the agent" in normalized["employee"] + + +def test_runtime_apply_persists_verified_stages_before_later_failure( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + import workday_connect as controller + import workday_connect_model as model + + store = controller.WorkdayConnectStore(tmp_path) + store.initialize() + for phase_id in ("preflight", "entra", "workday-admin", "connections"): + for action in model.PHASE_REQUIRED_ACTIONS[phase_id]: + store.complete_action( + phase_id, + action, + evidence={"outcome": "verified"}, + ) + store.set_phase_status(phase_id, "complete") + + def fail_after_two_stages(_state, **kwargs): + recorder = kwargs["stage_recorder"] + recorder( + "connection-references-bound", + {"outcome": "verified", "provenance": "Dataverse reread"}, + ) + recorder( + "runtime-flows-active", + {"outcome": "verified", "provenance": "Dataverse reread"}, + ) + raise controller.WorkdayConnectRuntimeError("authorization failed") + + monkeypatch.setattr( + controller, + "run_runtime_operation", + fail_after_two_stages, + ) + args = argparse.Namespace( + plan_hash="approved", + workday_connection_id=None, + dataverse_connection_id=None, + ) + + with pytest.raises( + controller.WorkdayConnectRuntimeError, + match="authorization failed", + ): + controller._runtime_apply(args, store) + + phase = store.load()["phases"]["runtime"] + assert phase["status"] == "active" + assert phase["completedActions"] == [ + "connection-references-bound", + "runtime-flows-active", + ] + assert {record["action"] for record in phase["evidence"]} == set( + phase["completedActions"] + )