diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 38da62aef..e72667258 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -21,6 +21,7 @@ For everything that isn't an attempt to use the kit (general questions, code exp - `/landing-page` - `/create` - `/connect` + - `/connect-workday` - `/delete` - `/evaluate` - `/run` diff --git a/solutions/ess-maker-skills/.github/copilot-instructions.md b/solutions/ess-maker-skills/.github/copilot-instructions.md index ab1c43952..2ff9c57ca 100644 --- a/solutions/ess-maker-skills/.github/copilot-instructions.md +++ b/solutions/ess-maker-skills/.github/copilot-instructions.md @@ -50,6 +50,14 @@ Respond with ONLY this exact message and nothing else: `flightCheckOnly: true`, proceed with `src/skills/flightcheck/SKILL.md`. This exception applies only to `/flightcheck`; every other command remains gated. +- If the user typed `/connect`, allow the command after **local workspace + materialization**, even when runtime `connect_ready` is false. Require + `schema_version: 4`, resolve `.local/config.json` `activeAgent` to the + canonical agent whose `agent.workspace_slug` matches, and require canonical + workspace evidence plus `steps.SETUP-07.state: "done"`. Connector readiness + is intentionally not a prerequisite because `/connect` is the workflow that + resolves product-extension connection gaps. If materialization is incomplete, + show the setup message above and stop. **Except for the cases above, this gate applies to ALL user messages** — including "hello", "hi", "help", @@ -79,8 +87,9 @@ After canonical DA setup is complete: available; - never run Dataverse push, publish, deletion, or server-backed validation instructions; explain that DA-GA deployment is not yet available; -- `/connect` and integration troubleshooting require the corresponding DA-GA - product extension guidance, which is not yet available; +- `/connect workday` uses the checked-in ESS DA HR Workday extension guidance; + unsupported products or agent verticals must stop at their explicit routing + boundary; - `/backup-template-configs` and `/restore-template-configs` are no longer supported because they belonged to the retired Dataverse-based agent model; - `/flightcheck` may run only its local-files scope. diff --git a/solutions/ess-maker-skills/.github/prompts/connect-workday.prompt.md b/solutions/ess-maker-skills/.github/prompts/connect-workday.prompt.md new file mode 100644 index 000000000..6b7bf9638 --- /dev/null +++ b/solutions/ess-maker-skills/.github/prompts/connect-workday.prompt.md @@ -0,0 +1,11 @@ +--- +mode: agent +description: "Connect the active ESS HR agent to Workday" +--- + +# Connect Workday + +Follow the same setup-state check and execution rules as +`.github/prompts/connect.prompt.md`, with `workday` supplied as the selected +integration. Then read `src/skills/connect/SKILL.md` and continue without +asking which system to connect. diff --git a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md index 0998b440d..bffb68f3c 100644 --- a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md +++ b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md @@ -1,22 +1,22 @@ --- mode: agent -description: "Check DA-GA product extension setup availability" +description: "Connect Workday or another supported integration" --- # Connect -**Setup-state check.** Read `.local/setup/config.json` and `.local/config.json`. If canonical state does not have `schema_version: 4` and an `agents` entry matching the active workspace slug with `connect_ready: true`, show: +**Setup-state check.** Read `.local/setup/config.json` and `.local/config.json`. +Resolve `activeAgent` to the canonical agent whose `agent.workspace_slug` +matches. Continue when canonical state has `schema_version: 4`, complete +workspace evidence, and `steps.SETUP-07.state: "done"`. Do not require +`connect_ready: true`; this command configures the product-extension +connections that may currently block runtime readiness. If local workspace +materialization is incomplete, show: > Welcome to the ESS Maker Kit. Before running `/connect`, type `/setup` to set up your environment. and STOP. Otherwise proceed. -Show: - -> DA-GA connector setup requires the corresponding product extension. Extension setup is not yet available in this release. - -and STOP. - You are a script executor. Read `src/skills/connect/SKILL.md` (a short router file) and follow it. It will tell you which step file to read next. Each step file contains pre-written messages between **Message:** and diff --git a/solutions/ess-maker-skills/.github/prompts/menu.prompt.md b/solutions/ess-maker-skills/.github/prompts/menu.prompt.md index 6fc197a6f..61f50ab91 100644 --- a/solutions/ess-maker-skills/.github/prompts/menu.prompt.md +++ b/solutions/ess-maker-skills/.github/prompts/menu.prompt.md @@ -14,7 +14,8 @@ Here's what I can help you with: | Command | What it does | |---------|-------------| | `/landing-page` | Configure the branding and content employees see when they open the ESS agent | -| `/connect` | Show the DA-GA product extension requirement | +| `/connect-workday` | Connect the active ESS HR agent to Workday | +| `/connect` | Choose an available integration | | `/create` | Create a topic, workflow, or evaluation test set locally | | `/update` | Update a topic, workflow, or evaluation test set locally | | `/delete` | Show DA-GA deletion availability | diff --git a/solutions/ess-maker-skills/README.md b/solutions/ess-maker-skills/README.md index 3ea6feda5..fb7675e1e 100644 --- a/solutions/ess-maker-skills/README.md +++ b/solutions/ess-maker-skills/README.md @@ -245,6 +245,9 @@ Connect your agent to ServiceNow for IT tickets, HR cases, and service catalog i Connect your agent to Workday for employee data, compensation, time off, and org lookups. Run `/connect workday` to start. +For Declarative Agents, this release supports the **ESS HR Agent**. Workday +integration with the ESS IT Agent is not supported in this release. + **Two supported install paths** — the kit detects which one applies and routes automatically: - **Simplified** (Microsoft's default for new installs) — just one Workday connection (OAuthUser via Entra ID) plus Dataverse. No ISU service accounts, security groups, or custom reports. User context comes from the Workday REST `/workers/me` endpoint. @@ -268,6 +271,27 @@ Connect your agent to Workday for employee data, compensation, time off, and org **Verify-first approach:** The kit runs API checks against your Workday tenant before asking you to configure anything. On the legacy path, if ISU accounts, auth policies, permissions, or the RaaS report are already set up (common on shared tenants), those tasks are automatically skipped. +**Test and production deployment:** `/connect workday` is the development +environment experience. After the ESS DA HR agent and Workday package are +deployed to Test or Production, an administrator runs the post-deployment +Dataverse authorization script for that target environment. See +[`scripts/alm/README.md`](scripts/alm/README.md) for the required parameters, +safe preview, execution, and verification procedure. + +For ESS DA HR in development, `/connect workday` also guides the maker through +the OAuthUser and Dataverse references, shared connection parameters, +stale-connection recovery, flow enablement, bot-to-flow authorization, V2 +employee context, topic selection, firewall readiness, and a signed-in +end-to-end Workday scenario. Settings without a reliable DA-scoped API require +explicit maker or administrator confirmation rather than being reported as +automatically verified. + +At the beginning of the experience, the skill presents the complete setup plan +and identifies when an Entra administrator, Workday administrator, Power +Platform/Dataverse administrator, InfoSec administrator, or Workday test user +is required. This lets the maker arrange the required participants before the +setup reaches a permission-dependent step. + **What you can build after connecting:** - Look up employee information, compensation, service anniversary, cost center - Check time off balances and request time off diff --git a/solutions/ess-maker-skills/scripts/alm/Enable-CosmosDAFlowAuthorization.ps1 b/solutions/ess-maker-skills/scripts/alm/Enable-CosmosDAFlowAuthorization.ps1 new file mode 100644 index 000000000..00f2e6141 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/alm/Enable-CosmosDAFlowAuthorization.ps1 @@ -0,0 +1,352 @@ +<# +.SYNOPSIS + Provisions the Dataverse records that allow Power Automate cloud flows to be installed and + invoked by a Cosmos-backed Declarative Agent (template gptagent-1.0.0, schema gptagent_*). + +.DESCRIPTION + Flow-RP resolves the "mcsbot" delegated-authorization principal exclusively from Dataverse. + A Cosmos-backed agent has no Dataverse 'bot' row, so that lookup fails and flow install / + invoke return 403. + + Flow-RP never reads the 'bot' table. It only requires: + 1. a 'delegatedauthorization' row of providertype 3 (MCSBot) carrying the bot GUID + 2. an ACCESS team (teamtype 1) linked to that delegated authorization + 3. that team shared on each workflow the agent calls, with at least WriteAccess + + This script creates those three things idempotently. It is safe to re-run: existing records + are detected and reused, and access is only granted where missing. + +.PARAMETER OrgUrl + Dataverse organization URL, e.g. https://contoso.crm.dynamics.com + +.PARAMETER BotId + The agent's CdsBotId (GUID). + +.PARAMETER WorkflowId + One or more workflow GUIDs (the 'workflowid' of each cloud flow the agent calls). + +.PARAMETER TeamName + Optional display name for the access team. + +.PARAMETER AdministratorId + Optional systemuserid to own the team. Must hold System Administrator. Auto-resolved if omitted. + +.PARAMETER BusinessUnitId + Optional business unit. Defaults to the org's root business unit. + +.PARAMETER AccessMask + Access rights granted to the team on each workflow. WriteAccess is the significant one - + Flow-RP maps a mask containing WriteAccess to UserAccessType.Owner. + +.PARAMETER WhatIf + Report what would change without writing anything. + +.EXAMPLE + .\Enable-CosmosDAFlowAuthorization.ps1 ` + -OrgUrl https://contoso.crm.dynamics.com ` + -BotId 923db1bf-2512-4378-a43b-71b944ea717c ` + -WorkflowId 3164dae9-3a2b-5843-98dd-e62bb5123324 + +.NOTES + Requires Azure CLI, signed in to an account with Dataverse system-administrator rights on + the target org. Run Steps in the ESS runbook order: this script covers steps 2-4. +#> + +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [Parameter(Mandatory = $true)][string] $OrgUrl, + [Parameter(Mandatory = $true)][guid] $BotId, + [Parameter(Mandatory = $true)][guid[]] $WorkflowId, + [string] $TeamName, + [guid] $AdministratorId, + [guid] $BusinessUnitId, + [string] $AccessMask = "ReadAccess,WriteAccess,AppendAccess,AppendToAccess,ShareAccess" +) + +$ErrorActionPreference = 'Stop' +$OrgUrl = $OrgUrl.TrimEnd('/') +$ApiBase = "$OrgUrl/api/data/v9.1" + +# providertype option-set value for MCSBot on the delegatedauthorization entity. +$ProviderTypeMcsBot = 3 +# teamtype option-set value for an Access team. Owner teams (0) are rejected by the platform +# with 0x80097207 when linked to a delegated authorization. +$TeamTypeAccess = 1 + +function Write-Step { param([string]$m) Write-Host "`n=== $m" -ForegroundColor Cyan } +function Write-Ok { param([string]$m) Write-Host " [ok] $m" -ForegroundColor Green } +function Write-Reuse { param([string]$m) Write-Host " [reuse] $m" -ForegroundColor DarkGray } +function Write-Create { param([string]$m) Write-Host " [create] $m" -ForegroundColor Yellow } +function Write-Fail { param([string]$m) Write-Host " [FAIL] $m" -ForegroundColor Red } + +function Test-DataverseToken { + param( + [Parameter(Mandatory = $true)][string]$Resource, + [Parameter(Mandatory = $true)][string]$Token + ) + try { + Invoke-RestMethod ` + -Method GET ` + -Uri "$($Resource.TrimEnd('/'))/api/data/v9.1/WhoAmI" ` + -Headers @{ Authorization = "******"; Accept = 'application/json' } ` + -ErrorAction Stop | Out-Null + return $true + } catch { + if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 401) { + return $false + } + throw + } +} + +function Get-DataverseToken { + param([string]$Resource) + $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 + } + + 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) + if ($LASTEXITCODE -ne 0) { + $safeError = ($output | Where-Object { $_ -notmatch '^ESS_DATAVERSE_TOKEN=' }) -join [Environment]::NewLine + throw "Kit Dataverse authentication failed: $safeError" + } + $marker = $output | Where-Object { $_ -match '^ESS_DATAVERSE_TOKEN=' } | Select-Object -Last 1 + if (-not $marker) { + throw "Kit Dataverse authentication did not return an access token." + } + $tok = ([string]$marker).Substring('ESS_DATAVERSE_TOKEN='.Length) + if ([string]::IsNullOrWhiteSpace($tok)) { + throw "Kit Dataverse authentication returned an empty access token for $Resource." + } + return $tok +} + +function Invoke-Dv { + param( + [ValidateSet('GET', 'POST', 'PATCH')][string]$Method = 'GET', + [Parameter(Mandatory = $true)][string]$Path, + $Body + ) + $uri = if ($Path -match '^https?://') { $Path } else { "$ApiBase/$($Path.TrimStart('/'))" } + $headers = @{ + Authorization = "Bearer $script:Token" + Accept = 'application/json' + 'OData-MaxVersion' = '4.0' + 'OData-Version' = '4.0' + Prefer = 'return=representation' + } + try { + if ($null -ne $Body) { + $json = if ($Body -is [string]) { $Body } else { $Body | ConvertTo-Json -Depth 10 } + return Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers -Body $json -ContentType 'application/json' + } + return Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers + } catch { + # PowerShell 7: the response body lives on ErrorDetails. $_.Exception.Response.GetResponseStream() + # does NOT exist on HttpResponseMessage and silently hides the real Dataverse error. + $detail = $_.ErrorDetails.Message + if ($detail) { + try { $parsed = ($detail | ConvertFrom-Json).error.message } catch { $parsed = $detail } + throw "Dataverse $Method $uri failed: $parsed" + } + throw + } +} + +# -------------------------------------------------------------------------------------------- +Write-Step "Connecting to $OrgUrl" +$script:Token = Get-DataverseToken -Resource $OrgUrl +$who = Invoke-Dv -Path 'WhoAmI' +Write-Ok "Authenticated. UserId $($who.UserId), OrgId $($who.OrganizationId)" + +# -------------------------------------------------------------------------------------------- +Write-Step "Step 2/4 - delegatedauthorization for bot $BotId" + +$daFilter = "delegatedauthorizations?`$select=delegatedauthorizationid,name,providertype&`$filter=botid eq '$BotId'" +$daExisting = (Invoke-Dv -Path $daFilter).value + +if ($daExisting.Count -gt 0) { + $daId = $daExisting[0].delegatedauthorizationid + Write-Reuse "delegatedauthorization $daId (providertype $($daExisting[0].providertype))" + if ($daExisting[0].providertype -ne $ProviderTypeMcsBot) { + Write-Fail "Existing delegated authorization has providertype $($daExisting[0].providertype); expected $ProviderTypeMcsBot (MCSBot). Resolve manually." + exit 1 + } +} else { + $daBody = @{ + name = if ($TeamName) { "$TeamName delegated auth" } else { "Cosmos DA delegated auth ($BotId)" } + providertype = $ProviderTypeMcsBot + botid = $BotId.ToString() + } + if ($PSCmdlet.ShouldProcess("delegatedauthorization for bot $BotId", 'Create')) { + $r = Invoke-Dv -Method POST -Path 'delegatedauthorizations?$select=delegatedauthorizationid' -Body $daBody + $daId = $r.delegatedauthorizationid + Write-Create "delegatedauthorization $daId" + } else { + Write-Create "would create delegatedauthorization (providertype $ProviderTypeMcsBot)" + $daId = '' + } +} + +# -------------------------------------------------------------------------------------------- +Write-Step "Step 3/4 - access team linked to the delegated authorization" + +# Query exactly the way Flow-RP does (XrmRequestFactory.GetTeamsForBotIdUri), so a hit here +# means Flow-RP will resolve the bot successfully. +$teamFilter = "teams?`$expand=delegatedauthorizationid&`$filter=delegatedauthorizationid/botid eq '$BotId'&`$select=teamid,name,teamtype" +$teamExisting = (Invoke-Dv -Path $teamFilter).value + +if ($teamExisting.Count -gt 0) { + $teamId = $teamExisting[0].teamid + Write-Reuse "team $teamId '$($teamExisting[0].name)' (teamtype $($teamExisting[0].teamtype))" + if ($teamExisting[0].teamtype -ne $TeamTypeAccess) { + Write-Fail "Existing team is teamtype $($teamExisting[0].teamtype); must be $TeamTypeAccess (Access). Resolve manually." + exit 1 + } +} else { + if (-not $BusinessUnitId) { + $bu = (Invoke-Dv -Path 'businessunits?$select=businessunitid,name&$filter=parentbusinessunitid eq null').value + if (-not $bu) { throw 'Could not resolve the root business unit. Pass -BusinessUnitId explicitly.' } + $BusinessUnitId = $bu[0].businessunitid + Write-Ok "Resolved root business unit $BusinessUnitId ($($bu[0].name))" + } + + if (-not $AdministratorId) { + # The team administrator must hold System Administrator, otherwise team creation fails + # with 0x80041d0a "The team administrator does not have privilege read team." + $roleQ = 'roles?$select=roleid&$filter=name eq ''System Administrator''' + $role = (Invoke-Dv -Path $roleQ).value + if (-not $role) { throw 'Could not locate the System Administrator role. Pass -AdministratorId explicitly.' } + $roleId = $role[0].roleid + $userQ = "systemusers?`$select=systemuserid,fullname&`$filter=systemuserroles_association/any(r:r/roleid eq $roleId) and isdisabled eq false&`$top=1" + $admin = (Invoke-Dv -Path $userQ).value + if (-not $admin) { throw 'Could not locate an enabled System Administrator. Pass -AdministratorId explicitly.' } + $AdministratorId = $admin[0].systemuserid + Write-Ok "Resolved team administrator $AdministratorId ($($admin[0].fullname))" + } + + $teamBody = @{ + name = if ($TeamName) { $TeamName } else { "Cosmos DA bot team ($BotId)" } + teamtype = $TeamTypeAccess + 'businessunitid@odata.bind' = "/businessunits($BusinessUnitId)" + 'administratorid@odata.bind' = "/systemusers($AdministratorId)" + 'delegatedauthorizationid@odata.bind' = "/delegatedauthorizations($daId)" + } + if ($PSCmdlet.ShouldProcess("access team for bot $BotId", 'Create')) { + $r = Invoke-Dv -Method POST -Path 'teams?$select=teamid' -Body $teamBody + $teamId = $r.teamid + Write-Create "team $teamId" + } else { + Write-Create "would create access team (teamtype $TeamTypeAccess)" + $teamId = '' + } +} + +# -------------------------------------------------------------------------------------------- +Write-Step "Step 4/4 - share each workflow with the team" + +foreach ($wf in $WorkflowId) { + + $wfRow = (Invoke-Dv -Path "workflows?`$select=workflowid,name,statecode&`$filter=workflowid eq $wf").value + if (-not $wfRow) { + Write-Fail "workflow $wf not found in this organization - skipping" + continue + } + $wfName = $wfRow[0].name + + $shared = $false + if ($teamId -ne '') { + # RetrieveSharedPrincipalsAndAccess is a FUNCTION, not an action. POSTing it returns + # 0x80060888 "Resource not found for the segment". + $tid = '{"@odata.id":"workflows(' + $wf + ')"}' + $spUri = "$ApiBase/RetrieveSharedPrincipalsAndAccess(Target=@tid)?@tid=" + [uri]::EscapeDataString($tid) + $sp = Invoke-Dv -Path $spUri + $match = $sp.PrincipalAccesses | Where-Object { + $_.Principal.'@odata.type' -match 'team' -and $_.Principal.ownerid -eq $teamId + } + if ($match) { + if ($match.AccessMask -match 'WriteAccess') { + Write-Reuse "'$wfName' already shared with the team (mask: $($match.AccessMask))" + $shared = $true + } else { + Write-Host " [fix] '$wfName' shared but mask lacks WriteAccess ($($match.AccessMask)) - re-granting" -ForegroundColor Yellow + } + } + } + + if (-not $shared) { + $grant = @{ + Target = @{ '@odata.type' = 'Microsoft.Dynamics.CRM.workflow'; workflowid = $wf.ToString() } + PrincipalAccess = @{ + Principal = @{ '@odata.type' = 'Microsoft.Dynamics.CRM.team'; teamid = $teamId } + AccessMask = $AccessMask + } + } + if ($PSCmdlet.ShouldProcess("workflow '$wfName' ($wf)", "Grant $AccessMask to team $teamId")) { + # GrantAccess is idempotent enough in practice, but ModifyAccess is the documented + # call when a share already exists. Try GrantAccess, fall back to ModifyAccess. + try { Invoke-Dv -Method POST -Path 'GrantAccess' -Body $grant | Out-Null } + catch { Invoke-Dv -Method POST -Path 'ModifyAccess' -Body $grant | Out-Null } + Write-Create "'$wfName' shared with team ($AccessMask)" + } else { + Write-Create "would share '$wfName' with the team" + } + } +} + +# -------------------------------------------------------------------------------------------- +Write-Step 'Verification - replaying the exact lookups Flow-RP performs' + +$ok = $true + +$teamCheck = (Invoke-Dv -Path $teamFilter).value +if ($teamCheck.Count -eq 1) { + Write-Ok "GetTeamsForBotId returns 1 team ($($teamCheck[0].teamid))" +} else { + Write-Fail "GetTeamsForBotId returned $($teamCheck.Count) teams; Flow-RP takes the first and expects exactly one" + if ($teamCheck.Count -eq 0) { $ok = $false } +} + +foreach ($wf in $WorkflowId) { + $tid = '{"@odata.id":"workflows(' + $wf + ')"}' + $spUri = "$ApiBase/RetrieveSharedPrincipalsAndAccess(Target=@tid)?@tid=" + [uri]::EscapeDataString($tid) + try { $sp = Invoke-Dv -Path $spUri } catch { Write-Fail "$wf - could not read shares: $_"; $ok = $false; continue } + + $match = $sp.PrincipalAccesses | Where-Object { + $_.Principal.'@odata.type' -match 'team' -and $_.Principal.ownerid -eq $teamCheck[0].teamid + } + if ($match -and $match.AccessMask -match 'WriteAccess') { + # XrmPrincipalAccessExtensions.ToUserAccessType: a mask containing WriteAccess maps to + # UserAccessType.Owner, the maximum, which is unambiguously sufficient for install. + Write-Ok "$wf resolves to UserAccessType.Owner (mask: $($match.AccessMask))" + } else { + Write-Fail "$wf is NOT shared with the team with WriteAccess" + $ok = $false + } +} + +Write-Host '' +if ($ok) { + Write-Host 'Dataverse authorization is in place.' -ForegroundColor Green + Write-Host 'Remaining steps, outside this script:' -ForegroundColor Green + Write-Host ' - Ensure the flow connection reference has connectionparametersetconfig populated (UX workstream).' + Write-Host ' - Ensure delegated authorization is NOT suppressed for Cosmos-backed agents (product code workstream).' + Write-Host ' - Clear cached user flow state before retesting: POST user-connections with connectionId null, or use a fresh user.' + exit 0 +} else { + Write-Host 'Verification FAILED - see [FAIL] lines above.' -ForegroundColor Red + exit 1 +} \ No newline at end of file diff --git a/solutions/ess-maker-skills/scripts/alm/README.md b/solutions/ess-maker-skills/scripts/alm/README.md new file mode 100644 index 000000000..9e36b5fa9 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/alm/README.md @@ -0,0 +1,222 @@ +# ESS DA Workday authorization in Test and Production + +`Enable-CosmosDAFlowAuthorization.ps1` is the idempotent post-deployment script +for ESS Declarative Agent flow authorization. It creates or reuses the +Dataverse delegated authorization and access team required by a Cosmos-backed +ESS Declarative Agent, shares the target Workday cloud flows with that team, +and verifies the resulting access. Run the checked-in script as documented; +do not modify its implementation. + +## PowerShell is the supported implementation + +Run the checked-in `.ps1` directly. Do not port or replace the authorization +implementation with Python. ADK orchestration may resolve parameters, preview +the operation, and invoke the script, but the authorization implementation +remains in the unchanged PowerShell file. The repository already uses +PowerShell for its Windows installers and CI smoke tests, and this script +parses under both PowerShell 7 and Windows PowerShell 5.1. + +PowerShell 7 (`pwsh`) is preferred. The script also requires Azure CLI (`az`) in +the same process environment. A normal Git clone does not require changing the +machine execution policy. If organizational policy prevents local scripts from +running, use the customer-approved signed-script or pipeline process rather +than weakening the machine-wide execution policy. + +## Current source-version limitation + +Keep the checked-in script unchanged. This source version can safely verify and +reuse an existing delegated authorization and single linked Access team, and +can add missing workflow shares. Do not use it to create a missing delegated +authorization or team: + +- Dataverse create requests return `204 No Content` unless the request asks for + a representation, while this source version reads the new IDs from the + response body. +- If multiple linked teams exist, this source version prints `[FAIL]` but may + still return exit code `0`. + +Before execution, confirm exactly one MCSBot delegated authorization and one +linked Access team already exist for the target bot. If either is missing, or +more than one team is returned, stop the deployment and obtain the corrected +Microsoft-owned script version. Do not work around this by modifying the +checked-in file or converting it to another language. + +This release supports the **ESS DA HR Agent** only. Do not run this procedure +for the ESS DA IT Agent. + +## When to run it + +In the development environment, `/connect workday` owns the guided setup and +can invoke this authorization operation after the ESS DA HR agent and Workday +flows exist. + +Makers are not expected to run `/connect workday` in Test or Production. +Instead, a Power Platform administrator runs this script as a post-deployment +step in each target environment: + +1. Deploy the ESS DA HR agent. +2. Install or upgrade the Workday extension package. +3. Apply the target environment's Workday and Dataverse connections. +4. Capture the target environment parameters below. +5. Run the script with `-WhatIf`. +6. Review the preview and obtain the normal deployment approval. +7. Run the script without `-WhatIf`. +8. Complete the connection-binding and runtime checks for that environment. + +Run the operation separately in Test and Production. Never reuse Dev GUIDs. + +## Prerequisites + +- PowerShell 7 or Windows PowerShell. +- Azure CLI (`az`) installed. +- An Azure CLI sign-in to the Entra tenant that owns the target environment. +- Dataverse System Administrator access in the target environment. +- The ESS DA HR agent and Workday extension package already deployed. +- The Workday cloud flows already present in the target environment. + +The script acquires a Dataverse token through Azure CLI. Do not pass an access +token, password, client secret, or Workday credential to the script. + +## Required parameters + +| Parameter | Meaning | How to obtain it | +| --- | --- | --- | +| `OrgUrl` | Target Dataverse organization URL | In the Power Platform admin center, open the target environment and copy its environment URL. Use the Test URL for Test and the Production URL for Production. | +| `BotId` | Target environment's ESS DA HR `CdsBotId` | Capture it from the ESS DA HR deployment or installation output for that target environment. If deployment is automated, expose it as a pipeline output or environment-scoped variable. Do not use the Dev bot ID. | +| `WorkflowId` | One or more target Workday cloud-flow `workflowid` GUIDs | In the target environment, open the installed Workday solution, list its cloud flows, and capture the GUID for every flow the ESS DA HR agent invokes. A flow's GUID is also the `flowId` referenced by the agent topic and the Dataverse `workflowid`. Do not include unrelated environment flows. | + +Optional parameters: + +| Parameter | Recommendation | +| --- | --- | +| `TeamName` | Use a stable target-specific name such as `ESS DA HR Workday - Test`. | +| `AdministratorId` | Omit unless your governance process requires a specific enabled System Administrator to own the team. | +| `BusinessUnitId` | Omit to let the script use the root business unit. | +| `AccessMask` | Keep the script's default unless updated Microsoft product guidance specifies a replacement. | + +If the deployment process cannot unambiguously identify the target bot or +Workday flows, stop the deployment. Do not guess GUIDs. + +## Sign in and verify the target + +```powershell +az login --tenant "" +az account show +az account get-access-token ` + --resource "https://.crm.dynamics.com" ` + --query accessToken ` + --output tsv | Out-Null +``` + +The final command confirms that Azure CLI can acquire a token for the exact +target organization. It does not print or persist the token. + +## Prepare the target parameters + +Use values captured from the same target environment: + +```powershell +$OrgUrl = "https://.crm.dynamics.com" +$BotId = [guid]"" +$WorkflowIds = [guid[]]@( + "", + "" +) +$TeamName = "ESS DA HR Workday - Test" +``` + +For Production, replace every value with the Production environment value and +use a Production-specific team name. + +## Preview + +Run the authorization script with `-WhatIf` first: + +```powershell +& ".\Enable-CosmosDAFlowAuthorization.ps1" ` + -OrgUrl $OrgUrl ` + -BotId $BotId ` + -WorkflowId $WorkflowIds ` + -TeamName $TeamName ` + -WhatIf +``` + +Review that the output targets: + +- the intended Dataverse organization; +- the intended ESS DA HR bot; +- only the expected Workday workflows; +- an access team, not an owner team. + +### New-environment `-WhatIf` behavior + +The authorization script performs its final live verification after the +preview. If workflow shares do not exist yet, `-WhatIf` intentionally does not +create them. The final verification therefore prints `[FAIL]` for those +not-yet-created shares and exits with code `1`. + +For the preview only, this is expected when all of the following are true: + +- each intended share operation is shown as `would share`; +- the target organization, bot, team type, and workflows are correct; +- the only `[FAIL]` lines describe records that the preview intentionally did + not create. + +If the preview says it would create the delegated authorization or team, stop: +that is the unsupported source-version case described above. + +Any authentication, lookup, permission, wrong-target, missing-workflow, +unexpected existing-record, or Dataverse request error is a real preview +failure and must be resolved before apply. The real apply run must still meet +the strict success criteria below. + +## Apply + +After deployment approval, run the same command without `-WhatIf`: + +```powershell +& ".\Enable-CosmosDAFlowAuthorization.ps1" ` + -OrgUrl $OrgUrl ` + -BotId $BotId ` + -WorkflowId $WorkflowIds ` + -TeamName $TeamName +``` + +The script is idempotent. Re-running it reuses valid existing records and adds +only missing workflow access. + +## Successful result + +The command must exit with code `0` and end with: + +```text +Dataverse authorization is in place. +``` + +The verification output must also show: + +- one team returned for the target bot; +- every supplied Workday workflow shared with that team; +- an access mask containing `WriteAccess`. + +Treat any `[FAIL]` line or nonzero exit code as a deployment failure. +This strict rule applies to the real apply run. The documented new-environment +`-WhatIf` exception applies only to the preview and only under the conditions +listed above. + +## Remaining target-environment steps + +This script covers only DA bot-to-flow Dataverse authorization. Before release +sign-off, also confirm: + +- Workday and Dataverse connection references use the target environment's + connections; +- required connection parameter configuration is populated; +- Workday cloud flows are turned on; +- stale user flow-connection state is cleared, or validation uses a fresh + test user; +- a signed-in test user can complete a Workday scenario through the ESS DA HR + agent. + +Record the parameter source, script output, and final runtime result in the +deployment evidence for each environment. diff --git a/solutions/ess-maker-skills/scripts/alm/get_dataverse_token.py b/solutions/ess-maker-skills/scripts/alm/get_dataverse_token.py new file mode 100644 index 000000000..877292429 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/alm/get_dataverse_token.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Emit a validated Dataverse token for the flow-authorization PowerShell script.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS_DIR)) + +import auth # noqa: E402 + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--environment", required=True) + args = parser.parse_args() + + token = auth.authenticate(args.environment.rstrip("/")) + print(f"ESS_DATAVERSE_TOKEN={token}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/solutions/ess-maker-skills/scripts/checkpoint.py b/solutions/ess-maker-skills/scripts/checkpoint.py index d8063f7a3..c19454201 100644 --- a/solutions/ess-maker-skills/scripts/checkpoint.py +++ b/solutions/ess-maker-skills/scripts/checkpoint.py @@ -10,6 +10,7 @@ Usage: python scripts/checkpoint.py "reason for checkpoint" python scripts/checkpoint.py --revert + python scripts/checkpoint.py --revert-reason "reason for checkpoint" [--only "glob"] python scripts/checkpoint.py --baseline python scripts/checkpoint.py --list """ @@ -19,6 +20,7 @@ import shutil import sys import time +from glob import glob from datetime import datetime, timezone EXCLUDE_DIRS = {".baseline", ".checkpoints"} @@ -103,6 +105,45 @@ def restore_from(agent_dir, source_dir): shutil.copy2(src, dst) +def restore_matching(agent_dir, source_dir, pattern): + """Restore only paths matching pattern from a checkpoint.""" + source_root = os.path.abspath(source_dir) + agent_root = os.path.abspath(agent_dir) + source_pattern = os.path.abspath(os.path.join(source_root, pattern)) + agent_pattern = os.path.abspath(os.path.join(agent_root, pattern)) + if os.path.commonpath([source_root, source_pattern]) != source_root: + raise ValueError(f"Restore pattern escapes checkpoint: {pattern}") + if os.path.commonpath([agent_root, agent_pattern]) != agent_root: + raise ValueError(f"Restore pattern escapes agent folder: {pattern}") + + source_matches = { + os.path.relpath(path, source_root) + for path in glob(source_pattern, recursive=True) + } + current_matches = { + os.path.relpath(path, agent_root) + for path in glob(agent_pattern, recursive=True) + } + if not source_matches and not current_matches: + raise ValueError(f'Restore pattern matched no paths: "{pattern}"') + + for relative_path in sorted(source_matches | current_matches): + source_path = os.path.abspath(os.path.join(source_root, relative_path)) + target_path = os.path.abspath(os.path.join(agent_root, relative_path)) + + if os.path.isdir(target_path): + shutil.rmtree(target_path) + elif os.path.exists(target_path): + os.remove(target_path) + + if os.path.isdir(source_path): + os.makedirs(os.path.dirname(target_path), exist_ok=True) + shutil.copytree(source_path, target_path) + elif os.path.isfile(source_path): + os.makedirs(os.path.dirname(target_path), exist_ok=True) + shutil.copy2(source_path, target_path) + + def create_checkpoint(agent_dir, reason): """Create a new checkpoint of current working files. Returns the number.""" checkpoints_dir = get_checkpoints_dir(agent_dir) @@ -153,6 +194,53 @@ def cmd_revert(agent_dir): print(f"Reverted to checkpoint {target}.") +def cmd_revert_reason(agent_dir, reason, only=None): + """Restore the newest checkpoint whose metadata reason exactly matches.""" + checkpoints_dir = get_checkpoints_dir(agent_dir) + if not os.path.exists(checkpoints_dir): + print("ERROR: No checkpoints exist. Nothing to revert.") + sys.exit(1) + + matches = [] + for entry in os.listdir(checkpoints_dir): + if not entry.isdigit() or not os.path.isdir( + os.path.join(checkpoints_dir, entry) + ): + continue + meta_path = os.path.join(checkpoints_dir, entry, "_meta.json") + try: + with open(meta_path, "r", encoding="utf-8") as f: + meta = json.load(f) + except (OSError, ValueError): + continue + if meta.get("reason") == reason: + matches.append(int(entry)) + + if not matches: + print(f'ERROR: No checkpoint found with reason: "{reason}"') + sys.exit(1) + + save_num = create_checkpoint(agent_dir, "auto-save before named revert") + print(f"Checkpoint {save_num} created: auto-save before named revert") + + target = max(matches) + source_dir = os.path.join(checkpoints_dir, str(target)) + if only: + source_root = os.path.abspath(source_dir) + source_pattern = os.path.abspath(os.path.join(source_root, only)) + if os.path.commonpath([source_root, source_pattern]) != source_root: + raise ValueError(f"Restore pattern escapes checkpoint: {only}") + + if only: + restore_matching(agent_dir, source_dir, only) + print( + f'Restored "{only}" from checkpoint {target}: "{reason}".' + ) + else: + restore_from(agent_dir, source_dir) + print(f'Reverted to checkpoint {target}: "{reason}".') + + def cmd_baseline(agent_dir): baseline_dir = get_baseline_dir(agent_dir) if not os.path.exists(baseline_dir): @@ -210,6 +298,8 @@ def main(): print("Usage:") print(' checkpoint.py "reason" — Create a checkpoint') print(" checkpoint.py --revert — Revert to last checkpoint") + print(' checkpoint.py --revert-reason "reason" [--only "glob"] ' + '— Revert all or matching paths to a named checkpoint') print(" checkpoint.py --baseline — Restore original environment state") print(" checkpoint.py --list — List all checkpoints") sys.exit(1) @@ -218,6 +308,28 @@ def main(): if arg == "--revert": cmd_revert(agent_dir) + elif arg == "--revert-reason": + if len(sys.argv) < 3: + print("ERROR: --revert-reason requires an exact checkpoint reason.") + sys.exit(1) + extra_args = sys.argv[2:] + only = None + if "--only" in extra_args: + only_index = extra_args.index("--only") + if only_index == len(extra_args) - 1: + print("ERROR: --only requires a path or glob.") + sys.exit(1) + only = extra_args[only_index + 1] + reason_parts = extra_args[:only_index] + if len(extra_args) != only_index + 2: + print("ERROR: Unexpected arguments after --only.") + sys.exit(1) + else: + reason_parts = extra_args + if not reason_parts: + print("ERROR: --revert-reason requires an exact checkpoint reason.") + sys.exit(1) + cmd_revert_reason(agent_dir, " ".join(reason_parts), only=only) elif arg == "--baseline": cmd_baseline(agent_dir) elif arg == "--list": diff --git a/solutions/ess-maker-skills/scripts/flightcheck/checks/_workday_app_assignment.py b/solutions/ess-maker-skills/scripts/flightcheck/checks/_workday_app_assignment.py index b0b75eeac..2d4e757f7 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/_workday_app_assignment.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/_workday_app_assignment.py @@ -86,12 +86,11 @@ def _workday_hints(config) -> tuple[str, str]: and ``entraAppObjectId`` steers the application lookup. The config-schema documents both as top-level keys of ``.local/config.json`` — the file FlightCheck loads into ``runner.config`` — so that source wins. In - practice the connect / setup playbooks currently persist them to - ``.local/connect/workday/config.json`` instead, which the runner never - loads; without a fallback the hint is always empty at runtime and the - consent / assignment / NameID checks silently validate ``sps[0]`` (an - arbitrary sibling Workday app). We therefore fall back to that connect - config. Any read/parse error degrades to empty hints (→ unscoped / + practice older callers may omit an explicit provider overlay, so they fall + back to ``.local/connect/workday/config.json``. When ``--connect-config`` + was supplied, that explicit architecture-specific file is authoritative: + missing values remain missing rather than bleeding in from CEA state. + Any fallback read/parse error degrades to empty hints (→ unscoped / ``sps[0]`` behavior), never raising — FlightCheck emitters must not throw. """ cfg = config or {} @@ -99,6 +98,8 @@ def _workday_hints(config) -> tuple[str, str]: obj_id = str(cfg.get("entraAppObjectId") or "").strip() if app_id and obj_id: return app_id, obj_id + if cfg.get("_connectConfigPath"): + return app_id, obj_id try: connect_path = os.path.join(".local", "connect", "workday", "config.json") with open(connect_path, encoding="utf-8") as f: diff --git a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday.py b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday.py index c229cebea..d35269db4 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday.py @@ -1179,7 +1179,14 @@ def _check_package_flavor(runner, *, wd_flows: list) -> list[CheckResult]: )) return results - workday_refs = [r for r in refs if _is_workday_soap_connector(r.get("connectorid"))] + workday_refs = [ + r + for r in refs + if _is_workday_soap_connector(r.get("connectorid")) + and not _AGENT_CONNECTION_REF_RE.search( + r.get("connectionreferencelogicalname") or "" + ) + ] runner._workday_connection_refs = workday_refs # Classify each Workday row's suffix (some may not match the @@ -2753,9 +2760,27 @@ def _select_active_workday_cert( return (active, others) -def _format_cert_detail_line(cert: dict, now: datetime) -> str: +def _format_preferred_thumbprint(preferred_thumbprint: str | None) -> str | None: + """Format Graph's colon-free SHA-1 preferred signing thumbprint.""" + normalized = (preferred_thumbprint or "").strip().replace(":", "") + if not re.fullmatch(r"[0-9A-Fa-f]{40}", normalized): + return None + return ":".join( + normalized[index:index + 2].upper() + for index in range(0, len(normalized), 2) + ) + + +def _format_cert_detail_line( + cert: dict, + now: datetime, + *, + thumbprint_override: str | None = None, +) -> str: """Render one cert group as a one-line summary for result text.""" - display, _ok = _format_cert_thumbprint(cert["customKeyIdentifier"]) + display = thumbprint_override + if display is None: + display, _ok = _format_cert_thumbprint(cert["customKeyIdentifier"]) end = cert["end"] if end is None: expiry_str = "NotAfter=(unknown)" @@ -3009,7 +3034,19 @@ def _check_saml_certificate_health(runner) -> list[CheckResult]: (c["end"] is not None and c["end"] < now) for c in cert_groups ) - cert_line = _format_cert_detail_line(active, now) + preferred_display = _format_preferred_thumbprint( + sp.get("preferredTokenSigningKeyThumbprint") + ) + # Graph tenants can surface a non-SHA-1 customKeyIdentifier even though + # preferredTokenSigningKeyThumbprint is the authoritative SHA-1 value. + # With one logical certificate there is no ambiguity, so show the + # preferred thumbprint rather than incorrectly calling the key malformed. + active_thumbprint = preferred_display if len(cert_groups) == 1 else None + cert_line = _format_cert_detail_line( + active, + now, + thumbprint_override=active_thumbprint, + ) rollover_lines = [ f" rollover: {_format_cert_detail_line(c, now)}" for c in others diff --git a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py new file mode 100644 index 000000000..07d1fb334 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py @@ -0,0 +1,214 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +ESS FlightCheck — Declarative Agent (DA) Workday Extension Validation. + +Verifies that the Workday extension package for the **Declarative Agent HR** +flavor of Employee Self-Service is installed into the target Power Platform +environment (``setup/workday-da`` skill, step DA1.1). DA and CEA +ship distinct extension packages under distinct schema names (see +``src/reference/solution-catalog.md``); this module never reuses or is +reused by ``checks/workday.py`` / ``checks/workday_extension.py``, which are +CEA-only (they key off Power Automate connection references and +``.mcs.yml`` topics that do not exist in a DA agent). + +Runnable in isolation via ``--checkpoint WD-DA-PKG-001``. +""" + +from ..runner import CheckResult, Priority, Role, Status +from auth import query_all, AuthExpiredError # scripts/auth.py, on path via cli.py + + +_DA_HR_PARENT_SCHEMA = "msdyn_copilotforemployeeselfservicedahr" +_DA_IT_PARENT_SCHEMA = "msdyn_copilotforemployeeselfservicedait" +_DA_HR_WORKDAY_CHILD_SCHEMA = "msdyn_EssDAHRWorkday" +_MOS_WORKDAY_RUNTIME_SCHEMA = "msdyn_EssWorkdayRuntime" +_DA_HR_AGENT_SCHEMAS = { + _DA_HR_PARENT_SCHEMA, + "gptagent_copilotforemployeeselfservicehr", +} +_DA_IT_AGENT_SCHEMAS = { + _DA_IT_PARENT_SCHEMA, + "gptagent_copilotforemployeeselfserviceit", +} + +_SOLN_SELECT = "solutionid,uniquename,friendlyname,ismanaged,version" + +_ALL_TRACKED_SCHEMAS = ( + _DA_HR_PARENT_SCHEMA, + _DA_IT_PARENT_SCHEMA, + _DA_HR_WORKDAY_CHILD_SCHEMA, + _MOS_WORKDAY_RUNTIME_SCHEMA, +) +_SOLN_FILTER = " or ".join( + f"uniquename eq '{schema}'" for schema in _ALL_TRACKED_SCHEMAS +) + +_DOC_LINK = ( + "https://learn.microsoft.com/en-us/microsoft-365/copilot/" + "employee-self-service/install" +) +_DESCRIPTION = "Workday extension package installed for the DA ESS HR agent" + + +def run_workday_da_checks(runner) -> list[CheckResult]: + """Emit the WD-DA-PKG-xxx checkpoints. + + Currently a single check (``WD-DA-PKG-001``); kept as a category + function so additional DA-Workday rows can be added later without + changing the registry / cli wiring. + """ + return _check_workday_da_package_installed(runner) + + +def _check_workday_da_package_installed(runner) -> list[CheckResult]: + """WD-DA-PKG-001: the DA Workday extension package is installed. + + Always emits exactly one CheckResult (principle 7 — bucket multi-resource + findings). Never raises — all errors are caught and turned into WARNING + results so a transient Dataverse failure does not abort the whole + flightcheck run. + """ + env_url = getattr(runner, "env_url", None) + token = getattr(runner, "dv_token", None) + + if not env_url or not token: + return [_result( + Status.SKIPPED.value, + "Dataverse URL or access token not available in this run.", + )] + + try: + all_solutions = query_all( + env_url, token, + "solutions", + _SOLN_SELECT, + _SOLN_FILTER, + ) + except AuthExpiredError as e: + return [_result( + Status.WARNING.value, + str(e), + remediation="Re-run FlightCheck to refresh the access token.", + )] + except Exception as e: + status_code = getattr(getattr(e, "response", None), "status_code", None) + status_hint = f" [HTTP {status_code}]" if status_code is not None else "" + return [_result( + Status.WARNING.value, + f"Unable to verify the DA Workday package: " + f"{type(e).__name__}{status_hint}: {e}", + remediation=( + "Inspect the error above; common causes are insufficient " + "Dataverse privileges on the solutions table (typically " + "surfaces as HTTP 403) or a transient platform error (HTTP 5xx)." + ), + )] + + installed_names = { + s.get("uniquename", "").casefold(): s for s in all_solutions + } + + config = getattr(runner, "config", {}) or {} + agents = config.get("agents") if isinstance(config, dict) else [] + active_slug = config.get("activeAgent") if isinstance(config, dict) else None + active_agent = next( + ( + agent + for agent in agents or [] + if isinstance(agent, dict) and agent.get("slug") == active_slug + ), + None, + ) + if not isinstance(active_agent, dict): + candidate = config.get("agent") if isinstance(config, dict) else None + active_agent = candidate if isinstance(candidate, dict) else {} + active_schema = str( + active_agent.get("schemaName") + or active_agent.get("schema_name") + or "" + ).casefold() + + if active_schema in _DA_IT_AGENT_SCHEMAS: + return [_result( + Status.FAILED.value, + "The active agent is the ESS DA IT agent.", + remediation=( + "Workday integration with the ESS IT Agent is not supported " + "in this release. Select the ESS HR Agent first." + ), + )] + + hr_installed = ( + active_schema in _DA_HR_AGENT_SCHEMAS + or _DA_HR_PARENT_SCHEMA in installed_names + ) + it_installed = ( + active_schema in _DA_IT_AGENT_SCHEMAS + or _DA_IT_PARENT_SCHEMA in installed_names + ) + + if not hr_installed and it_installed: + return [_result( + Status.FAILED.value, + "An ESS DA IT agent is installed, but no ESS DA HR agent was found.", + remediation=( + "Workday integration with the ESS IT Agent is not supported " + "in this release. Contact your administrator." + ), + )] + + if not hr_installed: + return [_result( + Status.FAILED.value, + "No ESS DA HR agent was found in this environment.", + remediation=( + "Run /setup to install the ESS DA HR agent first, then run " + "/connect workday again." + ), + )] + + required_schema = ( + _MOS_WORKDAY_RUNTIME_SCHEMA + if active_schema == "gptagent_copilotforemployeeselfservicehr" + else _DA_HR_WORKDAY_CHILD_SCHEMA + ) + child = installed_names.get(required_schema.casefold()) + if not child: + return [_result( + Status.FAILED.value, + "The Workday package required by the ESS HR agent is not " + "installed.", + remediation=( + "Run /connect workday to install the required Workday package " + "in this environment, then re-run this check." + ), + )] + + return [_result( + Status.PASSED.value, + "ESS HR agent detected. Workday package installed: " + f"{_describe_solution(child)}.", + )] + + +def _result(status: str, result: str, remediation: str = "") -> CheckResult: + return CheckResult( + roles=[Role.ESS_MAKER.value], + checkpoint_id="WD-DA-PKG-001", + category="Workday DA", + priority=Priority.CRITICAL.value, + status=status, + description=_DESCRIPTION, + result=result, + remediation=remediation, + doc_link=_DOC_LINK, + ) + + +def _describe_solution(sol: dict) -> str: + """Render one solution row as ``uniquename (vX.Y.Z)`` for the result text.""" + name = sol.get("uniquename", "") + version = sol.get("version") + return f"{name} (v{version})" if version else name 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 9f023bc01..f41ca7858 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py @@ -583,10 +583,36 @@ def _check_user_context_redirect(runner) -> list[CheckResult]: ), )] - agent_dirs = sorted( - d for d in agents_root.iterdir() - if d.is_dir() and not d.name.startswith(".") - ) + agent_slug = str( + getattr(runner, "agent_slug", "") + or config.get("activeAgent") + or (config.get("agent") or {}).get("slug") + or "" + ).strip() + if agent_slug: + target = agents_root / agent_slug + if not target.is_dir(): + return [CheckResult(roles=_MAKER_ROLES, + checkpoint_id="WD-REST-002", category=_CATEGORY, + priority=Priority.HIGH.value, + status=Status.NOT_CONFIGURED.value, + description=_REDIRECT_DESC, + result=( + f"No local workspace found for the active agent " + f"'{agent_slug}'." + ), + remediation=( + "Refresh the active agent with fetch_and_setup, then rerun " + "the checkpoint." + ), + doc_link=_DOC_SIMPLIFIED, + )] + agent_dirs = [target] + else: + agent_dirs = sorted( + d for d in agents_root.iterdir() + if d.is_dir() and not d.name.startswith(".") + ) topic_files = [ (d.name, d / "topics" / _USER_CONTEXT_FILE) for d in agent_dirs 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 c5a637d81..26cf8808b 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_tenant.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_tenant.py @@ -24,9 +24,9 @@ reach, and standing up a Workday connection to self-verify would be circular (it needs the same Entra-app + tenant config the ESS agent itself needs). So both checkpoints emit MANUAL attestations — they echo - whatever the operator captured into ``.local/connect/workday/config.json`` - and name the exact Workday admin screen to verify. A MANUAL row never - fails readiness and never auto-completes an attest row. + whatever the operator captured into the provider config explicitly merged + into ``runner.config`` and name the exact Workday admin screen to verify. + A MANUAL row never fails readiness and never auto-completes an attest row. * **Never raise** — the dispatcher wraps every emitter so an unexpected failure degrades to a WARNING for that checkpoint instead of aborting the whole run. @@ -125,7 +125,7 @@ def _check_api_client(config) -> list[CheckResult]: else: result = ( "Workday admin task — no Workday API client has been captured yet " - "(oauthClientId is empty in .local/connect/workday/config.json). " + "(oauthClientId is empty in the active Workday connect config). " "Register the API client and capture its Client ID and Token " "Endpoint from the 'View API Client' screen before this row can " "be attested." diff --git a/solutions/ess-maker-skills/scripts/flightcheck/cli.py b/solutions/ess-maker-skills/scripts/flightcheck/cli.py index 31b2feeaa..069a6cdf2 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/cli.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/cli.py @@ -71,6 +71,7 @@ from flightcheck.checks.graph_connector_kb import run_graph_connector_kb_checks from flightcheck.checks.agent_handoff import run_handoff_topic_checks from flightcheck.checks.workday import run_workday_checks +from flightcheck.checks.workday_da import run_workday_da_checks from flightcheck.checks.workday_tenant import run_workday_tenant_checks from flightcheck.checks.workday_extension import run_workday_extension_checks from flightcheck.checks.topics import run_topic_checks @@ -102,6 +103,7 @@ ("Workday", run_workday_checks), ("Workday Extension", run_workday_extension_checks), ], + "workdayda": [("Workday DA", run_workday_da_checks)], "topics": [("Workday Topics", run_topic_checks)], "graphconnector": [ ("External Systems", run_external_systems_checks), @@ -707,6 +709,45 @@ def _is_native_no_dataverse(config: dict, env_url: str) -> bool: return str(active.get("releaseLine") or "").casefold() == "da" +def _merge_connect_config(config: dict, connect_config_path: str | None) -> dict: + """Overlay provider-owned validation fields onto foundation config. + + Provider connect state intentionally lives outside ``.local/config.json``. + An explicit path keeps CEA and DA Workday state from being guessed or + merged together when both exist in the same workspace. + """ + merged = dict(config or {}) + if not connect_config_path: + return merged + + with open(connect_config_path, "r", encoding="utf-8") as f: + overlay = json.load(f) + if not isinstance(overlay, dict): + raise ValueError(f"{connect_config_path} must contain a JSON object") + + foundation_keys = { + "_connectConfigPath", + "activeAgent", + "agent", + "agents", + "connections", + "dataverseEndpoint", + "environmentId", + "selected_products", + "setup", + "status", + } + for key, value in overlay.items(): + if key not in foundation_keys: + merged[key] = value + if not merged.get("dataverseEndpoint"): + sidecar_endpoint = overlay.get("sidecarDataverseEndpoint") + if isinstance(sidecar_endpoint, str) and sidecar_endpoint.strip(): + merged["dataverseEndpoint"] = sidecar_endpoint.strip() + merged["_connectConfigPath"] = connect_config_path + return merged + + def _run_single_checkpoint(args): """Run exactly one checkpoint (or family) by ID and report only its result. @@ -740,6 +781,12 @@ def _run_single_checkpoint(args): print("ERROR: .local/config.json not found. Run /setup first.") sys.exit(1) + try: + config = _merge_connect_config(config, getattr(args, "connect_config", None)) + except (OSError, ValueError, json.JSONDecodeError) as e: + print(f"ERROR: Unable to load --connect-config: {e}") + sys.exit(1) + env_url = args.environment_url or config.get("dataverseEndpoint", "") if plan.requires_dataverse_endpoint and not env_url: print("ERROR: No dataverseEndpoint in .local/config.json.") @@ -906,6 +953,12 @@ def _run_single_checkpoint(args): target_matcher=lambda cid: registry.matches(target, cid), ) runner.config = config + runner.agent_slug = ( + getattr(args, "agent_slug", None) + or config.get("activeAgent") + or (config.get("agent") or {}).get("slug") + or "" + ) runner.env_url = env_url runner.dv_token = dv_token runner.env_id = env_id @@ -1072,6 +1125,24 @@ def main(): "prerequisites and initialises only the clients it needs. Mutually " "exclusive with --scope.", ) + parser.add_argument( + "--connect-config", + default=None, + help=( + "Merge a provider-specific connect config JSON object into " + ".local/config.json for this run. Connect/setup skills use this " + "when their validation state intentionally lives outside the " + "foundation config." + ), + ) + parser.add_argument( + "--agent-slug", + default=None, + help=( + "Scope agent-local checks to one workspace/agents/ folder. " + "Defaults to activeAgent (or agent.slug) from .local/config.json." + ), + ) parser.add_argument( "--list-checkpoints", action="store_true", help="List the registered setup checkpoint IDs and families (no broad " @@ -1177,6 +1248,12 @@ def main(): with open(config_path, "r", encoding="utf-8") as f: config = json.load(f) + try: + config = _merge_connect_config(config, args.connect_config) + except (OSError, ValueError, json.JSONDecodeError) as e: + print(f"ERROR: Unable to load --connect-config: {e}") + sys.exit(1) + infra_only_scope = args.scope == "infrastructure" da_local_scope = args.scope == "local" env_url = args.environment_url or config.get("dataverseEndpoint", "") @@ -1492,6 +1569,12 @@ def main(): # --- Build runner --- runner = FlightCheckRunner(scope=args.scope) runner.config = config + runner.agent_slug = ( + getattr(args, "agent_slug", None) + or config.get("activeAgent") + or (config.get("agent") or {}).get("slug") + or "" + ) runner.env_url = env_url runner.dv_token = dv_token runner.env_id = env_id @@ -1643,8 +1726,8 @@ def _print_prioritized_summary(result, *, verbose_manual=False): 3. ACTION REQUIRED — full per-row detail (Failed / Error). 4. NEEDS MANUAL VERIFICATION — one line per row (Warning / Manual / NotConfigured). - 5. PASSED — count only (includes Passed + Skipped); point to - report.html for the list. + 5. SKIPPED — count only, when present. + 6. PASSED — count only; point to report.html for the list. The goal is for an operator scanning the terminal to see, in order: am I OK? what must I fix? what must I verify? — without @@ -1653,7 +1736,9 @@ def _print_prioritized_summary(result, *, verbose_manual=False): buckets = bucket_results(result.results) action = buckets[BUCKET_ACTION] manual = buckets[BUCKET_MANUAL] - passed = buckets[BUCKET_PASSED] + completed = buckets[BUCKET_PASSED] + passed = [r for r in completed if r.status == Status.PASSED.value] + skipped = [r for r in completed if r.status == Status.SKIPPED.value] print() print("=" * 64) @@ -1740,7 +1825,13 @@ def _print_prioritized_summary(result, *, verbose_manual=False): print(" (Open report.html for the full result + verification " "steps.)") - # Section 3 — PASSED (count only; the operator doesn't need to + if skipped: + print() + print(f" SKIPPED ({len(skipped)})") + print(" " + "-" * 62) + print(" See report.html for the checks that could not be evaluated.") + + # Section 4 — PASSED (count only; the operator doesn't need to # scroll past 200+ green rows to find what needs their attention). print() print(f" PASSED ({len(passed)})") diff --git a/solutions/ess-maker-skills/scripts/flightcheck/registry.py b/solutions/ess-maker-skills/scripts/flightcheck/registry.py index 6040d49ff..dc759a9a1 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/registry.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/registry.py @@ -53,6 +53,7 @@ from flightcheck.checks.external_systems import run_external_systems_checks from flightcheck.checks.solution import run_solution_checks from flightcheck.checks.workday import run_workday_checks +from flightcheck.checks.workday_da import run_workday_da_checks from flightcheck.checks.workday_tenant import run_workday_tenant_checks from flightcheck.checks.workday_extension import run_workday_extension_checks from flightcheck.checks.topics import run_topic_checks @@ -103,6 +104,7 @@ "Workday Tenant", "External Systems", "Workday", + "Workday DA", "Workday Extension", "Workday Topics", "Graph Connector KB", @@ -266,6 +268,23 @@ class ResolvedPlan: priority=Priority.CRITICAL.value, roles=(Role.ESS_MAKER.value,), ), + # ---- Workday DA: WD-DA-PKG-001 (setup/workday-da skill, step DA1.1) ---- + # WD-DA-PKG-001: the Workday extension package for the Declarative Agent + # (DA) flavor of ESS is installed in the target env. Queries the + # Dataverse `solutions` table for the DA parent (HR/IT) plus its Workday + # child package. Fully independent of ESS-SOLN-001 / WD-PKG-001, which + # only recognize the CEA solution family. + CheckpointSpec( + key="WD-DA-PKG-001", + category_fn=run_workday_da_checks, + category_label="Workday DA", + clients=frozenset({DATAVERSE}), + requires_config=True, + requires_dataverse_endpoint=True, + prereqs=("ENV-002",), + priority=Priority.CRITICAL.value, + roles=(Role.ESS_MAKER.value,), + ), # ---- External Systems: WD-001 (prereq-only, hidden from listing) ---- # Sets runner._workday_flows, which the below-early-return Workday checks # (WD-CONN-012, WD-FLOW-*, WD-WF-*, WD-ENV-*, WD-CONN-*) depend on. @@ -632,6 +651,7 @@ class ResolvedPlan: "ENV-CAPACITY", "ESS-SOLN", "WD-PKG", + "WD-DA-PKG", "WD-CONN", "WD-RUN", "WD-FLOW", diff --git a/solutions/ess-maker-skills/scripts/install_workday_da_extension.py b/solutions/ess-maker-skills/scripts/install_workday_da_extension.py new file mode 100644 index 000000000..ceb131980 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/install_workday_da_extension.py @@ -0,0 +1,279 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Install the Workday package required by the active ESS HR agent.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys + +from flightcheck.checks.workday_da import ( + _DA_HR_WORKDAY_CHILD_SCHEMA, + _MOS_WORKDAY_RUNTIME_SCHEMA, +) + + +WORKDAY_PACKAGES = { + "runtime": { + "applicationName": _MOS_WORKDAY_RUNTIME_SCHEMA, + "schemaName": _MOS_WORKDAY_RUNTIME_SCHEMA, + }, + "legacy-da": { + "applicationName": "msdyn_EssDAHRWorkdayHCM", + "schemaName": _DA_HR_WORKDAY_CHILD_SCHEMA, + }, +} +CLOUD_FOR_RING = { + "preprod": "Preprod", + "prod": "Public", +} +_PROFILE_RE = re.compile(r"^\s*\[(\d+)\]\s*(\*)?\s*(.*)$") + + +class PacCliError(RuntimeError): + """Raised when PAC is unavailable or cannot complete an operation.""" + + +def resolve_pac_executable(environ=os.environ) -> Path: + """Prefer the shared managed PAC installation, then PAC on PATH.""" + local_app_data = environ.get("LOCALAPPDATA") + if local_app_data: + managed = Path(local_app_data) / "InternalTools" / "pac" / "pac.exe" + if managed.is_file(): + return managed + for candidate in ("pac", "pac.cmd", "pac.exe"): + resolved = shutil.which(candidate) + if resolved: + return Path(resolved) + raise PacCliError( + "PAC CLI is not installed. Install Microsoft Power Platform CLI, " + "then retry /connect workday." + ) + + +def _run(command, *, capture_output: bool, timeout: int): + """Run one PAC command without invoking a shell.""" + try: + return subprocess.run( + [str(part) for part in command], + capture_output=capture_output, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise PacCliError( + f"PAC command did not finish within {timeout // 60} minutes." + ) from exc + + +def _parse_profiles(output: str) -> list[dict]: + """Parse the profile index, active marker, and cloud from PAC output.""" + profiles = [] + for line in output.splitlines(): + match = _PROFILE_RE.match(line) + if not match: + continue + remainder = match.group(3) + cloud = next( + ( + token + for token in re.split(r"\s+", remainder) + if token.casefold() in {"public", "preprod"} + ), + None, + ) + if cloud: + profiles.append( + { + "index": match.group(1), + "active": bool(match.group(2)), + "cloud": cloud, + } + ) + return profiles + + +def ensure_pac_auth( + pac_executable: Path, + *, + ring: str, + environment_url: str, + runner=_run, +) -> None: + """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 [] + ) + matching = [ + profile + for profile in profiles + if profile["cloud"].casefold() == cloud.casefold() + ] + active = [profile for profile in matching if profile["active"]] + if len(active) == 1: + return + if len(matching) == 1: + selected = runner( + [ + pac_executable, + "auth", + "select", + "--index", + matching[0]["index"], + ], + capture_output=True, + timeout=60, + ) + if selected.returncode != 0: + raise PacCliError("PAC could not select the required auth profile.") + return + if len(matching) > 1: + raise PacCliError( + f"Multiple PAC profiles exist for {cloud}. Select the correct " + "profile with 'pac auth select', then retry /connect workday." + ) + + command = [ + pac_executable, + "auth", + "create", + "--cloud", + cloud, + ] + if ring == "preprod": + command.extend(["--environment", environment_url]) + command.append("--deviceCode") + authenticated = runner( + command, + capture_output=False, + timeout=15 * 60, + ) + if authenticated.returncode != 0: + raise PacCliError( + f"PAC authentication for {cloud} did not complete successfully." + ) + + +def install_workday_package( + environment_url: str, + package_flavor: str, + *, + ring: str, + pac_resolver=resolve_pac_executable, + runner=_run, +) -> str: + """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}") + environment_url = environment_url.rstrip("/") + if not environment_url.startswith("https://"): + raise ValueError("The Power Platform environment URL must use HTTPS.") + + package = WORKDAY_PACKAGES[package_flavor] + pac_executable = pac_resolver() + ensure_pac_auth( + pac_executable, + ring=ring, + environment_url=environment_url, + runner=runner, + ) + installed = runner( + [ + pac_executable, + "application", + "install", + "--environment", + environment_url, + "--application-name", + package["applicationName"], + ], + capture_output=False, + timeout=20 * 60, + ) + if installed.returncode != 0: + raise PacCliError( + "PAC could not install the Workday package. Review the PAC output " + "above, confirm environment access, and retry /connect workday." + ) + return package["schemaName"] + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Install the Workday package required by an ESS HR agent." + ) + parser.add_argument( + "--url", + required=True, + help="Dataverse environment URL", + ) + parser.add_argument( + "--vertical", + required=True, + choices=["hr"], + help="ESS vertical (this release supports hr only)", + ) + parser.add_argument( + "--package-flavor", + choices=sorted(WORKDAY_PACKAGES), + default="runtime", + help="Package required by the active ESS agent architecture.", + ) + parser.add_argument( + "--ring", + choices=sorted(CLOUD_FOR_RING), + default="prod", + help="Power Platform ring captured during setup.", + ) + args = parser.parse_args() + + package = WORKDAY_PACKAGES[args.package_flavor] + base_result = { + "environmentUrl": args.url.rstrip("/"), + "vertical": args.vertical, + "ring": args.ring, + "packageFlavor": args.package_flavor, + "schemaName": package["schemaName"], + "applicationName": package["applicationName"], + } + try: + schema_name = install_workday_package( + args.url, + args.package_flavor, + ring=args.ring, + ) + except (OSError, PacCliError, RuntimeError, ValueError) as error: + print( + "WORKDAY_PACKAGE_INSTALL_FAILED_JSON:" + f"{json.dumps({**base_result, 'error': str(error)})}", + flush=True, + ) + print(f"ERROR: {error}", file=sys.stderr) + sys.exit(1) + + print( + "INSTALLED_WORKDAY_DA_EXTENSION_JSON:" + f"{json.dumps({**base_result, 'schemaName': schema_name})}" + ) + + +if __name__ == "__main__": + main() diff --git a/solutions/ess-maker-skills/scripts/managed-pac.nuget.config b/solutions/ess-maker-skills/scripts/managed-pac.nuget.config new file mode 100644 index 000000000..cdb154146 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/managed-pac.nuget.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/solutions/ess-maker-skills/src/skills/connect/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/SKILL.md index 7de1b88cc..1381c48c2 100644 --- a/solutions/ess-maker-skills/src/skills/connect/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/connect/SKILL.md @@ -19,15 +19,16 @@ pass it to step1 as PRE_SELECTED_INTEGRATION. Step1 will skip the Read `src/skills/connect/step1.md` and follow it. (Step 1 asks which integration, detects existing state, and dispatches — -ServiceNow to its own step files and Workday to the hybrid-extension boundary -in `src/skills/setup/SKILL.md`.) +ServiceNow to its own step files; Workday first by agent architecture, then +DA to its package/Entra/tenant checklist or CEA to either the lightweight +already-installed lifecycle or the full setup orchestrator.) --- ## Routing Each integration routes differently — ServiceNow has its own step files; -Workday delegates to the setup orchestrator: +Workday routes by architecture before package detection: - **ServiceNow**: `src/skills/connect/servicenow/` - Steps template: `src/skills/connect/servicenow/steps.md` @@ -43,10 +44,34 @@ Workday delegates to the setup orchestrator: - Step 3 (Basic): `step3-basic.md` — install extension pack (Basic fields) - Step 4: `step4.md` — verify connection -- **Workday**: routes to `src/skills/setup/SKILL.md`, which reports that the - separately owned hybrid-extension setup contract is not yet available. It - does not execute the retained legacy Workday playbooks or any retired - foundation setup operation. +- **Workday**: + - **CEA extension already installed elsewhere** — a lightweight lifecycle that + confirms the extension and its connections, wires this agent's Workday + topics, and validates the connection end to end: + `src/skills/connect/workday/SKILL.md`, driven by the generic + `src/skills/connect/shared/lifecycle-runner.md` against + `src/skills/connect/workday/contract.json`. State: + `.local/connect/workday/agents/{agent-slug}/lifecycle.json`. This same runner + contract + pattern is what a future integration reuses — a new ISV only needs its + own contract file and action fragments, not a new runner. + - **CEA full/legacy package or nothing installed yet** — unsupported from + the current hybrid boundary. The router explains the limitation and stops + without reading `src/skills/setup/SKILL.md` or changing state. + - **DA HR agent** — the **DA Workday connect skill** + (`src/skills/setup/workday-da/SKILL.md`). It sequences five steps + (extension package, Entra app, tenant config, Power Platform/agent + integration, and signed-in runtime validation) the same resume-aware way. + State: `.local/setup/workday-da/tasks.md` + `setupStatus` in + `.local/connect/workday-da/config.json`. DA-scoped settings that cannot be + queried reliably remain explicit manual/attestation gates; the provider is + not marked ready until a signed-in Workday scenario succeeds. + - **DA IT agent** — unsupported for Workday in this release. The router + stops before creating state or running any Workday lifecycle step and + directs the maker to contact their administrator. + + `src/skills/connect/step1.md` resolves the active agent from + `.local/config.json` and its canonical materialization record from + `.local/setup/config.json`; it never routes from retired product inventory. Each integration's steps.md and config.json persist after completion. Running `/connect` again lets the user add a different integration 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 new file mode 100644 index 000000000..dec863c5b --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md @@ -0,0 +1,157 @@ +# Connect Lifecycle — Provider Contract Schema (Shared) + +This file documents the **canonical shape** of a provider's connect-lifecycle +contract, read by the generic runner (`lifecycle-runner.md`). It is a +*reference doc*, not an executable fragment — there are no Message blocks here. + +A **provider contract** is what makes the runner reusable across integrations: +Workday, ServiceNow, SuccessFactors, or any future ISV each supply their own +contract file plus their own action fragments for steps that change the +agent. The runner itself contains no integration-specific logic — it only +reads a contract, runs the checkpoints it names, and renders results. + +**Canonical contract file:** `src/skills/connect/{provider}/contract.json` +**Canonical state file (per agent):** +`.local/connect/{provider}/agents/{agentSlug}/lifecycle.json` + +--- + +## Contract fields + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `provider` | string | yes | Short key, lower-case, no spaces (e.g. `"workday"`). Must match the folder name under `src/skills/connect/{provider}/` and the state folder under `.local/connect/{provider}/`. | +| `displayName` | string | yes | Human name shown to the user (e.g. `"Workday"`). | +| `detect` | object | yes | How the runner's caller decides this lifecycle applies at all — see "Detect block" below. | +| `phases` | array | yes | Ordered list of phase objects — see "Phase fields" below. Executed strictly in array order; a phase never starts until every phase before it is `done` (or `skipped`, see below). | + +### Detect block + +```json +"detect": { + "checkpoint": "WD-PKG-001", + "meansInstalled": "Passed", + "meansNotInstalled": "NotConfigured" +} +``` + +- `checkpoint` — a FlightCheck checkpoint ID whose result tells the caller + whether the external package/extension already exists in the environment. +- `meansInstalled` — the `status` value (from `flightcheck/runner.Status`) + that permits installed-package routing. Providers with multiple installed + flavors must also inspect the checkpoint result and select only a compatible + lifecycle. +- `meansNotInstalled` — the sole status that permits a from-scratch setup + route. `Failed`, `Warning`, `Skipped`, `Error`, and unknown statuses are + remediation/stop outcomes, not evidence that the package is absent. + +### Phase fields + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `connectConfig` | string | no | Provider-specific config JSON to pass to FlightCheck as `--connect-config`. Use this when provider state intentionally lives outside `.local/config.json`; the explicit file prevents architecture-specific state from being guessed or merged. | +| `id` | string | yes | Stable, internal only — never shown to the user. | +| `label` | string | yes | Plain-language description shown in the up-front plan and the resume checklist (e.g. `"Confirm the Workday extension and its connections are healthy"`). No internal IDs, checkpoint names, or file paths. | +| `checkpoints` | array of strings | yes | One or more FlightCheck checkpoint IDs (or family wildcards, e.g. `"WD-FLOW-*"`) that gate this phase. The phase is `done` only when every listed checkpoint returns a status allowed by `completionStatuses`. | +| `completionStatuses` | array of strings | no | Statuses allowed to complete this phase. Defaults to `["Passed"]`. Add `Manual`, `Warning`, `NotConfigured`, or `Skipped` only when that outcome is genuinely sufficient for this specific phase; acknowledgement alone must not turn missing evidence into a healthy result. | +| `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"). | +| `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. | + +### Evolving a phase's checkpoint list + +FlightCheck may later ship purpose-built "connect lifecycle" scoped profiles +that replace a phase's individual checkpoint list with one pre-composed +check. Until a profile exists, a contract simply lists the individual +checkpoints that cover the same ground today — there's no separate field for +this; it's just how `checkpoints` is populated in the meantime. When a +consolidated profile ships, update the phase's `checkpoints` list to use it. + +Never mark a phase `done` because a future profile is "assumed" to pass — +only a real checkpoint run (or a real, attested manual step) advances a +phase. + +--- + +## Phase status rules + +Reuses the exact `Status` values `flightcheck/runner.py` already defines — +this schema does not invent a parallel status vocabulary: + +| Checkpoint status | Phase effect | +|---|---| +| `Passed` | Counts toward `done`. | +| `Manual` | Counts toward `done` only when listed in `completionStatuses` and the user explicitly attests. | +| `NotConfigured` / `Skipped` | Counts toward `done` only when explicitly listed in `completionStatuses`. | +| `Warning` | Counts toward `done` only when explicitly listed in `completionStatuses` and the user chooses to continue. | +| `Failed` / `Error` | Blocks the phase. The runner shows the remediation and stops advancing; the user retries after fixing it, or exits and resumes later. | + +A phase is `done` only when **every** checkpoint's current status appears in +that phase's `completionStatuses` (default `Passed`) and any required +acknowledgement is complete. Partial or unavailable evidence leaves the phase +`in-progress`; `Failed`/`Error` blocks it. + +--- + +## State file shape + +```json +{ + "provider": "workday", + "agentSlug": "employee-self-service-hr", + "attested": true, + "attestedAt": "2026-09-15T14:02:00Z", + "currentPhase": "agent-wiring", + "phases": { + "discovery": { + "status": "done", + "lastVerifiedAt": "2026-09-15T14:03:10Z", + "checkpointResults": { "WD-PKG-001": "Passed", "DV-CONN-001": "Passed", "WD-CONN-012": "Passed" }, + "checkpointAcknowledgements": {} + }, + "agent-wiring": { + "status": "pending", + "actionApplied": false, + "checkpointResults": {} + }, + "validation": { "status": "pending", "checkpointResults": {} } + } +} +``` + +- `attested` — `true` once the user has seen the up-front plan (phase labels + + required roles) and agreed to proceed. Set once; never reset by a resume. +- `phases.{id}.status` ∈ `pending` \| `in-progress` \| `done` \| `blocked`. +- `phases.{id}.lastVerifiedAt` — timestamp of the most recent **live** + checkpoint run that produced the current `status`. The runner never trusts + a `done` status without a `lastVerifiedAt` from *this* resume — see + "Live re-verification on resume" in `lifecycle-runner.md`. +- `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}.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 + when the live status still matches; a changed status requires a new decision. + +The `agentSlug` and state-file path are mandatory isolation boundaries. A +provider may be connected to multiple agents in one workspace; no agent may +reuse another agent's progress or checkpoint results. + +--- + +## Round-trip contract + +Any file that touches the state file must: + +1. **Read** the existing file first (it may hold progress from an earlier + turn or an earlier `/connect` session). +2. **Merge** — update only the phase(s) it just ran; never drop other phases' + recorded state. +3. **Write** the merged object back immediately (not batched) — the same + durability rule `checklist-updater.md` applies to the setup checklist. 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 new file mode 100644 index 000000000..483e2e202 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md @@ -0,0 +1,256 @@ +# Connect Lifecycle Runner (Shared) + +The single, provider-agnostic routine that drives **any** integration's +connect lifecycle from its contract file. A provider (Workday, ServiceNow, any +future ISV) supplies a contract (`lifecycle-contract-schema.md`) plus its own +action fragments for mutating phases; this file contains zero +integration-specific logic. Adding a new provider never requires changing this +file — only authoring a new contract + action fragments. + +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 surface a checkpoint ID, phase ID, file +path, or the word "checkpoint"/"contract"/"state file" to the user. + +**Inputs from the calling file:** +- `PROVIDER` — the provider key (e.g. `"workday"`), matching a contract at + `src/skills/connect/{PROVIDER}/contract.json`. +- `AGENT_SLUG` — the active agent's slug from `.local/config.json` + (`activeAgent`, falling back to `agent.slug`). + +**Files:** +- **Contract (read-only):** `src/skills/connect/{PROVIDER}/contract.json` +- **State (read/write):** + `.local/connect/{PROVIDER}/agents/{AGENT_SLUG}/lifecycle.json` — shape + defined in `lifecycle-contract-schema.md`. + +--- + +## L.0 — Load the contract and state + +Read `src/skills/connect/{PROVIDER}/contract.json`. If it does not exist, +**stop and report** — the calling file named a provider with no contract. + +If the contract has `connectConfig` and that file exists, add +`--connect-config "{connectConfig}"` to every FlightCheck command in this +runner. If it does not exist, omit the argument; do not substitute another +provider or architecture's state. + +If `AGENT_SLUG` is empty, stop and ask the user to run `/setup`; agent-specific +mutation and validation must never fall back to scanning every local agent. + +Read `.local/connect/{PROVIDER}/agents/{AGENT_SLUG}/lifecycle.json`. If it +does not exist, this is a first run: initialize it in memory with +`agentSlug: AGENT_SLUG`, `attested: false`, and every phase from the contract +at `status: "pending"`, `checkpointResults: {}` — do not write it to disk yet +(write only after the plan is shown, in L.1). + +--- + +## L.1 — Show the plan and get attestation (first run only) + +Skip this section entirely if the state file already has `attested: true`. + +Build the plan from the contract, in phase order: +- One line per phase using its `label` verbatim. +- Collect the **union** of `requiredRole` across every `mutates: true` phase, + de-duplicated, in the order phases appear. + +**Message:** + +Here's what I'll do to connect this agent to {displayName}: + +{numbered list of phase labels, in contract order} + +This needs someone with the following access: {comma-separated required +roles, or omit this sentence entirely if no phase mutates}. + +I'll check as I go and stop to tell you if something needs attention. Ready +to start? + +**End message.** + +Use the `vscode_askQuestions` tool: + +```json +[ + { + "header": "Start connection", + "question": "Ready to start?", + "options": [ + { "label": "Yes, let's go", "recommended": true }, + { "label": "Not now" } + ], + "allowFreeformInput": false + } +] +``` + +**If "Not now":** Stop here. Do not write the state file — the next +invocation should show this same plan again. + +**If "Yes, let's go":** Write +`.local/connect/{PROVIDER}/agents/{AGENT_SLUG}/lifecycle.json` now with +`agentSlug: AGENT_SLUG`, `attested: true`, `attestedAt` = current UTC +timestamp, and every phase at `status: "pending"`. Continue to L.2. + +--- + +## L.2 — Live re-verification on resume + +**This is the load-bearing rule of this file.** A phase recorded `status: +"done"` in the state file from an earlier turn is a *cache*, not a fact. Never +report a phase complete, and never skip straight past it, without a live +checkpoint run from **this** invocation confirming it still holds. Environments +drift — a connection can be removed, a topic redirect can be reverted outside +this tool — and trusting a stale checkbox has already caused a real bug in +this family of skills once; do not reintroduce it. + +For every phase the state file marks `done`, in contract order, before doing +anything else: + +1. Re-run every checkpoint the phase lists (see L.4's checkpoint-running + steps — reuse that exact mechanism here, silently, without re-showing the + up-front plan). +2. If every checkpoint still resolves to a status allowed by that phase's + `completionStatuses`, and every `Manual`/`Warning` result has a matching + persisted `checkpointAcknowledgements` entry for that checkpoint and + status, update `lastVerifiedAt` and leave the phase `done`. Do **not** + re-render the U.0 table for a phase that was already `done` and stays + `done` on resume — only surface output for phases that change state or that + are not yet done. +3. If any checkpoint resolves to a status **outside** that phase's + `completionStatuses`, **or** an allowed `Manual`/`Warning` result lacks a + persisted acknowledgement matching the checkpoint and current status, the + cached completion has regressed. This includes `Warning`, `Skipped`, + `NotConfigured`, and unacknowledged `Manual`/`Warning` results — not only + `Failed`/`Error`. Set the phase to `blocked` for `Failed`/`Error`, otherwise + set it to `in-progress`, and set **every phase after it** back to `pending` + (later phases may have depended on this one still holding). Persist the + current checkpoint results, render the result using L.4b, and stop. Do not + re-run L.4a's mutating action merely because a live re-check regressed; + mutation requires a fresh gate and rollback checkpoint. + +Once every previously-`done` phase is confirmed (or the loop stopped early on +a regression), continue to L.3. + +--- + +## L.3 — Find the current phase + +Walk the phases in contract order. The **current phase** is the first one +whose `status` is not `done`. + +- If every phase is `done`: go to L.5 (completion). +- Otherwise: set `currentPhase` to that phase's `id` and continue to L.4. + +--- + +## L.4 — Run the current phase + +### L.4a — Mutating phases: gate, then act + +If the current phase has `mutates: true` and `actionApplied` is not yet +`true` in its state entry: + +Apply `permission-gate.md` (from `src/skills/setup/shared/permission-gate.md`) +with `REQUIRED_ROLE` = the phase's `requiredRole`. Use the phase's `gateMode` +(default `"attested"` if the contract omits it); if `gateMode` is +`"programmatic"`, pass the phase's `roleQuery` as `ROLE_QUERY` verbatim, and +treat the query as a pass only if it returns one of `roleQueryPassNames` — the +runner never invents its own role query or pass condition. + +**If the gate returns `"stop"`:** stop here. Leave the phase `in-progress` in +the state file (write it now) so the next invocation resumes at this same +gate rather than re-showing the whole plan. + +**If the gate returns `"pass"`:** if the phase names a `rollbackLabel`, save a +checkpoint first: + +``` +python scripts/checkpoint.py "{rollbackLabel}" +``` + +Then read the phase's `actionDoc` file and follow it completely — it contains +its own Message blocks and tool calls. When it finishes, set +`phases.{id}.actionApplied = true` and write the state file immediately. + +### L.4b — Run the phase's checkpoints + +For each checkpoint ID the current phase lists, run: + +``` +python scripts/flightcheck/cli.py --checkpoint {ID} +``` + +If the contract has `connectConfig` and that file exists, add +`--connect-config "{connectConfig}"`. For agent-local checkpoints, also add +`--agent-slug "{AGENT_SLUG}"`. Use the same arguments when re-verifying +completed phases in L.2. + +After each run, render the result using the exact U.0 and U.0a routines from +`src/skills/setup/shared/checklist-updater.md` (read that file's U.0/U.0a +sections and apply them verbatim against `workspace/flightcheck/results.json` +— do not re-implement or paraphrase that rendering logic here). + +Aggregate the phase's outcome using the phase's `completionStatuses` +(default `["Passed"]`) and the status rules in +`lifecycle-contract-schema.md`: + +- **All checkpoints "count toward done"** (after any needed attestation for + `Manual`/`Warning` results — ask the same style of yes/no confirmation + `checklist-updater.md`'s U.2 uses when a result needs acknowledgement): + set `phases.{id}.status = "done"`, `lastVerifiedAt` = now, record each + checkpoint's status in `checkpointResults`. For each acknowledged + `Manual`/`Warning` result, also record + `checkpointAcknowledgements.{checkpointId} = {status, acknowledgedAt}`. + Remove an old acknowledgement when that checkpoint now returns a different + status. Write the state file. Return to L.3 to advance to the next phase. +- **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: + + ``` + python scripts/checkpoint.py --revert-reason "{rollbackLabel}" --only "{rollbackPushGlob}" + python scripts/push.py --only "{rollbackPushGlob}" --dry-run + python scripts/push.py --only "{rollbackPushGlob}" --yes + ``` + + If all three commands succeed, set `actionApplied = false`, keep the phase + `in-progress`, record `rolledBackAt` = now, and write the state file. This + lets a later invocation re-run the gated action instead of skipping an + action that was undone. If any rollback command fails, leave + `actionApplied = true`, keep the phase `blocked`, and report that both the + phase and rollback need manual attention. + + **Message:** + + I can't complete this step yet — {plain-language summary of what's + blocking it, drawn from the remediation text already shown above}. Fix + that, then tell me to continue and I'll pick up right here. + + **End message.** + + Stop. Do not attempt later phases. + +- **Any status not in `completionStatuses`:** keep the phase + `in-progress`, record the result, show its remediation, and stop. Do not + describe the provider as connected. + +--- + +## L.5 — Completion + +Once every phase is `done`: + +**Message:** + +{displayName} is connected to this agent. Every required validation phase in +the provider plan passed. + +**End message.** + +Return control to the calling file (it may offer next steps, such as `/create` +for building topics — that offer belongs to the calling file, not here). diff --git a/solutions/ess-maker-skills/src/skills/connect/step1.md b/solutions/ess-maker-skills/src/skills/connect/step1.md index 00291147c..d896504d5 100644 --- a/solutions/ess-maker-skills/src/skills/connect/step1.md +++ b/solutions/ess-maker-skills/src/skills/connect/step1.md @@ -7,13 +7,25 @@ Do not rephrase, add commentary, or tell the user what tools you are calling. ## 1.1 — Check what's already connected +Read `.local/config.json` when it exists and resolve `ACTIVE_AGENT_SLUG` from +`activeAgent`, falling back to `agent.slug`. Resolve the matching active agent +record and retain its `schemaName` / architecture. Use this identity for every +agent-specific integration state and validation below. + Build a list of connected integrations (if any): - **ServiceNow** — connected if `.local/connect/servicenow/steps.md` exists and all items are checked. -- **Workday** — connected if `.local/connect/workday/config.json` exists and its - `setupStatus` shows every setup row (`S1.1` … `S6.2`) in state `done` (the - setup orchestrator owns this state). +- **Workday** — connected if either: + - `.local/connect/workday/config.json` exists and its `setupStatus` shows + every CEA setup row (`S1.1` … `S6.2`) in state `done` (the CEA setup + orchestrator owns this state), or + - `.local/connect/workday/agents/{ACTIVE_AGENT_SLUG}/lifecycle.json` exists + and every phase is `done` (this specific CEA agent was wired to an + extension already installed elsewhere — see 1.3 below), or + - the active agent is the ESS DA HR Agent and + `.local/connect/workday-da/config.json` has `status: "ready"` with every + DA setup row through `DA5.1` in state `done`. --- @@ -55,6 +67,33 @@ Wait for the user to respond. ### If the user chose ServiceNow (1 or "servicenow") +Resolve `activeAgent` against `.local/config.json` `agents`, falling back to +the legacy `agent` object only when needed. Do not consult the retired +`selected_products` field. + +If no concrete active agent with architecture identity can be resolved, show: + +**Message:** + +Select the Employee Self-Service agent you want to connect, then run +`/connect servicenow` again. + +**End message.** + +Stop immediately without creating or updating ServiceNow state. + +If the active agent is a Declarative Agent, show: + +**Message:** + +ServiceNow integration with an ESS Declarative Agent isn't supported in this +release. Please contact your administrator. + +**End message.** + +Stop immediately. Do not create ServiceNow state or enter the ServiceNow +lifecycle. Continue below only for a concrete active CEA agent. + Check if `.local/connect/servicenow/steps.md` exists. **If it exists and all items are checked:** @@ -206,8 +245,116 @@ Now read `src/skills/connect/servicenow/step1.md` and follow it. ### If the user chose Workday (2 or "workday") -Now read `src/skills/setup/SKILL.md` and follow its hybrid-extension -availability boundary. Do not run the retained Workday playbooks directly. +First find out which ESS agent is active. Read `.local/config.json`, resolve +`activeAgent` against `agents`, and fall back to the legacy `agent` object only +when needed. For a DA agent, also read the canonical +`.local/setup/config.json` `agents` record keyed by the active `botId`. Require +canonical workspace evidence and `steps.SETUP-07.state: "done"`; do not require +`connect_ready: true`, because Workday connection configuration may be the +remaining readiness blocker. + +- **No concrete active agent, missing schema/architecture identity, or a DA + agent whose local workspace is not materialized** — setup is incomplete. + + **Message:** + + I don't see a setup-complete Employee Self-Service agent selected in this + workspace yet. Run `/setup` or select the intended agent first, then come + back and run `/connect workday` again. + + **End message.** + + Stop here. + +Route from the active agent's `releaseLine` and schema name +(`schemaName`/`schema_name`): + +- active `gptagent_copilotforemployeeselfservicehr` (current MOS HR), + `gptagent_copilotforemployeeselfserviceit` (current MOS IT), or the legacy + `msdyn_copilotforemployeeselfservicedahr` / + `msdyn_copilotforemployeeselfservicedait` aliases → use the DA rules below; +- another agent with `releaseLine: "da"` → use the DA unsupported-target + rules below; +- active CEA agent → skip to **CEA agent** below. + +Never infer the target from a retired `selected_products` field or from the +first installed agent in a multi-agent workspace. + +### DA HR agent + +Enter this branch only when the active agent is DA. Use its schema name to +determine the vertical. + +**ESS DA IT is not supported in this release.** If the active agent is +`gptagent_copilotforemployeeselfserviceit` or +`msdyn_copilotforemployeeselfservicedait`, show: + +**Message:** + +Workday integration with the ESS IT Agent isn't supported in this release. +Please contact your administrator. + +**End message.** + +Stop immediately. Do not create DA Workday state, run a Workday package +checkpoint, install a package, or enter any DA Workday lifecycle step. + +**ESS DA Hub is not supported in this release.** If the active agent is the +ESS DA Hub, show: + +**Message:** + +Workday integration with the ESS Hub Agent isn't supported in this release. +Please select the ESS HR Agent or contact your administrator. + +**End message.** + +Stop immediately without creating or updating Workday state. + +**ESS DA HR is supported.** Continue only when the active agent is +`gptagent_copilotforemployeeselfservicehr` or the legacy +`msdyn_copilotforemployeeselfservicedahr` alias. Do not run `WD-PKG-001` or the +CEA lifecycle: DA packages share some Workday connection-reference names with +CEA, so that checkpoint is not an architecture discriminator. + +Read `src/skills/setup/workday-da/SKILL.md` and follow it. That skill runs +`WD-DA-PKG-001`, installs or verifies the DA HR Workday child package, and +resumes the DA HR checklist through Entra, tenant, Power Platform connection, +bot-to-flow authorization, topic selection, and signed-in runtime validation. + +### CEA agent + +Enter this branch when the resolved active agent is CEA. + +Check the current CEA Workday extension before honoring any existing lifecycle +state: + +``` +python scripts/flightcheck/cli.py --checkpoint WD-PKG-001 +``` + +Read the `WD-PKG-001` row from `workspace/flightcheck/results.json` and route +by both its status and detected flavor: + +- **`Passed` + simplified-install result** — the installed extension can use + the lightweight per-agent lifecycle. Whether or not + `.local/connect/workday/agents/{ACTIVE_AGENT_SLUG}/lifecycle.json` already + exists, read + `src/skills/connect/workday/SKILL.md` and follow it. +- **`Passed` + full / legacy result** — do not run the simplified V2 wiring + lifecycle. Full/legacy CEA Workday setup is not available from the current + hybrid setup boundary; explain that this existing installation needs the + legacy CEA setup experience and stop without changing state. +- **`NotConfigured`** — no Workday package is installed. Fresh CEA Workday + installation is not available from the current hybrid setup boundary; + explain that this release supports the DA HR Workday path and stop without + changing state. +- **`Failed`** — a partial or broken install was detected. Show the checkpoint + remediation and stop; do not treat it as a fresh environment. +- **`Warning` / `Skipped` / `Error`**, or a `Passed` result whose flavor cannot + be determined — package detection is inconclusive. Show the result and stop + so the maker can remediate or retry. Never start setup from an inconclusive + package check. ### If the user said something else diff --git a/solutions/ess-maker-skills/src/skills/connect/steps.md b/solutions/ess-maker-skills/src/skills/connect/steps.md index 1605edf75..b8f1c6184 100644 --- a/solutions/ess-maker-skills/src/skills/connect/steps.md +++ b/solutions/ess-maker-skills/src/skills/connect/steps.md @@ -4,6 +4,9 @@ This folder does not use a top-level steps.md. Each integration has its own steps.md and config.json under its subfolder: - `servicenow/steps.md` + `servicenow/config.json` -- `workday/steps.md` + `workday/config.json` (future) +- Workday routes by architecture: CEA uses `connect/workday/` when an + extension already exists or `src/skills/setup/SKILL.md` for full setup; + DA HR uses `src/skills/setup/workday-da/SKILL.md`. DA IT is not supported + for Workday in this release and does not enter a Workday lifecycle. See SKILL.md for routing logic. diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/workday/SKILL.md new file mode 100644 index 000000000..42801740e --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/connect/workday/SKILL.md @@ -0,0 +1,33 @@ +# Connect Workday (already installed) + +Entry point for connecting this agent to a Workday extension that is +**already installed** somewhere in this environment. If no Workday extension +exists yet, the caller (`src/skills/connect/step1.md`) routes to the setup +orchestrator (`src/skills/setup/SKILL.md`) instead — this file is never +reached in that case. + +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. + +--- + +## W.1 — Run the lifecycle + +Read `src/skills/connect/shared/lifecycle-runner.md` and follow it with +`PROVIDER = "workday"` and `AGENT_SLUG` resolved from `.local/config.json` +(`activeAgent`, falling back to `agent.slug`). + +That file loads `src/skills/connect/workday/contract.json`, resumes any +in-progress state from +`.local/connect/workday/agents/{AGENT_SLUG}/lifecycle.json`, shows the +plan and collects attestation on a first run, live-re-verifies anything +already recorded done, then runs whichever phase is next — confirming the +extension and its connections, wiring the agent's Workday topics, and +validating the end-to-end connection. + +--- + +## W.2 — After completion + +Once the lifecycle runner reports Workday connected, return control to the +caller. Do not repeat the runner's completion message or add your own. 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 new file mode 100644 index 000000000..069fafeb2 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md @@ -0,0 +1,109 @@ +# Action: Wire the User Context redirect to Workday + +Called by the lifecycle runner (`src/skills/connect/shared/lifecycle-runner.md`) +for the `agent-wiring` phase of the Workday contract. By the time this file +runs, the role gate has already passed and a rollback checkpoint has already +been saved — this file only performs the edit and pushes it. + +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. + +## 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." + +**End message.** + +--- + +## A.2 — Resolve the installed system topic + +Find the installed Workday "Set User Context" system topic's dialog id under +`workspace/agents/{AGENT_SLUG}/topics/`. Use the actual installed topic name — do not +assume a fixed name, since it varies by install path (for example, +`WorkdaySystemGetUserContextV2` on the current extension pack). + +--- + +## 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: + +```yaml +kind: AdaptiveDialog +beginDialog: + kind: OnRedirect + id: main + priority: 0 + actions: + - kind: BeginDialog + id: bfT9Kx + displayName: Redirect to Workday System Get User Context + dialog: {USER_CONTEXT_DIALOG} +``` + +--- + +## A.4 — Scan, dry run, push + +Check for errors using the diagnostics tool on the edited file. Fix any +before continuing. + +Preview and push: + +``` +python scripts/push.py --only "topics/user-context-setup.mcs.yml" --dry-run +``` + +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. + +Use the `vscode_askQuestions` tool: + +```json +[ + { + "header": "Publish Workday wiring", + "question": "Publish this scoped User Context topic change to the active agent?", + "options": [ + { "label": "Publish", "recommended": true }, + { "label": "Not now" } + ], + "allowFreeformInput": false + } +] +``` + +If the user selects **Not now**, stop 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 +``` + +The explicit question above is the approval for this concrete scoped change; +`--yes` prevents the script from attempting a second terminal-only prompt. + +--- + +## A.5 — Return + +Return to the lifecycle runner. It re-runs `WD-REST-002` for `AGENT_SLUG` +immediately after this file completes 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 new file mode 100644 index 000000000..a5b92924a --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/connect/workday/contract.json @@ -0,0 +1,43 @@ +{ + "provider": "workday", + "displayName": "Workday", + "connectConfig": ".local/connect/workday/config.json", + "detect": { + "checkpoint": "WD-PKG-001", + "meansInstalled": "Passed", + "meansNotInstalled": "NotConfigured" + }, + "phases": [ + { + "id": "discovery", + "label": "Confirm the Workday extension is installed and its connections are healthy", + "checkpoints": ["WD-PKG-001", "DV-CONN-001", "WD-CONN-012"], + "completionStatuses": ["Passed"], + "mutates": false + }, + { + "id": "agent-wiring", + "label": "Wire this agent's Workday topics so they can run", + "checkpoints": ["WD-REST-002"], + "completionStatuses": ["Passed"], + "mutates": true, + "requiredRole": "Environment Maker", + "gateMode": "programmatic", + "roleQuery": [ + "az rest --method GET --resource \"{ENV_URL}\" --url \"{ENV_URL}/api/data/v9.2/WhoAmI\" --query \"UserId\" -o tsv", + "az rest --method GET --resource \"{ENV_URL}\" --url \"{ENV_URL}/api/data/v9.2/systemusers%28{USER_ID}%29/systemuserroles_association?%24select=name\" --query \"value[].name\" -o json" + ], + "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" + }, + { + "id": "validation", + "label": "Confirm the end-to-end connection is healthy", + "checkpoints": ["WD-RUN-001"], + "completionStatuses": ["Passed"], + "mutates": false + } + ] +} 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 new file mode 100644 index 000000000..0294dabc8 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md @@ -0,0 +1,253 @@ + +# 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 Declarative +Agent HR** flavor, 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 DA 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 DA 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 DA 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 ESS DA HR Workday package | 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. Workday single sign-on (Entra)** + - {m} Create the Workday single sign-on app + - {m} Expose the Workday API permission + - {m} Grant admin consent + - {m} Assign users to the Workday app + - {m} Map the sign-in identifier + - {m} Set the SAML signing option + - {m} Confirm a single sign-in tenant + + **3. Workday tenant configuration** + - {m} Register the Workday API client + - {m} Capture your Workday connection details + - {m} Activate the Workday authentication policy + - {m} Match the signing certificate + + **4. Power Platform and agent integration** + - {m} Connect the Workday account + - {m} Connect Microsoft Dataverse + - {m} Share the Workday connection parameters + - {m} Bind the extension connections + - {m} Turn on the Workday cloud flows + - {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 scopes and activates the authentication 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 DA 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.** 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 new file mode 100644 index 000000000..f22d39fed --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md @@ -0,0 +1,279 @@ + +# 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 DA 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 version, 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 may verify/reuse them and add 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 linked team exists → stop and require administrator + remediation. Do not rely on the script's exit code because this source + version can print `[FAIL]` for multiple teams without returning failure. + +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 preflight found 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. 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 new file mode 100644 index 000000000..c3450e7f1 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md @@ -0,0 +1,406 @@ + +# DA-3 — Configure the Workday Tenant + +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**. + +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. + +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-TENANT-001` — Tenant Setup – Security + connection fields captured | attest | +| DA3.3 | `WD-TENANT-001` — authentication policy scoped to the OAuth client + activated | 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 +``` + +**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)** → **authentication policy +(DA3.3)**. The API client is registered **before** the authentication policy +because the policy must be scoped to the OAuth client identity, which only +exists once the client is registered. 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: + +```json +[ + { + "header": "Workday administrator", + "question": "Have you looped in a Workday admin to perform the Workday side of configuration?", + "options": [ + { "label": "Yes, I have", "recommended": true }, + { "label": "No, I have not" } + ], + "allowFreeformInput": false + } +] +``` + +**If the user chose "Yes, I have":** +- Set `GATE_RESULT = "pass"` and + `GATE_EVIDENCE = { "verifiedBy": "attested", "note": "user confirmed a Workday administrator is available to perform DA3.1–DA3.4 with them" }`. +- 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?", + "options": [ + { "label": "Yes, they match", "recommended": true }, + { "label": "No / 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`. +- **"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. **Register the client before +touching the authentication policy (DA3.3)** — the policy is scoped to this +client's identity. + +**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.** + +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. +- 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 the captured 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 — Scope & activate the authentication policy + +Scope the authentication policy to the OAuth client from DA3.1/DA3.2 and activate +it. + +**Message:** + +In Workday, run **Manage Authentication Policies**. Add or edit the policy so it +is scoped to the **OAuth client you registered in the previous step**, and allow +**SAML** as an allowed authentication type. Then run **Activate All Pending +Authentication Policy Changes** to make it live. Type **done** when the changes +are activated. + +**End message.** + +Wait for the user, 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):** + +``` +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 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 the policy is scoped and activated. + +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. + +--- + +## Done + +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 the authentication policy are all set. Next I'll review your +Workday connection and let you know what's left. + +**End message.** 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 new file mode 100644 index 000000000..a31fc91ac --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md @@ -0,0 +1,137 @@ + +# DA-1 — Install the Workday Extension Package + +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 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. + +--- + +## 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` + +Call this value `PACKAGE_FLAVOR`. Stop if the active agent does not match one +of these supported HR schemas. + +Run the checkpoint that reports both facts at once — whether a DA base agent +exists, and whether Workday is already installed against it: + +``` +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. + + **Message:** + + I don't see a DA Employee Self-Service agent installed in this environment + yet. Run `/setup` first to install it, then come back and run + `/connect workday` again. + + **End message.** + + Halt this skill entirely — do not proceed to DA-2 or DA-3. + +- **`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. + +--- + +## P1.1 — Attempt an automated install + +``` +python scripts/install_workday_da_extension.py --url "{WORKDAY_DATAVERSE_URL}" --vertical "hr" --package-flavor "{PACKAGE_FLAVOR}" --ring "{RING}" +``` + +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, ask whether the maker wants the kit to install the + current-user managed copy. 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 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. 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 new file mode 100644 index 000000000..2009dc088 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md @@ -0,0 +1,688 @@ + +# DA-2 — Provision the Workday Entra App + +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**. + +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. + +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 = ...`). + +**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: + + ``` + az login --tenant "{SETUP_TENANT_ID}" --use-device-code --allow-no-subscriptions + ``` + +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 / app owner) +- `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?%24select=displayName" --query "value[].displayName" -o json + ``` + + The role is held if the returned role names include **`Application + Administrator`**, **`Cloud Application Administrator`**, **`Privileged Role + Administrator`**, or **`Global Administrator`**. Treat an + `Insufficient privileges` / `Authorization_RequestDenied` / forbidden response + as "role not held". If the query errors for an unrelated reason (network, not + signed in), follow the gate's retry-then-attest fallback — never assume pass. + +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: + +```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 + } +] +``` + +**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 Workday SAML app(s) already exist** — consider only the returned apps in + SAML mode (`sso == "saml"`): + + - **Exactly one** → 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` query above returns 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. + + - **More than one** → do **not** guess which is correct. 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. 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. + +**Message:** + +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. + +**End message.** + +**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" +``` + +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**. Then confirm your Workday tenant's SAML IdP is +configured with the **Issuer**, **SSO / Login URL**, and **SP audience** shown in +the check result above, and that it trusts the signing certificate you activated +earlier. Type **done** when it's set. + +**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. On `ACK=true` the row becomes `done`; a `MANUAL` +result alone never completes it. + +--- + +## DA2.7 — Confirm single-Entra-tenant federation alignment + +Confirm exactly one Entra tenant federates to the Workday tenant ESS uses (a +misaligned or duplicate federation breaks user-context SAML SSO). + +**Message:** + +Now I'll review the Workday SAML federation to confirm exactly one Entra tenant is +linked to the Workday tenant your agent uses. + +**End message.** + +**Verify (WD-CONN-010):** + +``` +python scripts/flightcheck/cli.py --checkpoint WD-CONN-010 --connect-config ".local/connect/workday-da/config.json" +``` + +`WD-CONN-010` summarizes the federated Workday SAML app(s) and their entity IDs. +Present the result, then — this is an **attest** row — ask the user to confirm the +alignment 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. 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. 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 new file mode 100644 index 000000000..8206c8e7a --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/checklist-updater.md @@ -0,0 +1,270 @@ +# 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` \| `WARNING` \| `MANUAL` \| `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. + +**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.** + +Decide `Status` as follows: + +| `GATE` | Condition | Resulting `Status` | +|--------|-----------|--------------------| +| `prog` | `CHECKPOINT_RESULT` = `PASSED` | `done` | +| `prog` | `CHECKPOINT_RESULT` = `FAILED` | `blocked` | +| `prog` | `CHECKPOINT_RESULT` = `WARNING` / `SKIPPED` / `null` | `in-progress` | +| `manual` / `attest` | `ACK` = `true` (user acknowledged **and** evidence captured) | `done` | +| `manual` / `attest` | `ACK` = `false`, regardless of `CHECKPOINT_RESULT` | `in-progress` (or `blocked` if `FAILED`) | +| `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": "" + } + } + } + ``` + 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 new file mode 100644 index 000000000..48ddae732 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md @@ -0,0 +1,151 @@ +# Workday DA Setup — Config Persistence Schema + +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. + +**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" }, + "DA2.1": { "state": "pending", "checkpoint": "WD-CONN-102", "gate": "attest", "verifiedBy": null } + } +} +``` + +- `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. + +--- + +## Power Platform integration state + +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) + +```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", + "status": "in-progress", + "setupStatus": { + "DA1.1": { "state": "done", "checkpoint": "WD-DA-PKG-001", "gate": "prog", "verifiedBy": "programmatic" } + } +} +``` + +--- + +## 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`. 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 new file mode 100644 index 000000000..89b8abbc4 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/connection-fields.md @@ -0,0 +1,143 @@ +# 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 new file mode 100644 index 000000000..a46c670d6 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/permission-gate.md @@ -0,0 +1,146 @@ +# 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 + persists it under `setupStatus["{STEP_ID}"].verifiedBy` in + `.local/connect/workday-da/config.json` (see `config-schema.md`): + - `verifiedBy` ∈ `"programmatic"` \| `"attested"`. + - `note` — short free text (e.g. the role-query result, or the user's + attestation timestamp/identity). + +--- + +## 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?%24select=displayName" --query "value[].displayName" -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 if the result contains a directory role that grants `REQUIRED_ROLE` + (e.g. `Application Administrator`, `Cloud Application Administrator`, + `Global Administrator`). +- **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 = { "verifiedBy": "programmatic", "note": "" }`. +- 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, **do not** assume pass — fall back to the +attestation gate in G.2 (so a check error never silently grants access), +recording `note` = the query error. + +--- + +## G.2 — Attestation gate + +Use when `GATE_MODE` is `"attested"` (Workday Administrator, InfoSec/IT), or as +the fallback when a programmatic query errored. + +**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 = { "verifiedBy": "attested", "note": "user attested {REQUIRED_ROLE} for {STEP_ID}" }`. +- 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"`. +- 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 new file mode 100644 index 000000000..770751354 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/tasks.md @@ -0,0 +1,114 @@ + +# Workday Connect (DA) — Checklist (template) + +The single, trackable checklist spanning the five Workday connect steps for the +**Declarative Agent (DA)** flavor of Employee Self-Service. 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 DA Employee Self-Service base 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 DA 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. + +- [ ] **Activate the Workday authentication policy** — Scope Workday's authentication policy to the new OAuth client, allow SAML sign-in, and activate it. + +- [ ] **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 DA HR Agent. + +- [ ] **Configure employee context and topics** — Use the DA 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 new file mode 100644 index 000000000..0fab24751 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md @@ -0,0 +1,93 @@ + +# 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.** + +``` +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 DA 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? + +**End message.** + +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. + +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. + +--- + +## Done + +Return control to the orchestrator (`SKILL.md`) — every configuration row +should now be `done`. diff --git a/tests/flightcheck/checks/test_entra_app.py b/tests/flightcheck/checks/test_entra_app.py index 7b9611b51..e73d4342e 100644 --- a/tests/flightcheck/checks/test_entra_app.py +++ b/tests/flightcheck/checks/test_entra_app.py @@ -390,6 +390,21 @@ def test_partial_runner_config_completed_from_connect( # entraAppId from runner.config; entraAppObjectId filled from connect. assert _workday_hints({"entraAppId": "app-r"}) == ("app-r", "obj-x") + def test_explicit_overlay_does_not_fall_back_to_cea_config( + self, tmp_path, monkeypatch + ) -> None: + from flightcheck.checks.entra_app import _workday_hints + + self._write_connect_config( + tmp_path, {"entraAppId": "cea-app", "entraAppObjectId": "cea-obj"} + ) + monkeypatch.chdir(tmp_path) + + assert _workday_hints({ + "_connectConfigPath": ".local/connect/workday-da/config.json", + "entraAppId": "da-app", + }) == ("da-app", "") + def test_missing_connect_config_returns_empty( self, tmp_path, monkeypatch ) -> None: diff --git a/tests/flightcheck/checks/test_workday_da.py b/tests/flightcheck/checks/test_workday_da.py new file mode 100644 index 000000000..5b2c87b65 --- /dev/null +++ b/tests/flightcheck/checks/test_workday_da.py @@ -0,0 +1,289 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""End-to-end tests for WD-DA-PKG-001 (DA Workday extension package +installed) in +``solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py``. + +Mocks the single Dataverse Web API endpoint the check calls (the +``solutions`` table query) with the ``responses`` library, then invokes the +real production helper ``_check_workday_da_package_installed`` and asserts +on the resulting ``CheckResult``. Mirrors the pattern in +``tests/flightcheck/checks/test_solution.py``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import pytest +import responses + +from tests.conftest import FAKE_DATAVERSE_URL, require_validated_mock +from tests.mocks import dataverse as dv + +require_validated_mock(dv) + + +# Production module — flightcheck is importable because pyproject.toml puts +# solutions/ess-maker-skills/scripts on pythonpath. +from flightcheck.checks.workday_da import ( # noqa: E402 + _check_workday_da_package_installed, +) + + +BASE_URL = FAKE_DATAVERSE_URL + +# Verbatim from the production check; if these drift, mock-builder URLs will +# stop matching and tests fail loudly with an unregistered-URL error. +SOLN_SELECT = "solutionid,uniquename,friendlyname,ismanaged,version" +SOLN_FILTER = ( + "uniquename eq 'msdyn_copilotforemployeeselfservicedahr' or " + "uniquename eq 'msdyn_copilotforemployeeselfservicedait' or " + "uniquename eq 'msdyn_EssDAHRWorkday' or " + "uniquename eq 'msdyn_EssWorkdayRuntime'" +) + +SOLUTION_ID = "22222222-2222-2222-2222-222222222222" + + +# ─────────────────────────────────────────────────────────────────────── +# Minimal runner — mirrors the pattern in test_solution.py. +# ─────────────────────────────────────────────────────────────────────── + + +@dataclass +class _MinimalRunner: + env_url: str | None + dv_token: str | None + config: dict[str, Any] = field(default_factory=dict) + + +@pytest.fixture +def runner(fake_dataverse_url: str, fake_token: str) -> _MinimalRunner: + return _MinimalRunner(env_url=fake_dataverse_url, dv_token=fake_token) + + +# ─────────────────────────────────────────────────────────────────────── +# Mock payload builders + registration helpers +# ─────────────────────────────────────────────────────────────────────── + + +def _solution_record( + uniquename: str, + *, + version: str = "1.0.0.0", + ismanaged: bool = True, +) -> dict[str, Any]: + return { + "@odata.etag": 'W/"1"', + "solutionid": SOLUTION_ID, + "uniquename": uniquename, + "friendlyname": uniquename, + "ismanaged": ismanaged, + "version": version, + } + + +def _register_solutions(solutions: list[dict[str, Any]]) -> None: + responses.add(**dv.query( + base_url=BASE_URL, + entity_set="solutions", + records=solutions, + select=SOLN_SELECT, + filter_expr=SOLN_FILTER, + )) + + +# ─────────────────────────────────────────────────────────────────────── +# Tests — one per verdict path. +# ─────────────────────────────────────────────────────────────────────── + + +def test_skipped_when_env_url_missing() -> None: + results = _check_workday_da_package_installed( + _MinimalRunner(env_url=None, dv_token="tok") + ) + assert len(results) == 1 + r = results[0] + assert r.checkpoint_id == "WD-DA-PKG-001" + assert r.category == "Workday DA" + assert r.status == "Skipped" + assert "Dataverse URL or access token not available" in r.result + + +def test_skipped_when_token_missing() -> None: + results = _check_workday_da_package_installed( + _MinimalRunner(env_url=BASE_URL, dv_token=None) + ) + assert results[0].status == "Skipped" + + +@responses.activate +def test_failed_when_no_da_base_agent(runner: _MinimalRunner) -> None: + _register_solutions(solutions=[]) + + results = _check_workday_da_package_installed(runner) + assert len(results) == 1 + r = results[0] + assert r.checkpoint_id == "WD-DA-PKG-001" + assert r.status == "Failed" + assert "No ESS DA HR agent" in r.result + assert "/setup" in r.remediation + + +@responses.activate +def test_failed_when_hr_base_agent_present_but_workday_child_missing( + runner: _MinimalRunner, +) -> None: + _register_solutions(solutions=[ + _solution_record("msdyn_copilotforemployeeselfservicedahr"), + ]) + + r = _check_workday_da_package_installed(runner)[0] + assert r.status == "Failed" + assert "HR" in r.result + assert "/connect workday" in r.remediation + + +@responses.activate +def test_native_hr_agent_only_requires_child_in_sidecar( + runner: _MinimalRunner, +) -> None: + runner.config = { + "releaseLine": "da", + "activeAgent": "ess-hr", + "agents": [ + { + "slug": "ess-hr", + "schemaName": "gptagent_copilotforemployeeselfservicehr", + } + ], + } + _register_solutions( + solutions=[ + _solution_record("msdyn_EssWorkdayRuntime", version="2.1.0.0"), + ] + ) + + result = _check_workday_da_package_installed(runner)[0] + assert result.status == "Passed" + assert "msdyn_EssWorkdayRuntime" in result.result + + +@responses.activate +def test_native_it_active_agent_is_rejected( + runner: _MinimalRunner, +) -> None: + runner.config = { + "releaseLine": "da", + "activeAgent": "ess-it", + "agents": [ + { + "slug": "ess-it", + "schemaName": "gptagent_copilotforemployeeselfserviceit", + } + ], + } + _register_solutions( + solutions=[ + _solution_record("msdyn_copilotforemployeeselfservicedahr"), + _solution_record("msdyn_EssWorkdayRuntime"), + ] + ) + + result = _check_workday_da_package_installed(runner)[0] + assert result.status == "Failed" + assert "active agent is the ESS DA IT agent" in result.result + + +@responses.activate +def test_failed_when_only_it_base_agent_is_present( + runner: _MinimalRunner, +) -> None: + _register_solutions(solutions=[ + _solution_record("msdyn_copilotforemployeeselfservicedait"), + ]) + + r = _check_workday_da_package_installed(runner)[0] + assert r.status == "Failed" + assert "ESS DA IT agent is installed" in r.result + assert "not supported" in r.remediation + + +@responses.activate +def test_passed_when_hr_workday_child_present(runner: _MinimalRunner) -> None: + _register_solutions(solutions=[ + _solution_record("msdyn_copilotforemployeeselfservicedahr"), + _solution_record("msdyn_EssDAHRWorkday", version="2.0.0.1"), + ]) + + r = _check_workday_da_package_installed(runner)[0] + assert r.status == "Passed" + assert "msdyn_EssDAHRWorkday" in r.result + assert "2.0.0.1" in r.result + # Principle: PASSED carries no remediation. + assert r.remediation == "" + + +@responses.activate +def test_it_agent_does_not_block_supported_hr_package( + runner: _MinimalRunner, +) -> None: + """An IT agent in the environment is outside the active HR lifecycle.""" + _register_solutions(solutions=[ + _solution_record("msdyn_copilotforemployeeselfservicedahr"), + _solution_record("msdyn_copilotforemployeeselfservicedait"), + _solution_record("msdyn_EssDAHRWorkday"), + ]) + + r = _check_workday_da_package_installed(runner)[0] + assert r.status == "Passed" + assert "ESS HR agent detected" in r.result + assert "msdyn_EssDAHRWorkday" in r.result + + +@responses.activate +def test_warning_when_dataverse_returns_500(runner: _MinimalRunner) -> None: + """A transient platform error must surface as WARNING, not silently PASS.""" + responses.add( + "GET", + dv.build_query_url( + BASE_URL, + "solutions", + select=SOLN_SELECT, + filter_expr=SOLN_FILTER, + ), + json={"error": {"code": "0x80040220", "message": "boom"}}, + status=500, + ) + + r = _check_workday_da_package_installed(runner)[0] + assert r.status == "Warning" + assert "Unable to verify the DA Workday package" in r.result + + +@responses.activate +def test_warning_when_dataverse_returns_401(runner: _MinimalRunner) -> None: + """A 401 must surface as WARNING with an auth-expired hint. + + Exercises the AuthExpiredError catch block in + _check_workday_da_package_installed. + """ + responses.add( + "GET", + dv.build_query_url( + BASE_URL, + "solutions", + select=SOLN_SELECT, + filter_expr=SOLN_FILTER, + ), + json={"error": {"code": "0x80048306", "message": "token expired"}}, + status=401, + ) + + r = _check_workday_da_package_installed(runner)[0] + assert r.status == "Warning" + assert "401" in r.result + assert "Re-run FlightCheck" in r.remediation diff --git a/tests/flightcheck/checks/test_workday_extension.py b/tests/flightcheck/checks/test_workday_extension.py index 0e641716b..096c257a7 100644 --- a/tests/flightcheck/checks/test_workday_extension.py +++ b/tests/flightcheck/checks/test_workday_extension.py @@ -69,6 +69,7 @@ class _Runner: dv_token: str | None = None pp_admin: Any = None env_id: str | None = None + agent_slug: str = "" _workday_connection_refs: list[dict[str, Any]] = field(default_factory=list) @@ -519,6 +520,42 @@ def test_one_of_two_agents_unwired_fails(self, tmp_path, monkeypatch): assert "broken" in r.result assert "wired" not in r.result.split("missing for:")[1] + def test_active_agent_scope_ignores_unrelated_unwired_agent( + self, tmp_path, monkeypatch + ): + monkeypatch.chdir(tmp_path) + _write_topic( + tmp_path, + "active", + " - kind: BeginDialog\n dialog: WorkdaySystemGetUserContextV2\n", + ) + _write_topic(tmp_path, "unrelated", "kind: AdaptiveDialog\n") + + runner = _Runner(config={}, agent_slug="active") + r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-002"] + + assert r.status == Status.PASSED.value + assert "active" in r.result + assert "unrelated" not in r.result + + def test_active_agent_scope_does_not_pass_from_other_agent( + self, tmp_path, monkeypatch + ): + monkeypatch.chdir(tmp_path) + _write_topic( + tmp_path, + "other", + " - kind: BeginDialog\n dialog: WorkdaySystemGetUserContextV2\n", + ) + _write_topic(tmp_path, "active", "kind: AdaptiveDialog\n") + + runner = _Runner(config={}, agent_slug="active") + r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-002"] + + assert r.status == Status.FAILED.value + assert "active" in r.result + assert "other" not in r.result + # ───────────────────────────────────────────────────────────────────── # WD-NET-001 — firewall allowlisting (S5.8, always MANUAL attestation). diff --git a/tests/flightcheck/checks/test_workday_package.py b/tests/flightcheck/checks/test_workday_package.py index 35eb175a2..4a8266bba 100644 --- a/tests/flightcheck/checks/test_workday_package.py +++ b/tests/flightcheck/checks/test_workday_package.py @@ -24,6 +24,7 @@ import pytest import responses +from unittest.mock import patch from tests.conftest import require_validated_mock from tests.mocks import dataverse as dv @@ -356,6 +357,32 @@ def test_runtime_all_bound_passes(self, runner: _MinimalRunner) -> None: assert r.status == "Passed" assert "Workday Runtime (OBO)" in r.result + def test_runtime_detection_ignores_agent_scoped_refs( + self, runner: _MinimalRunner + ) -> None: + from flightcheck.checks.workday import _check_package_flavor + + refs = dv.workday_connection_refs_runtime() + refs.append({ + "connectionreferencelogicalname": ( + "gptagent_copilotforemployeeselfservicehr." + "3164dae9-3a2b-5843-98dd-e62bb5123324.shared_workdaysoap" + ), + "connectionreferencedisplayname": "Agent Workday Runtime", + "connectorid": "/providers/Microsoft.PowerApps/apis/shared_workdaysoap", + "connectionid": "agent-connection", + "statuscode": 1, + }) + runner._workday_connection_refs = refs + + with patch("auth.query_all", return_value=refs): + results = _check_package_flavor(runner, wd_flows=[{"name": "runtime"}]) + + r = _result_by_id(results, "WD-PKG-001") + assert r.status == "Passed" + assert runner._workday_package_flavor == "simplified" + assert len(runner._workday_connection_refs) == 1 + def test_runtime_unbound_fails(self, runner: _MinimalRunner) -> None: from flightcheck.checks.workday import _check_package_connection_completeness diff --git a/tests/flightcheck/checks/test_workday_saml_certificate.py b/tests/flightcheck/checks/test_workday_saml_certificate.py index 45fbd9a18..3f64c56dc 100644 --- a/tests/flightcheck/checks/test_workday_saml_certificate.py +++ b/tests/flightcheck/checks/test_workday_saml_certificate.py @@ -624,6 +624,38 @@ def test_malformed_custom_key_identifier_does_not_crash( assert "(malformed)" in r.result +class TestPreferredThumbprintDisplay: + """The preferred SHA-1 thumbprint is authoritative for a single cert.""" + + @responses.activate + def test_single_cert_uses_preferred_sha1_when_custom_identifier_is_sha256( + self, runner: _MinimalRunner + ) -> None: + from flightcheck.checks.workday import _check_saml_certificate_health + + sha256_identifier = base64.b64encode(b"\xAB" * 32).decode("ascii") + preferred = "905236D91F87B87FDD6AD3A832909751D7C403EA" + kc = g.key_credential( + key_id="cert-sha256-identifier", + custom_key_identifier=sha256_identifier, + end_date_time="2099-01-01T00:00:00Z", + ) + sp = g.service_principal( + sp_id="sp-workday-preferred", + display_name="Workday Preferred", + key_credentials=[kc], + preferred_token_signing_key_thumbprint=preferred, + ) + responses.add(**g.list_service_principals(service_principals=[sp])) + + results = _check_saml_certificate_health(runner) + r = _result_by_id(results, "WD-CONN-102") + + assert r.status == "Manual" + assert "90:52:36:D9:1F:87:B8:7F:DD:6A:D3:A8:32:90:97:51:D7:C4:03:EA" in r.result + assert "(malformed)" not in r.result + + # ─────────────────────────────────────────────────────────────────────── diff --git a/tests/flightcheck/test_check_roles.py b/tests/flightcheck/test_check_roles.py index 7688efb02..d812e5f89 100644 --- a/tests/flightcheck/test_check_roles.py +++ b/tests/flightcheck/test_check_roles.py @@ -150,6 +150,22 @@ def test_terminal_summary_shows_roles_on_action_rows(capsys): assert "X-004" in out +def test_terminal_summary_does_not_count_skipped_as_passed(capsys): + from flightcheck.runner import RunResult + from flightcheck.cli import _print_prioritized_summary + + rr = RunResult(scope="full", started="2026-01-01T00-00-00", overall="READY") + rr.results = [_result("X-SKIP", "Skipped", [])] + rr.total = 1 + rr.skipped = 1 + + _print_prioritized_summary(rr) + + out = capsys.readouterr().out + assert "SKIPPED (1)" in out + assert "PASSED (0)" in out + + def test_publishing_checks_all_carry_roles(): """Every result from a real check module must declare at least one role.""" from flightcheck.checks.publishing import run_publishing_checks diff --git a/tests/flightcheck/test_cli.py b/tests/flightcheck/test_cli.py index 3508aa24d..1d400fdef 100644 --- a/tests/flightcheck/test_cli.py +++ b/tests/flightcheck/test_cli.py @@ -28,6 +28,14 @@ from flightcheck import cli +def test_workday_da_check_is_explicit_scope_only() -> None: + """An optional DA HR package must not fail unrelated full runs.""" + assert cli.SCOPE_MAP["workdayda"] == [ + ("Workday DA", cli.run_workday_da_checks) + ] + assert ("Workday DA", cli.run_workday_da_checks) not in cli.FULL_SCOPE + + class TestOpenReportInBrowser: """Tests for cli.open_report_in_browser.""" diff --git a/tests/flightcheck/test_cli_single_checkpoint.py b/tests/flightcheck/test_cli_single_checkpoint.py index 95f032393..bd28f1857 100644 --- a/tests/flightcheck/test_cli_single_checkpoint.py +++ b/tests/flightcheck/test_cli_single_checkpoint.py @@ -39,6 +39,8 @@ def _args( tmp_path: Path, environment_url: str | None = None, environment_id: str | None = None, + connect_config: str | None = None, + agent_slug: str | None = None, no_telemetry: bool = True, invocation_source: str | None = None, quiet_auth: bool = False, @@ -47,6 +49,8 @@ def _args( checkpoint=checkpoint, environment_url=environment_url, environment_id=environment_id, + connect_config=connect_config, + agent_slug=agent_slug, output=str(tmp_path / "out"), no_telemetry=no_telemetry, invocation_source=invocation_source, @@ -75,6 +79,100 @@ def _silence_output(monkeypatch: pytest.MonkeyPatch) -> None: class TestGates: + def test_connect_config_overlay_preserves_foundation_identity( + self, tmp_path: Path + ) -> None: + overlay = tmp_path / "workday-da.json" + overlay.write_text( + '{"entraAppId":"app-123","tenant":"acme","status":"in-progress"}', + encoding="utf-8", + ) + + merged = cli._merge_connect_config( + { + "dataverseEndpoint": "https://example.crm.dynamics.com", + "agent": {"slug": "ess-hr"}, + "status": "complete", + }, + str(overlay), + ) + + assert merged["dataverseEndpoint"] == "https://example.crm.dynamics.com" + assert merged["agent"] == {"slug": "ess-hr"} + assert merged["entraAppId"] == "app-123" + assert merged["tenant"] == "acme" + assert merged["status"] == "complete" + assert merged["_connectConfigPath"] == str(overlay) + + def test_connect_config_overlay_preserves_foundation_connections( + self, tmp_path: Path + ) -> None: + overlay = tmp_path / "workday-da.json" + overlay.write_text( + '{"connections":{"Workday":{"tenant":"wrong"}}}', + encoding="utf-8", + ) + + merged = cli._merge_connect_config( + {"connections": {"Workday": {"tenant": "foundation"}}}, + str(overlay), + ) + + assert merged["connections"]["Workday"]["tenant"] == "foundation" + + def test_connect_config_supplies_native_sidecar_dataverse( + self, tmp_path: Path + ) -> None: + overlay = tmp_path / "workday-da.json" + overlay.write_text( + '{"sidecarDataverseEndpoint":' + '"https://sidecar.crm.dynamics.com"}', + encoding="utf-8", + ) + + merged = cli._merge_connect_config( + {"powerPlatformApiEndpoint": "https://api.powerplatform.com"}, + str(overlay), + ) + + assert ( + merged["dataverseEndpoint"] + == "https://sidecar.crm.dynamics.com" + ) + assert ( + merged["powerPlatformApiEndpoint"] + == "https://api.powerplatform.com" + ) + + def test_foundation_dataverse_wins_over_sidecar( + self, tmp_path: Path + ) -> None: + overlay = tmp_path / "workday-da.json" + overlay.write_text( + '{"sidecarDataverseEndpoint":' + '"https://sidecar.crm.dynamics.com"}', + encoding="utf-8", + ) + + merged = cli._merge_connect_config( + {"dataverseEndpoint": "https://foundation.crm.dynamics.com"}, + str(overlay), + ) + + assert ( + merged["dataverseEndpoint"] + == "https://foundation.crm.dynamics.com" + ) + + def test_connect_config_overlay_rejects_non_object( + self, tmp_path: Path + ) -> None: + overlay = tmp_path / "invalid.json" + overlay.write_text('["not", "an", "object"]', encoding="utf-8") + + with pytest.raises(ValueError, match="must contain a JSON object"): + cli._merge_connect_config({}, str(overlay)) + def test_environment_checkpoints_accept_explicit_foundation_context( self, ) -> None: @@ -254,6 +352,55 @@ def test_passed_row_exits_0( cli._run_single_checkpoint(_args("FAKE-001", tmp_path)) assert exc.value.code == 0 + def test_assigns_explicit_agent_slug_and_connect_config( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + _silence_output: None, + ) -> None: + overlay = tmp_path / "provider.json" + overlay.write_text('{"tenant":"acme"}', encoding="utf-8") + captured = {} + + class _Spec: + category_label = "Fake" + is_family = False + + class _Plan: + clients = frozenset() + requires_config = False + requires_dataverse_endpoint = False + + def __init__(self) -> None: + self.ordered_fns = [("Fake", self._fn)] + + @staticmethod + def _fn(runner): + captured["agent_slug"] = runner.agent_slug + captured["config"] = runner.config + return [_row("FAKE-001", Status.PASSED.value)] + + monkeypatch.setattr(registry, "resolve", lambda target: _Spec()) + monkeypatch.setattr( + registry, "transitive_requirements", lambda target: _Plan() + ) + monkeypatch.chdir(tmp_path) + + with pytest.raises(SystemExit) as exc: + cli._run_single_checkpoint( + _args( + "FAKE-001", + tmp_path, + connect_config=str(overlay), + agent_slug="active-agent", + ) + ) + + assert exc.value.code == 0 + assert captured["agent_slug"] == "active-agent" + assert captured["config"]["tenant"] == "acme" + assert captured["config"]["_connectConfigPath"] == str(overlay) + def test_failed_row_exits_1( self, tmp_path: Path, diff --git a/tests/scripts/test_checkpoint.py b/tests/scripts/test_checkpoint.py new file mode 100644 index 000000000..0ef55176a --- /dev/null +++ b/tests/scripts/test_checkpoint.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import json + +import pytest + +import checkpoint + + +def _write_agent_file(agent_dir, value: str) -> None: + agent_dir.mkdir(parents=True, exist_ok=True) + (agent_dir / "topic.yml").write_text(value, encoding="utf-8") + + +def test_revert_reason_restores_named_checkpoint(tmp_path) -> None: + agent_dir = tmp_path / "agent" + _write_agent_file(agent_dir, "before") + checkpoint.create_checkpoint(str(agent_dir), "before Workday redirect") + + _write_agent_file(agent_dir, "edited") + checkpoint.create_checkpoint(str(agent_dir), "auto-save before push") + _write_agent_file(agent_dir, "pushed") + + checkpoint.cmd_revert_reason(str(agent_dir), "before Workday redirect") + + assert (agent_dir / "topic.yml").read_text(encoding="utf-8") == "before" + checkpoints_dir = agent_dir / ".checkpoints" + reasons = [] + for meta_path in checkpoints_dir.glob("*/_meta.json"): + reasons.append(json.loads(meta_path.read_text(encoding="utf-8"))["reason"]) + assert "auto-save before named revert" in reasons + + +def test_revert_reason_fails_when_checkpoint_is_missing(tmp_path) -> None: + agent_dir = tmp_path / "agent" + _write_agent_file(agent_dir, "current") + + with pytest.raises(SystemExit) as exc: + checkpoint.cmd_revert_reason(str(agent_dir), "missing") + + assert exc.value.code == 1 + + +def test_revert_reason_only_restores_matching_path(tmp_path) -> None: + agent_dir = tmp_path / "agent" + topic_dir = agent_dir / "topics" + topic_dir.mkdir(parents=True) + target = topic_dir / "user-context-setup.mcs.yml" + unrelated = topic_dir / "other.mcs.yml" + target.write_text("before", encoding="utf-8") + unrelated.write_text("before-unrelated", encoding="utf-8") + checkpoint.create_checkpoint(str(agent_dir), "before Workday redirect") + + target.write_text("edited", encoding="utf-8") + unrelated.write_text("keep-this", encoding="utf-8") + + checkpoint.cmd_revert_reason( + str(agent_dir), + "before Workday redirect", + only="topics/user-context-setup.mcs.yml", + ) + + assert target.read_text(encoding="utf-8") == "before" + assert unrelated.read_text(encoding="utf-8") == "keep-this" + + +def test_revert_reason_only_rejects_parent_traversal(tmp_path) -> None: + agent_dir = tmp_path / "agent" + _write_agent_file(agent_dir, "before") + checkpoint.create_checkpoint(str(agent_dir), "before Workday redirect") + + with pytest.raises(ValueError, match="escapes checkpoint"): + checkpoint.cmd_revert_reason( + str(agent_dir), + "before Workday redirect", + only="../outside.yml", + ) + + +def test_revert_reason_only_fails_when_pattern_matches_nothing(tmp_path) -> None: + agent_dir = tmp_path / "agent" + _write_agent_file(agent_dir, "before") + checkpoint.create_checkpoint(str(agent_dir), "before Workday redirect") + + with pytest.raises(ValueError, match="matched no paths"): + checkpoint.cmd_revert_reason( + str(agent_dir), + "before Workday redirect", + only="topics/missing.yml", + ) diff --git a/tests/scripts/test_install_workday_da_extension.py b/tests/scripts/test_install_workday_da_extension.py new file mode 100644 index 000000000..1a6e492e2 --- /dev/null +++ b/tests/scripts/test_install_workday_da_extension.py @@ -0,0 +1,207 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for the PAC-based Workday package installer.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +def _result(returncode=0, stdout="", stderr=""): + return SimpleNamespace( + returncode=returncode, + stdout=stdout, + stderr=stderr, + ) + + +def test_parse_profiles_reads_cloud_and_active_marker(): + import install_workday_da_extension as m + + profiles = m._parse_profiles( + "[1] user@contoso.com Public\n" + "[2] * user@contoso.com Preprod https://org.crm10.dynamics.com\n" + ) + + assert profiles == [ + {"index": "1", "active": False, "cloud": "Public"}, + {"index": "2", "active": True, "cloud": "Preprod"}, + ] + + +def test_runtime_install_uses_preprod_pac_profile_and_application(): + import install_workday_da_extension as m + + calls = [] + + def runner(command, *, capture_output, timeout): + calls.append(([str(part) for part in command], capture_output, timeout)) + if command[1:3] == ["auth", "list"]: + return _result( + stdout=( + "[1] * user@contoso.com Preprod " + "https://org.crm10.dynamics.com\n" + ) + ) + return _result() + + schema = m.install_workday_package( + "https://org.crm10.dynamics.com", + "runtime", + ring="preprod", + pac_resolver=lambda: Path("pac.exe"), + runner=runner, + ) + + assert schema == "msdyn_EssWorkdayRuntime" + assert calls[-1][0] == [ + "pac.exe", + "application", + "install", + "--environment", + "https://org.crm10.dynamics.com", + "--application-name", + "msdyn_EssWorkdayRuntime", + ] + assert calls[-1][1] is False + + +def test_preprod_auth_uses_environment_anchor_when_profile_is_missing(): + import install_workday_da_extension as m + + calls = [] + + def runner(command, *, capture_output, timeout): + calls.append([str(part) for part in command]) + return _result() + + m.install_workday_package( + "https://org.crm10.dynamics.com", + "runtime", + ring="preprod", + pac_resolver=lambda: Path("pac.exe"), + runner=runner, + ) + + assert calls[1] == [ + "pac.exe", + "auth", + "create", + "--cloud", + "Preprod", + "--environment", + "https://org.crm10.dynamics.com", + "--deviceCode", + ] + + +def test_selects_single_inactive_profile_for_requested_ring(): + import install_workday_da_extension as m + + calls = [] + + def runner(command, *, capture_output, timeout): + calls.append([str(part) for part in command]) + if command[1:3] == ["auth", "list"]: + return _result(stdout="[4] user@contoso.com Public\n") + return _result() + + m.ensure_pac_auth( + Path("pac.exe"), + ring="prod", + environment_url="https://org.crm.dynamics.com", + runner=runner, + ) + + assert calls[-1] == [ + "pac.exe", + "auth", + "select", + "--index", + "4", + ] + + +def test_rejects_ambiguous_profiles_for_requested_ring(): + import install_workday_da_extension as m + + def runner(command, *, capture_output, timeout): + return _result( + stdout=( + "[1] user1@contoso.com Preprod\n" + "[2] user2@contoso.com Preprod\n" + ) + ) + + with pytest.raises(m.PacCliError, match="Multiple PAC profiles"): + m.ensure_pac_auth( + Path("pac.exe"), + ring="preprod", + environment_url="https://org.crm10.dynamics.com", + runner=runner, + ) + + +def test_legacy_da_uses_targeted_appsource_application(): + import install_workday_da_extension as m + + calls = [] + + def runner(command, *, capture_output, timeout): + calls.append([str(part) for part in command]) + if command[1:3] == ["auth", "list"]: + return _result(stdout="[1] * user@contoso.com Public\n") + return _result() + + schema = m.install_workday_package( + "https://org.crm.dynamics.com", + "legacy-da", + ring="prod", + pac_resolver=lambda: Path("pac.exe"), + runner=runner, + ) + + assert schema == "msdyn_EssDAHRWorkday" + assert calls[-1][-1] == "msdyn_EssDAHRWorkdayHCM" + + +def test_surfaces_pac_install_failure(): + import install_workday_da_extension as m + + def runner(command, *, capture_output, timeout): + if command[1:3] == ["auth", "list"]: + return _result(stdout="[1] * user@contoso.com Public\n") + return _result(returncode=1) + + with pytest.raises(m.PacCliError, match="could not install"): + m.install_workday_package( + "https://org.crm.dynamics.com", + "runtime", + ring="prod", + pac_resolver=lambda: Path("pac.exe"), + runner=runner, + ) + + +def test_rejects_non_https_environment_url(): + import install_workday_da_extension as m + + with pytest.raises(ValueError, match="must use HTTPS"): + m.install_workday_package( + "http://org.crm.dynamics.com", + "runtime", + ring="prod", + ) + + +def test_resolve_pac_reports_missing_cli(monkeypatch): + import install_workday_da_extension as m + + monkeypatch.setattr(m.shutil, "which", lambda _candidate: None) + + with pytest.raises(m.PacCliError, match="PAC CLI is not installed"): + m.resolve_pac_executable(environ={}) diff --git a/tests/setup/test_da_setup_router.py b/tests/setup/test_da_setup_router.py index 679301c90..3767a31de 100644 --- a/tests/setup/test_da_setup_router.py +++ b/tests/setup/test_da_setup_router.py @@ -173,7 +173,6 @@ def test_global_and_command_gates_require_canonical_da_completion() -> None: gated_prompts = ( "backup-template-configs.prompt.md", - "connect.prompt.md", "create.prompt.md", "delete.prompt.md", "evaluate.prompt.md", @@ -203,6 +202,16 @@ def test_global_and_command_gates_require_canonical_da_completion() -> None: not in normalized ), path + connect_prompt = (_PROMPTS / "connect.prompt.md").read_text(encoding="utf-8") + assert "schema_version: 4" in connect_prompt + assert 'steps.SETUP-07.state: "done"' in connect_prompt + assert "Do not require\n`connect_ready: true`" in connect_prompt + assert "workspace evidence" in connect_prompt + + assert "If the user typed `/connect`" in instructions + assert 'steps.SETUP-07.state: "done"' in instructions + assert "even when runtime `connect_ready` is false" in instructions + assert "`.local/config.json`'s" in instructions @@ -233,7 +242,8 @@ def test_workday_routing_remains_separate() -> None: step1 = _CONNECT_STEP1.read_text(encoding="utf-8") workday = _WORKDAY.read_text(encoding="utf-8") - assert "src/skills/setup/SKILL.md" in step1 + assert "src/skills/setup/workday-da/SKILL.md" in step1 + assert "Fresh CEA Workday" in step1 assert "src/skills/foundation-setup/SKILL.md" not in step1 assert _WORKDAY.is_file() assert "Hybrid Workday extension setup is not available" in workday @@ -903,7 +913,6 @@ def test_da_commands_degrade_by_operation() -> None: expected_text = { "push.prompt.md": "DA-GA agent is not yet available", "delete.prompt.md": "DA-GA agent is not yet available", - "connect.prompt.md": "requires the corresponding product extension", "troubleshoot.prompt.md": ( "requires the corresponding product extension guidance" ), @@ -914,6 +923,10 @@ def test_da_commands_degrade_by_operation() -> None: assert text in prompt, name assert "transport" not in prompt.casefold(), name + connect_prompt = (_PROMPTS / "connect.prompt.md").read_text(encoding="utf-8") + assert "src/skills/connect/SKILL.md" in connect_prompt + assert "Extension setup is not yet available" not in connect_prompt + def test_hybrid_workday_config_commands_remain_available() -> None: routes = { diff --git a/tests/setup/test_setup_router.py b/tests/setup/test_setup_router.py index 62b4d67e6..027afd62e 100644 --- a/tests/setup/test_setup_router.py +++ b/tests/setup/test_setup_router.py @@ -1,10 +1,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""Structural guards for the hybrid Workday setup boundary.""" +"""Structural guards for Workday routing and the hybrid setup boundary.""" from __future__ import annotations +import json from pathlib import Path @@ -14,7 +15,7 @@ _CONNECT_STEP1 = _SOLUTION / "src" / "skills" / "connect" / "step1.md" _CONNECT_SKILL = _SOLUTION / "src" / "skills" / "connect" / "SKILL.md" _OLD_PROMPT = _SOLUTION / ".github" / "prompts" / "setup-workday.prompt.md" -_MONOLITH_DIR = _SOLUTION / "src" / "skills" / "connect" / "workday" +_WORKDAY_PROVIDER_DIR = _SOLUTION / "src" / "skills" / "connect" / "workday" _WORKDAY_PLAYBOOKS = ( "provision-power-platform-environment.md", "install-ess.md", @@ -26,19 +27,163 @@ ) -def test_connect_workday_routes_to_hybrid_boundary() -> None: - step1 = _CONNECT_STEP1.read_text(encoding="utf-8") - connect = _CONNECT_SKILL.read_text(encoding="utf-8") - normalized_step1 = " ".join(step1.split()) - - assert "src/skills/setup/SKILL.md" in step1 - assert "hybrid-extension availability boundary" in normalized_step1 - assert "connect/workday/step" not in step1 - assert "connect/workday/step" not in connect - assert "src/skills/connect/servicenow/" in connect - - -def test_hybrid_boundary_is_non_mutating() -> None: +def test_connect_workday_routes_by_architecture_and_install_state() -> None: + text = _CONNECT_STEP1.read_text(encoding="utf-8") + + assert "src/skills/setup/workday-da/SKILL.md" in text + assert "src/skills/connect/workday/SKILL.md" in text + assert "WD-DA-PKG-001" in text + assert "WD-PKG-001" in text + assert "gptagent_copilotforemployeeselfservicehr" in text + assert "gptagent_copilotforemployeeselfserviceit" in text + assert "ESS DA Hub is not supported" in text + assert "Passed` + simplified-install result" in text + assert "Passed` + full / legacy result" in text + assert "do not treat it as a fresh environment" in text + assert "retired `selected_products` field" in text + assert "Fresh CEA Workday\n installation is not available" in text + assert 'steps.SETUP-07.state: "done"' in text + assert "do not require\n`connect_ready: true`" in text + assert "Do not consult the retired\n`selected_products` field" in text + assert "Continue below only for a concrete active CEA agent" in text + + connect_skill = _CONNECT_SKILL.read_text(encoding="utf-8") + assert "unsupported from\n the current hybrid boundary" in connect_skill + assert "never routes from retired product inventory" in connect_skill + assert "connect/workday/step" not in text + + +def test_connect_workday_provider_contract_and_review_guards() -> None: + contract_path = _WORKDAY_PROVIDER_DIR / "contract.json" + contract = json.loads(contract_path.read_text(encoding="utf-8")) + assert contract["detect"]["meansInstalled"] == "Passed" + assert contract["detect"]["meansNotInstalled"] == "NotConfigured" + assert (_WORKDAY_PROVIDER_DIR / "SKILL.md").is_file() + assert not list(_WORKDAY_PROVIDER_DIR.glob("step*.md")) + + runner = ( + _SOLUTION + / "src" + / "skills" + / "connect" + / "shared" + / "lifecycle-runner.md" + ).read_text(encoding="utf-8") + assert "checkpointAcknowledgements" in runner + assert "allowed `Manual`/`Warning` result lacks" in runner + + da_skill = ( + _SOLUTION / "src" / "skills" / "setup" / "workday-da" / "SKILL.md" + ).read_text(encoding="utf-8") + assert "canonical\n`.local/setup/config.json` `agents` record" in da_skill + assert "stable slug,\n`botId`" in da_skill + assert 'steps.SETUP-07.state: "done"' in da_skill + assert "Do not\nrequire `connect_ready: true`" in da_skill + assert "you\ndo not need to run `/setup` again" in da_skill + assert 'provider `status` to be `"ready"`' in da_skill + + updater = ( + _SOLUTION + / "src" + / "skills" + / "setup" + / "workday-da" + / "shared" + / "checklist-updater.md" + ).read_text(encoding="utf-8") + assert 'RESULT_SOURCE="external"' in updater + assert "already shown `EXTERNAL_EVIDENCE`" in updater + + entra = ( + _SOLUTION + / "src" + / "skills" + / "setup" + / "workday-da" + / "provision-entra-app.md" + ).read_text(encoding="utf-8") + assert "^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$" in entra + assert "label-to-app mapping" in entra + 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 + + power_platform = ( + _SOLUTION + / "src" + / "skills" + / "setup" + / "workday-da" + / "configure-power-platform.md" + ).read_text(encoding="utf-8") + assert "exactly one MCSBot delegated authorization" in power_platform + assert "contains no `[FAIL]` line" in power_platform + assert "Azure CLI Dataverse\ntoken before use" in power_platform + assert "msdyn_sharedworkdaysoap_workdayruntime" in power_platform + assert "msdyn_sharedcommondataserviceforapps_workdayruntime" in power_platform + assert "Enable all Workday topics" in power_platform + assert "legacy ISU/RaaS" not in power_platform + assert "migrationSource" not in power_platform + assert "make.preprod.powerautomate.com" in power_platform + + verify_connection = ( + _SOLUTION + / "src" + / "skills" + / "setup" + / "workday-da" + / "verify-connection.md" + ).read_text(encoding="utf-8") + assert "generic or ISU sign-in" not in verify_connection + + tasks = ( + _SOLUTION + / "src" + / "skills" + / "setup" + / "workday-da" + / "tasks.md" + ).read_text(encoding="utf-8") + assert "Connect Microsoft Entra sign-in to Workday" in tasks + assert "Match the signed-in employee" in tasks + + authorization_script = ( + _SOLUTION + / "scripts" + / "alm" + / "Enable-CosmosDAFlowAuthorization.ps1" + ).read_text(encoding="utf-8") + assert "Test-DataverseToken" in authorization_script + assert "get_dataverse_token.py" in authorization_script + + install = ( + _SOLUTION + / "src" + / "skills" + / "setup" + / "workday-da" + / "install-extension.md" + ).read_text(encoding="utf-8") + assert "sidecarDataverseEndpoint" in install + assert "create it as an empty JSON object" in install + assert '--connect-config ".local/connect/workday-da/config.json"' in install + assert "**`WARNING` / `SKIPPED`**" in install + assert "Never attempt package\n installation from an inconclusive result" in install + + verify = ( + _SOLUTION + / "src" + / "skills" + / "setup" + / "workday-da" + / "verify-connection.md" + ).read_text(encoding="utf-8") + assert '--connect-config ".local/connect/workday-da/config.json"' in verify + assert "sidecarDataverseEndpoint" in power_platform + + +def test_hybrid_boundary_remains_non_mutating() -> None: text = _BOUNDARY.read_text(encoding="utf-8") assert "Hybrid Workday extension setup is not available" in text @@ -48,7 +193,7 @@ def test_hybrid_boundary_is_non_mutating() -> None: assert playbook not in text -def test_retained_workday_playbooks_are_not_publicly_routed() -> None: +def test_retained_legacy_playbooks_are_not_directly_routed() -> None: playbook_dir = _SOLUTION / "src" / "skills" / "setup" / "workday" boundary = _BOUNDARY.read_text(encoding="utf-8") connect = _CONNECT_SKILL.read_text(encoding="utf-8") @@ -61,6 +206,5 @@ def test_retained_workday_playbooks_are_not_publicly_routed() -> None: assert playbook not in step1 -def test_retired_workday_entry_points_remain_absent() -> None: +def test_retired_workday_prompt_remains_absent() -> None: assert not _OLD_PROMPT.exists() - assert not _MONOLITH_DIR.exists()