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 26cf8808b..0eb19efba 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_tenant.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_tenant.py @@ -15,8 +15,8 @@ ``oauthClientId`` / ``tokenEndpoint``. * ``WD-TENANT-001`` — Tenant Setup - Security is configured (redirect URL set; OAuth 2.0 Clients + SAML enabled; SAML Service Provider ID matches - the Entra Identifier) AND the authentication policy is scoped to the - OAuth client and activated. Echoes the captured ``restBaseUrl`` / + the Entra Identifier) AND an active authentication rule allows SAML for + the intended employee population. Echoes the captured ``restBaseUrl`` / ``soapBaseUrl`` / ``tenant`` / ``appIdUri``. Design invariants (per ``scripts/flightcheck/AGENTS.md``): @@ -48,8 +48,7 @@ "areas, Include Workday Owned Scope = Yes)" ) _TENANT_DESC = ( - "Workday Tenant Setup - Security + authentication policy configured for " - "the OAuth client" + "Workday Tenant Setup - Security + signed-in employee SAML policy verified" ) # Marker used in the finding when a config field the operator is expected to @@ -120,7 +119,9 @@ def _check_api_client(config) -> list[CheckResult]: "uses Client Grant Type = SAML ******, includes the functional " "areas Core Payroll, Organizations and Roles, Staffing, and Time " "Off and Leave, and has Include Workday Owned Scope = Yes " - "(required for the REST /workers/me call)." + "(required for the REST /workers/me call). This signed-in employee " + "setup does not use an ISU, RaaS report, or integration-system " + "security-group domain mapping." ) else: result = ( @@ -141,10 +142,9 @@ def _check_api_client(config) -> list[CheckResult]: "Type = SAML ******, select the functional areas Core Payroll, " "Organizations and Roles, Staffing, and Time Off and Leave, and " "set Include Workday Owned Scope = Yes. Then open 'View API " - "Client' and capture the Client ID and Token Endpoint. Register " - "the API client BEFORE scoping the authentication policy — the " - "policy references the OAuth client identity, which only exists " - "once the client is registered." + "Client' and capture the Client ID and Token Endpoint. Do not add " + "legacy ISU, RaaS, or integration-system security-group steps to " + "this signed-in employee setup." ), )] @@ -167,9 +167,9 @@ def _check_tenant_security(config) -> list[CheckResult]: f"(App ID URI) = {app_id_uri}. Confirm Tenant Setup - Security has the " "redirection URL set, OAuth 2.0 Clients and SAML enabled, and the " "SAML Service Provider ID matching the Entra Identifier above — and " - "that the authentication policy is scoped to the registered OAuth " - "client and 'Activate All Pending Authentication Policy Changes' has " - "been run." + "that an active authentication rule allows SAML for the intended " + "employee population. If the existing active policy already provides " + "that access, no policy change or activation is required." ) return [CheckResult(roles=_ROLES, @@ -181,11 +181,14 @@ def _check_tenant_security(config) -> list[CheckResult]: "In Workday: (1) edit 'Tenant Setup - Security' — set the " "redirection URL, enable OAuth 2.0 Clients and SAML, and verify " "the SAML Service Provider ID equals the Entra Identifier / Entity " - "ID; (2) run 'Manage Authentication Policies' — scope the policy " - "to the OAuth client registered in S4.1, allow SAML as an allowed " - "authentication type, then run 'Activate All Pending " - "Authentication Policy Changes'. The functional proof comes " - "downstream, when skill-5's Copilot Studio connection " - "authenticates successfully." + "ID; (2) open 'Manage Authentication Policies' and verify an active " + "rule allows SAML for the intended employees. Do not invent an " + "OAuth-client condition when the tenant UI does not expose one, " + "and do not use an ISU/integration-system security-group rule for " + "this signed-in employee setup. If a policy change is required, " + "preserve administrator access and existing network restrictions, " + "review all pending changes, and only then activate them. The " + "functional proof comes downstream, when the Copilot Studio " + "connection authenticates successfully." ), )] 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..eabe63a8c --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md @@ -0,0 +1,463 @@ + +# 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-API-CLIENT-001` — Workday connection fields captured with the registered API client | attest | +| DA3.3 | `WD-TENANT-001` — signed-in employee SAML authentication policy verified | attest | +| DA3.4 | `WD-CONN-102` *(reuse)* — Workday X.509 signing cert matches the Entra one | manual/attest | + +Run any one with: + +``` +python scripts/flightcheck/cli.py --checkpoint +``` + +**After every checkpoint run, show its result in chat first.** As soon as a +`--checkpoint` run returns, render the result to the user per +[`shared/checklist-updater.md`](shared/checklist-updater.md) §U.0–U.0a — the +compact result table and, for any `MANUAL` (or `Warning` / `NotConfigured`) row, +its full verification steps — **before** you show any later **Message** or ask any +attestation question. Single-checkpoint runs never open the HTML report, so this +in-chat render is the only place the user sees the manual steps; never ask a user +to attest to steps they have not been shown. + +Both `WD-API-CLIENT-001` and `WD-TENANT-001` are always `MANUAL` — they read only +`.local/connect/workday-da/config.json` and echo the captured values. A `MANUAL` +result is **never** completion: each attest row also needs the user's explicit +acknowledgement (enforced by +[`shared/checklist-updater.md`](shared/checklist-updater.md)). + +**Build order.** These tasks must happen in Workday's natural order, which is +**not** the row-number order: sign-in cert (DA3.0c) → Tenant Setup – Security +(DA3.0d) → **register the API client (DA3.1 + DA3.2)** → **verify the +signed-in employee authentication policy (DA3.3)**. Each section states which +checklist row(s) it completes. + +**On every resume, always re-run DA3.0 (Workday-admin gate) and DA3.0b +(single-tenant SAML pre-gate) first — both are idempotent/read-only — before +working the first incomplete row.** The SAML pre-gate is a safety check that +must run before any tenant change; skipping it on resume risks silently +overwriting an active federation. After re-running DA3.0 and DA3.0b, skip any row +whose `setupStatus` state is already `done`. + +--- + +## DA3.0 — Workday administrator gate + +Every task in this step is a **manual Workday-tenant change** — the SAML signing +certificate, Tenant Setup – Security, the Workday API client, and the +authentication policy. None is reachable through a Microsoft admin API, and the +person running this kit (the maker) is often **not** a Workday administrator. So +these steps must be performed **together with a Workday administrator**. Before +making any tenant change, confirm one is lined up. + +This is the attested gate for **DA3.1** (`GATE_MODE = "attested"`, `STEP_ID = +"DA3.1"`, per [`shared/permission-gate.md`](shared/permission-gate.md)) — Workday +has **no directory the kit can query**, so it is an explicit confirmation, not a +programmatic check. + +**Message:** + +The next steps change your Workday tenant directly — the SAML signing +certificate, Tenant Setup – Security, the Workday API client, and the +authentication policy. These are Workday-administrator tasks, so they should be +done **together with a Workday administrator** (if that isn't you). Before we +start, please confirm you have a Workday administrator ready to work through these +steps with you. + +**End message.** + +Use the `vscode_askQuestions` tool: + +```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 = { "method": "attested", "outcome": "pass", "provenance": "user-attestation", "note": "user confirmed a Workday administrator is available to perform DA3.1–DA3.4 with them", "capturedAt": "" }`. +- Carry `GATE_EVIDENCE` forward (recorded when the DA3 rows are updated), and + continue to DA3.0b. + +**If the user chose "No, I have not":** + +**Message:** + +No problem — these steps have to be done with a Workday administrator. Line one up +(or ask whoever holds that role to join you), then come back and run this skill +again. + +**End message.** + +- Set `GATE_RESULT = "stop"` and **halt** — do not continue. + +> An attested `"pass"` records that a Workday administrator was **confirmed +> available**, not directory-proven. It satisfies the *gate*, but it does **not** +> by itself complete any DA3 row — each row still needs its own captured evidence +> and acknowledgement per +> [`shared/checklist-updater.md`](shared/checklist-updater.md). + +--- + +## DA3.0b — Single-tenant SAML pre-gate *(do this before any tenant change)* + +Workday supports exactly **one** active Entra-tenant SAML federation at a time. +Pointing a second Entra tenant at the same Workday tenant silently breaks the +first. Before changing anything, identify and record the **current active SAML +IdP** so a later step never overwrites an unrelated federation. + +**Message:** + +Before I change any Workday security settings, I need to check the tenant's +current SAML sign-on. In Workday, search for and open the **Edit Tenant Setup – +Security** task and find the **SAML Setup** section. Tell me, for the currently +enabled Identity Provider row: the **Issuer** (or IdP name), the **Service +Provider ID**, and the **x509 Certificate** name plus its **Valid From** / +**Valid To** dates (Workday shows no thumbprint). If there is +no active SAML IdP yet, just say **none**. + +**End message.** + +Wait for the user's answer, then record it as the pre-gate evidence +(`SAML_ISSUER`, `SAML_SP_ID`, `SAML_CERT`). + +- **If an IdP is already active AND it is not the Entra app DA-2 provisioned** + (the Issuer / Service Provider ID does not match this tenant's `appIdUri` / + `entraAppId` from `.local/connect/workday-da/config.json`): + + **Message:** + + This Workday tenant already has a **different** SAML identity provider active. + Workday only allows one at a time, and replacing it would break the existing + sign-on for its users. I'm stopping here so nothing is overwritten — please + confirm with whoever owns that federation before continuing, then come back. + + **End message.** + + **Halt.** Do not proceed. + +- **Otherwise** (no active IdP, or the active one is this tenant's own Entra app) + → continue. + +--- + +## DA3.0c — Upload the X.509 signing certificate & confirm certificate parity *(completes DA3.4)* + +Create the Workday **X.509 Public Key** from the Entra signing certificate DA-2 +activated, then confirm the certificate matches — a mismatch means the wrong +certificate was uploaded and SSO will fail. + +**Message:** + +In Entra, open **Enterprise applications → your Workday app → Single sign-on → +SAML Signing Certificate**, and download the **Certificate (Base64)**. Then in +Workday, run the **Create x509 Public Key** task and paste that certificate. Type +**done** when the key is created. + +**End message.** + +Wait for the user, then verify the certificate parity against the certificate +DA-2 activated in Entra. + +**Message:** + +Now I'll compare the certificate you uploaded in Workday against the one activated +in Entra to make sure they match. + +**End message.** + +**Verify (WD-CONN-102):** + +``` +python scripts/flightcheck/cli.py --checkpoint WD-CONN-102 --connect-config ".local/connect/workday-da/config.json" +``` + +`WD-CONN-102` reports the Entra-side certificate health and returns `MANUAL` for +the Workday-side comparison (the Workday cert field is not API-reachable). + +If FlightCheck's Microsoft Graph token has expired or the cache was cleared, this +command **opens a browser window for a Graph sign-in** before it returns. That is +expected — do **not** cancel or re-run it while it pauses; it is blocked on the +sign-in, not hung, and continues once you complete it. + +**Show the `WD-CONN-102` result in chat first.** It always returns `MANUAL` for +the Workday-side comparison, so render it per +[`shared/checklist-updater.md`](shared/checklist-updater.md) §U.0–U.0a — the +result table **and** its full verification steps — **before** the +certificate-parity question below. Never ask the user to attest to a comparison +they have not been shown. + +**Message:** + +Workday doesn't display a certificate thumbprint, so we compare another way. +Confirm you uploaded the exact **Certificate (Base64)** from your Workday app in +Entra (Single sign-on → SAML Signing Certificate), and that the **Valid From** / +**Valid To** dates shown on the Workday x509 Public Key match that Entra +certificate's validity dates. Do they match? + +**End message.** + +Use the `vscode_askQuestions` tool: + +```json +[ + { + "header": "Certificate parity", + "question": "Does the uploaded Workday certificate (and its Valid From / Valid To dates) match the Entra signing certificate?", + "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`, + `ROW_EVIDENCE` recording the compared thumbprints and confirmation, and the + carried `GATE_EVIDENCE`. +- **"No / not sure"** → leave DA3.4 `in-progress`; have the user re-upload the + correct Base64 certificate from Entra and re-check. Do not continue to DA3.0d + with a mismatched cert. + +--- + +## DA3.0d — Edit Tenant Setup – Security + +Configure the tenant's security so OAuth and SAML sign-on work. This is captured +as part of the `WD-TENANT-001` attestation (verified at the end of DA3.3). + +**Message:** + +In Workday, run **Edit Tenant Setup – Security**. Set the **Redirect URL** for +the sign-on, and enable both **OAuth 2.0 Clients Enabled** and **SAML**. In the +SAML Setup, confirm the **Service Provider ID** matches your Entra app's +**Identifier (Entity ID)** — they must be identical. Type **done** when saved. + +**End message.** + +Wait for the user, then continue to DA3.1. + +--- + +## DA3.1 + DA3.2 — Register the API client & capture the connection fields + +Register the Workday API client, then capture the connection identifiers the +Workday extension package's connection form needs. + +**Message:** + +In Workday, run the **Register API Client** task with **Client Grant Type = SAML +******. Under **Scope (Functional Areas)** select **Core Payroll**, +**Organizations and Roles**, **Staffing**, and **Time Off and Leave**, and set +**Include Workday Owned Scope = Yes** (this is required for the REST +`/workers/me` call). Save it, then open **View API Client** for the client you +just created. Type **done** when you're on the View API Client screen. + +**End message.** + +**Message:** + +This setup uses each signed-in employee's Workday identity. It does **not** use +an Integration System User, a RaaS report, or an Integration System Security +Group. The functional areas above define which Workday APIs the client can call; +the employee's existing Workday security determines which employee data those +calls may return. There is no separate domain-to-integration-security-group +mapping step in this setup. + +**End message.** + +Wait for the user. Then **capture and validate the connection fields** using the +shared [`shared/connection-fields.md`](shared/connection-fields.md) (sections +C.1–C.6), passing whatever is already known from +`.local/connect/workday-da/config.json`: + +- `OAUTH_CLIENT_ID`, `TOKEN_ENDPOINT` — from the **View API Client** screen. +- `WD_TENANT`, `WD_BASE_URL`, `WD_TOKEN_HOST` — read from + `.local/connect/workday-da/config.json` if already captured, otherwise gathered + here from the Workday tenant URL (the token endpoint on the View API Client + screen has the form `https://{WD_TOKEN_HOST}/ccx/oauth2/{WD_TENANT}/token`). +- `APP_ID_URI` — the Entra `appIdUri` from DA-2. + +`shared/connection-fields.md` derives the **SOAP base URL** from the Workday web +host (with a user-prompt fallback), trims the **REST base URL** to `/api`, and +persists `oauthClientId`, `tokenEndpoint`, `soapBaseUrl`, `restBaseUrl`, and +`appIdUri` back to `.local/connect/workday-da/config.json` (round-trip merge — +never drop fields owned by other steps). + +**Message:** + +Now I'll confirm the Workday API client you registered was captured correctly. + +**End message.** + +**Verify (WD-API-CLIENT-001):** + +``` +python scripts/flightcheck/cli.py --checkpoint WD-API-CLIENT-001 --connect-config ".local/connect/workday-da/config.json" +``` + +This echoes the captured `oauthClientId` / `tokenEndpoint` and restates the +registration facts to confirm. `WD-API-CLIENT-001` always returns `MANUAL`, so +render its result in chat per +[`shared/checklist-updater.md`](shared/checklist-updater.md) §U.0–U.0a — the +result table **and** its full verification steps — **before** you ask the user +to acknowledge the row. Then: + +- Confirm the row via [`shared/checklist-updater.md`](shared/checklist-updater.md) + with `STEP_ID="DA3.1"`, `GATE="attest"`, `CHECKPOINT_RESULT="MANUAL"`, + `ACK=true` once the user acknowledges the client is registered correctly, + plus `ROW_EVIDENCE` recording the confirmed registration facts and the + carried `GATE_EVIDENCE`. +- Then update **DA3.2** (connection fields captured) via + [`shared/checklist-updater.md`](shared/checklist-updater.md) with + `STEP_ID="DA3.2"`, `GATE="attest"`, `CHECKPOINT_RESULT="MANUAL"`, `ACK=true` — + using the persisted fields as `ROW_EVIDENCE` and carrying `GATE_EVIDENCE`. + +If the user says the client is wrong or fields are missing, leave DA3.1/DA3.2 +`in-progress` and re-capture before continuing. + +--- + +## DA3.3 — Verify the signed-in employee authentication policy + +Verify that the Workday environment permits SAML authentication for the intended +employee population. Workday tenants vary in how authentication policies are +organized, and the policy screens may not expose an OAuth-client condition. +Never invent one, never route this signed-in employee setup through an ISU rule, +and never enable a disabled policy only to satisfy this checklist. + +**Message:** + +In Workday, open **Manage Authentication Policies** for the environment your +employees use. With your Workday administrator, verify that an active rule allows +**SAML** for the intended employee population. + +- Do not use an ISU or integration-system security-group rule for this setup. +- Do not look for an OAuth-client restriction if this tenant's policy screen + does not provide one. +- Preserve administrator access, employee coverage, and existing network/IP + restrictions. +- If the current active policy already allows employee SAML sign-in, no change + is needed. +- If a change is required, review all pending authentication-policy changes + before activating them. + +**End message.** + +Use the `vscode_askQuestions` tool: + +```json +[ + { + "header": "Employee SAML policy", + "question": "What did the Workday administrator confirm for the employee authentication policy?", + "options": [ + { + "label": "Existing active policy already allows employee SAML", + "description": "No policy change or activation was needed", + "recommended": true + }, + { + "label": "Reviewed policy change was activated", + "description": "The admin preserved employee/admin access and existing network restrictions" + }, + { + "label": "Not confirmed yet", + "description": "Keep this step in progress" + } + ], + "allowFreeformInput": false + } +] +``` + +For either confirmed option, capture the selected policy name or rule and whether +the existing configuration was reused or a reviewed change was activated. For +**Not confirmed yet**, leave DA3.3 `in-progress` and stop without blocking or +resetting completed rows. + +Then verify the whole tenant configuration. + +**Message:** + +Now I'll confirm your Workday tenant security and authentication-policy settings +are in place. + +**End message.** + +**Verify (WD-TENANT-001):** + +``` +python scripts/flightcheck/cli.py --checkpoint WD-TENANT-001 --connect-config ".local/connect/workday-da/config.json" +``` + +This echoes the captured `tenant` / `restBaseUrl` / `soapBaseUrl` / `appIdUri` +and restates the Tenant Setup – Security and signed-in employee +authentication-policy facts to confirm. +`WD-TENANT-001` always returns `MANUAL`, so render its result in chat per +[`shared/checklist-updater.md`](shared/checklist-updater.md) §U.0–U.0a — the +result table **and** its full verification steps — **before** you ask the user to +confirm. Then update **DA3.3** via +[`shared/checklist-updater.md`](shared/checklist-updater.md) with +`STEP_ID="DA3.3"`, `GATE="attest"`, `CHECKPOINT_RESULT="MANUAL"`, `ACK=true` once +the user confirms one of the two supported outcomes above. Pass that selected +policy/rule and whether it was reused or activated as `ROW_EVIDENCE`, together +with the carried `GATE_EVIDENCE`. + +The **functional** proof of all of this comes downstream, when the Workday +extension package's Dataverse connection authenticates successfully — not +from any standalone Workday call here. Verifying that connection end-to-end is +outside this skill's current scope; see DA-4 for what is and isn't checked. + +--- + +## 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 signed-in employee 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/provision-entra-app.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md new file mode 100644 index 000000000..7c4bfb75a --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md @@ -0,0 +1,696 @@ + +# 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) +- `GATE_MODE` = `"programmatic"` +- `STEP_ID` = `"DA2.1"` +- `ROLE_QUERY` = a Microsoft Graph directory-role membership check for the + signed-in user: + + ``` + az rest --method GET --url "https://graph.microsoft.com/v1.0/me/memberOf/microsoft.graph.directoryRole?%24select=displayName,roleTemplateId" --query "value[].{displayName:displayName,roleTemplateId:roleTemplateId}" -o json + ``` + + The role is held only when a returned `roleTemplateId` equals one of these + stable built-in role template IDs: + + - Application Administrator: + `9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3` + - Cloud Application Administrator: + `158c047a-c907-4556-b7ef-446551a6b5f7` + - Privileged Role Administrator: + `e8611ab8-c189-46e8-94e1-60213ab1f814` + - Global Administrator: + `62e90394-69f5-4237-9190-012177145e10` + + Do not authorize from `displayName`; it is included only for readable + evidence. Treat an + `Insufficient privileges` / `Authorization_RequestDenied` / forbidden response + as "role not held". If the query errors for an unrelated reason (network, not + signed in), retry once and then stop. Never downgrade this programmatic role + gate to self-attestation. + +If `GATE_RESULT` is `"stop"`, **halt** — do not continue. Otherwise carry +`GATE_EVIDENCE` forward (recorded when the DA2 rows are updated). + +--- + +## DA2.0b — Capture the Workday tenant URL *(enables deterministic app discovery)* + +Knowing the Workday tenant lets DA2.1 pin the **exact** Entra SSO app for this +Workday tenant — the app federated to it carries `http://www.workday.com/{tenant}` +as its SAML identifier — instead of guessing among look-alike "Workday" apps. This +step is **idempotent** and **best-effort**: if the URL isn't handy, skip it and +DA2.1 falls back to an interactive picker. + +**If `tenant` is already set** in `.local/connect/workday-da/config.json`, skip +this step — it was captured here on an earlier run, or by DA-3. + +Otherwise ask for the Workday URL with the `vscode_askQuestions` tool: + +```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 one or more Workday SAML apps already exist** — consider only the + returned apps in SAML mode (`sso == "saml"`). Because tenant identity was not + established, never auto-select by display name, even when there is exactly + one match. The app chosen here is pinned to `entraAppId` in config, and every + later step and FlightCheck check (consent, user assignment, NameID) keys off + it — picking the wrong sibling makes a correctly-configured app report + FAILED. Ask the user to choose. Use the `vscode_askQuestions` tool, building + the `options` array **dynamically from the returned SAML apps** — one option + per app, plus a final "Create a new app instead" option: + + ```json + [ + { + "header": "Workday Entra app", + "question": "I found more than one Workday enterprise app in your tenant. Which one should ESS use for Workday single sign-on?", + "options": [ + { "label": "Workday (ESS Copilot)", "description": "SAML · reply URL https://…/ess · provisioned by this kit", "recommended": true }, + { "label": "Create a new app instead", "description": "Provision a fresh \"Workday (ESS Copilot)\" app from the gallery" } + ], + "allowFreeformInput": false + } + ] + ``` + + Emit one option object per returned SAML app. Build a label-to-app mapping + before asking. If a display name is unique, use it as the label. If two or + more apps share a display name, make each **label itself** unique by + appending `· {last 6 characters of appId}`. Set `description` to its SSO + mode plus first reply URL; never include a full app/object GUID. Mark the + option for the kit-provisioned **`Workday (ESS Copilot)`** app as + `recommended` when unambiguous. Then: + - **User picks an existing app** → use the retained label-to-app mapping + (never a display-name search) to map the unique chosen label to that app and + save its `appId` → `WD_ENTRA_APP_ID` and its `id` (the service-principal + id) → `WD_ENTRA_SP_ID`, then resolve its **application** object id — the + `az ad sp list` results carry the *service-principal* id, **not** the app + object id, so query it explicitly: + + ``` + az ad app list --filter "appId eq '{WD_ENTRA_APP_ID}'" --query "[0].id" -o tsv + ``` + + → `WD_ENTRA_APP_OBJECT_ID`. Then **skip to Persist below** so `entraAppId` + is written to config — do **not** jump ahead to verify. + - **User picks "Create a new app instead"** → follow the **If none exists** + instantiate path below. + +- **If none exists**, instantiate from the Workday gallery template. Find the + template id, then instantiate it: + + ``` + az rest --method GET --url "https://graph.microsoft.com/v1.0/applicationTemplates?%24filter=displayName%20eq%20'Workday'" --query "value[0].id" -o tsv + ``` + + ```powershell + $body = @{displayName="Workday (ESS Copilot)"} | ConvertTo-Json + $body | Out-File "$env:TEMP\ess-wd-template.json" -Encoding utf8 + az rest --method POST --url "https://graph.microsoft.com/v1.0/applicationTemplates/{TEMPLATE_ID}/instantiate" --headers "Content-Type=application/json" --body "@$env:TEMP\ess-wd-template.json" + ``` + + From the response, save `application.appId` → `WD_ENTRA_APP_ID`, + `application.id` → `WD_ENTRA_APP_OBJECT_ID`, `servicePrincipal.id` → + `WD_ENTRA_SP_ID`. Then set SAML mode, the identifier/reply URLs, and add **and + activate** a token-signing certificate (set + `preferredSingleSignOnMode = "saml"`, `identifierUris`/`web.redirectUris`, then + `addTokenSigningCertificate` and set `preferredTokenSigningKeyThumbprint` to + activate it — capture the thumbprint + expiry). + + **Portal fallback (permission error on instantiate/PATCH):** + + **Message:** + + I need permission to create and configure enterprise applications in your Entra + tenant, which requires the **Application Administrator** or **Cloud Application + Administrator** role. If you can't get that role, ask your IT admin to create a + Workday enterprise app from the Entra gallery (SAML mode, with a token-signing + certificate) and share its Application ID with you, then tell me and I'll pick + it up from there. + + **End message.** + + Wait for the user, then re-resolve the app with the `az ad sp list` filter above. + +**Persist** the app identity to `.local/connect/workday-da/config.json` (merge — +keep other keys, per [`shared/config-schema.md`](shared/config-schema.md)): + +- `entraAppId` = `WD_ENTRA_APP_ID` +- `entraAppObjectId` = `WD_ENTRA_APP_OBJECT_ID` + +**Verify (WD-CONN-102):** + +This is the **first FlightCheck checkpoint in this skill that uses Microsoft +Graph**. FlightCheck signs in to Graph with its **own** token — separate from the +`az` sign-in used to create the app above and from the earlier environment +sign-in — so the command below **opens a browser window for a Microsoft Graph +sign-in** the first time it runs. Show the message first, then run the command. +Do **not** wait for a chat reply before running it, and do **not** cancel or +re-run the command while it appears to pause: it is **blocked on the browser +sign-in, not hung**, and returns on its own once the sign-in completes. (Later +Graph checkpoints reuse this token and run silently.) + +**Message (do NOT wait for a response — continue immediately):** + +I'm running the first readiness check now — it confirms the single sign-on signing +certificate for your Workday app is present and healthy. A browser window will open +for a Microsoft Graph sign-in — please complete it with the same admin account, and +I'll continue automatically once it finishes. + +**End message.** + +``` +python scripts/flightcheck/cli.py --checkpoint WD-CONN-102 --connect-config ".local/connect/workday-da/config.json" +``` + +`WD-CONN-102` reports the Entra-side signing-certificate health. It returns +`MANUAL` for the healthy state because Workday-side certificate parity is verified +later in DA-3 (row DA3.4). Present the certificate/thumbprint result to the +user, then update **DA2.1** via +[`shared/checklist-updater.md`](shared/checklist-updater.md) with +`STEP_ID="DA2.1"`, `GATE="manual"`, `CHECKPOINT_RESULT` = the checkpoint result, +and `ACK` = the user's explicit confirmation that the certificate was added and +activated. Pass the certificate thumbprint and activation confirmation as +`ROW_EVIDENCE`, and persist the DA2.0 `GATE_EVIDENCE`. + +--- + +## DA2.2 — Expose the API scope, pre-authorize the connector, grant Graph perms + +Configure the app (`WD_ENTRA_APP_OBJECT_ID` from DA2.1) so the Power Platform +Workday connector can obtain an on-behalf-of token. + +1. **Expose the `user_impersonation` scope** — apply + [`connect/azure/app-registration.md`](../../connect/azure/app-registration.md) + **§B.4** against this app, with `APP_OBJECT_ID` = `WD_ENTRA_APP_OBJECT_ID`, + `APP_CLIENT_ID` = `WD_ENTRA_APP_ID`, and `SCOPE_RESOURCE_LABEL` = `Workday`. + That sets the identifier URI `api://{WD_ENTRA_APP_ID}`, generates a + `SCOPE_GUID`, and exposes `user_impersonation` (with a built-in portal + fallback). + +2. **Pre-authorize the Workday connector** — apply the same file's **§B.5** with + `CONNECTOR_APP_ID` = `4e4707ca-5f53-46a6-a819-f7765446e6ff` (the Power Platform + **Workday** connector — never the ServiceNow `c26b24aa`), `APP_OBJECT_ID` = + `WD_ENTRA_APP_OBJECT_ID`, and the `SCOPE_GUID` from step 1. + +3. **Add the Graph delegated permissions** `openid`, `profile`, `User.Read`: + + ```powershell + $body = @{requiredResourceAccess=@(@{ + resourceAppId="00000003-0000-0000-c000-000000000000" + resourceAccess=@( + @{ id="37f7f235-527c-4136-accd-4a02d197296e"; type="Scope" } + @{ id="14dad69e-099b-42c9-810b-d002981feec1"; type="Scope" } + @{ id="e1fe6dd8-ba31-4d61-89e7-88639da4683d"; type="Scope" } + ) + })} | ConvertTo-Json -Depth 6 + $body | Out-File "$env:TEMP\ess-wd-graphperms.json" -Encoding utf8 + az rest --method PATCH --url "https://graph.microsoft.com/v1.0/applications/{WD_ENTRA_APP_OBJECT_ID}" --headers "Content-Type=application/json" --body "@$env:TEMP\ess-wd-graphperms.json" + ``` + + **Portal fallback (PATCH fails):** + + **Message:** + + I couldn't add the Microsoft Graph permissions automatically. You can add them + in the portal: open https://entra.microsoft.com → **App registrations** → your + Workday app → **API permissions** → **Add a permission** → **Microsoft Graph** + → **Delegated permissions** → add **openid**, **profile**, and **User.Read**. + Type **done** when you're finished. + + **End message.** + + Wait for the user, then continue. + +**Persist** to `.local/connect/workday-da/config.json` (merge): `scopeGuid` = +`SCOPE_GUID`, `appIdUri` = `api://{WD_ENTRA_APP_ID}`, `entraSSO` = `true`. + +**Message:** + +Now I'll verify the Workday app exposes its API permission and that the Power +Platform Workday connector is pre-authorized to call it. + +**End message.** + +**Verify (WD-ENTRA-SCOPE-001):** + +``` +python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-SCOPE-001 --connect-config ".local/connect/workday-da/config.json" +``` + +- **`PASSED`** → update **DA2.2** via + [`shared/checklist-updater.md`](shared/checklist-updater.md) with + `STEP_ID="DA2.2"`, `GATE="prog"`, `CHECKPOINT_RESULT="PASSED"`; persist + `GATE_EVIDENCE`. Continue to DA2.3. +- **`FAILED`** → the result names which of the three (scope / pre-authorization / + Graph perms) is missing. Redo that step (Graph or portal fallback), then re-run + the checkpoint. Keep DA2.2 `in-progress` until it passes. +- **`WARNING` / `SKIPPED`** → surface the message; re-run once. A `SKIPPED` means + Graph auth or the app couldn't be resolved — confirm DA2.1 completed first. + +--- + +## DA2.3 — Grant admin consent for the Graph delegated permissions + +Grant tenant-wide admin consent so the on-behalf-of handshake works for all end +users. Attempt it through Graph; if the caller lacks a consent-capable role, +**escalate to manual consent** rather than hard-failing. + +Grant admin consent for the app's service principal (portal is the reliable path; +attempt the portal/`az` grant): + +**Message:** + +Now I need an administrator to grant consent for the Workday app's permissions. +Open https://entra.microsoft.com → **Enterprise applications** → the **Workday +(ESS Copilot)** app → **Permissions** → **Grant admin consent for +<your tenant>**, then approve the prompt. This needs a consent-capable role +(Application Administrator, Cloud Application Administrator, Privileged Role +Administrator, or Global Administrator). Type **done** when the consent is granted. + +**End message.** + +Wait for the user, then verify. + +**Message:** + +Now I'll confirm that admin consent was recorded for the Workday app's +permissions. + +**End message.** + +**Verify (WD-ENTRA-CONSENT-001):** + +``` +python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-CONSENT-001 --connect-config ".local/connect/workday-da/config.json" +``` + +- **`PASSED`** → update **DA2.3** via + [`shared/checklist-updater.md`](shared/checklist-updater.md) with + `STEP_ID="DA2.3"`, `GATE="prog"`, `CHECKPOINT_RESULT="PASSED"`. Continue to + DA2.4. +- **`FAILED`** → consent isn't recorded yet. + + **Message:** + + I don't see admin consent for the Workday app's Graph permissions yet. If you + don't hold a consent-capable role (Application Administrator, Cloud Application + Administrator, Privileged Role Administrator, or Global Administrator), ask an + administrator to run **Grant admin consent** on the Workday enterprise app, then + tell me and I'll re-check. + + **End message.** + + After the user confirms, re-run the checkpoint. Keep DA2.3 `in-progress` + (escalated to manual consent) until it passes. + +--- + +## DA2.4 — Enterprise-app user assignment (or confirm not required) + +Ensure the Workday enterprise app either does not require user assignment, or has +the ESS user security group assigned — otherwise the OBO handshake fails for end +users at first access. + +**Message:** + +Now I'll check whether the Workday enterprise app requires user assignment and, if +so, that the right users are assigned. + +**End message.** + +**Verify (WD-ASSIGN-001):** + +``` +python scripts/flightcheck/cli.py --checkpoint WD-ASSIGN-001 --connect-config ".local/connect/workday-da/config.json" +``` + +- **`PASSED`** (assignment satisfied via a group, or not required) → update + **DA2.4** via [`shared/checklist-updater.md`](shared/checklist-updater.md) with + `STEP_ID="DA2.4"`, `GATE="prog"`, `CHECKPOINT_RESULT="PASSED"`. Continue to + DA2.5. +- **`FAILED`** (assignment required, nothing assigned): + + **Message:** + + The Workday enterprise app requires user assignment but nothing is assigned yet. + Open https://entra.microsoft.com → **Enterprise applications** → the Workday app + → **Users and groups** → **Add user/group**, and assign the ESS user security + group (preferred over individual users). Type **done** when you've assigned it. + + **End message.** + + After the user confirms, re-run the checkpoint. Keep DA2.4 `in-progress` until + it passes. +- **`WARNING`** (assignment not required, or only individual users assigned) → this + is a hardening recommendation, not a blocker. Surface the message; treat a + passing-with-warning as a `prog` pass for the row only if the underlying state is + acceptable to the user, otherwise leave `in-progress` and let them assign a group. + +--- + +## DA2.5 — NameID claim mapping (`claimsMappingPolicy`) + +Map the SAML NameID claim so the value Workday receives equals the Workday User +Name. Attempt the `claimsMappingPolicy` create + assign through Graph; if the +policy route proves brittle, degrade to the manual portal path. + +Create a claimsMappingPolicy that overrides the NameID claim (map to the attribute +that equals the Workday User Name — typically `user.mail` or +`user.userPrincipalName`) and assign it to the Workday service principal +(`WD_ENTRA_SP_ID`) via +`POST /servicePrincipals/{WD_ENTRA_SP_ID}/claimsMappingPolicies/$ref`. + +**Portal fallback (policy create/assign fails, or the tenant blocks custom +policies):** + +**Message:** + +I couldn't set the NameID mapping automatically. You can set it in the portal: +open https://entra.microsoft.com → **Enterprise applications** → the Workday app → +**Single sign-on** → **Attributes & Claims** → edit the **Unique User +Identifier (Name ID)** claim so its source attribute equals the Workday User Name +(commonly **user.mail** or **user.userPrincipalName**). Type **done** when it's +set. + +**End message.** + +Wait for the user, then verify. + +**Message:** + +Now I'll verify the single sign-on user identifier (NameID) is mapped to the value +your Workday tenant expects. + +**End message.** + +**Verify (WD-ENTRA-NAMEID-001):** + +``` +python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-NAMEID-001 --connect-config ".local/connect/workday-da/config.json" +``` + +- **`PASSED`** (a NameID-overriding policy is assigned) → update **DA2.5** via + [`shared/checklist-updater.md`](shared/checklist-updater.md) with + `STEP_ID="DA2.5"`, `GATE="prog"`, `CHECKPOINT_RESULT="PASSED"`. Continue to + DA2.6. +- **`FAILED`** (no override — Entra sends the default UPN) → if your tenant + deliberately relies on the default `userPrincipalName` NameID **and** it already + equals the Workday User Name, this can be attested manually; otherwise create the + mapping (Graph or portal fallback) and re-run. Keep DA2.5 `in-progress` until + resolved. +- **`MANUAL`** (the policy route is unreadable — missing `Policy.Read.All`) → + degrade to a manual portal check: confirm the NameID mapping in the portal + (steps above), then treat DA2.5 as a `manual` row needing explicit + acknowledgement via + [`shared/checklist-updater.md`](shared/checklist-updater.md). + +--- + +## DA2.6 — "Sign SAML response and assertion" signing option *(portal-only)* + +This signing option has no documented Graph property, so it is a **manual portal +gate** — a Workday service provider that validates signatures rejects the +assertion if it is set wrong. + +**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**. Confirm only this Entra setting here. Workday-side +issuer, service-provider ID, and certificate verification happens in the next +phase, after the Workday-administrator gate. Type **done** when the Entra setting +is saved. + +**End message.** + +Then, per [`shared/checklist-updater.md`](shared/checklist-updater.md)'s manual +rule, ask for an explicit acknowledgement and update **DA2.6** with +`STEP_ID="DA2.6"`, `GATE="manual"`, `CHECKPOINT_RESULT="MANUAL"`, and `ACK` = the +user's explicit confirmation. Pass the displayed signing-option values and +confirmation as `ROW_EVIDENCE`. On `ACK=true` with that evidence the row becomes +`done`; a `MANUAL` result alone never completes it. + +--- + +## DA2.7 — Confirm single-Entra-tenant federation alignment + +Confirm that the selected Workday SAML application belongs to the same Entra +tenant selected during `/setup`. This phase stays Entra-only; it does not ask the +maker to inspect or change Workday before a Workday administrator is available. + +**Message:** + +Now I'll confirm that the selected Workday sign-in application belongs to this +environment's Microsoft Entra tenant. No Workday portal changes are needed in +this phase. + +**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 and scope the confirmation to the Entra application selected +in DA2.1. Do not ask the maker to open Workday or prove the active Workday IdP +here; DA3.0b performs that comparison after the Workday-administrator gate. +Then — this is an **attest** row — ask the user to confirm that the selected app +is the intended Workday tenant application and update **DA2.7** via +[`shared/checklist-updater.md`](shared/checklist-updater.md) with +`STEP_ID="DA2.7"`, `GATE="attest"`, `CHECKPOINT_RESULT` = the checkpoint result, +and `ACK` = the user's explicit confirmation. Pass the selected application +identity and tenant match as `ROW_EVIDENCE`, and persist the DA2.0 +`GATE_EVIDENCE`. + +--- + +## Done + +**Message:** + +Your Workday Entra app is configured and verified — the API scope, connector +authorization, admin consent, user assignment, NameID mapping, and SAML signing +are all in place. Next we'll configure the Workday tenant side (DA-3). + +**End message.** + +Rows DA2.1–DA2.7 are now recorded in the checklist. Return control to the +orchestrator (`SKILL.md`) to resume at the next unverified row. Stop here — the +Workday tenant configuration is a separate step. 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..0b81fedf3 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/checklist-updater.md @@ -0,0 +1,307 @@ +# Master-Checklist Updater (DA) + +The single routine every DA Workday setup skill calls to update **its own rows** +in the DA master checklist. Centralizing it means each skill records status the +same way, and the **MANUAL/attestation rule** below is enforced in exactly one +place. + +Forked from the CEA `setup/shared/checklist-updater.md` with DA-scoped state +paths (`.local/setup/workday-da/tasks.md`, `.local/connect/workday-da/config.json`). +The logic is identical — only the persisted files differ — so the two skills can +evolve independently. + +Every **Message** block is the exact text to show the user. Copy it verbatim. Do +not narrate tool calls. + +**Inputs from the calling file:** +- `STEP_ID` — the DA master checklist Step ID to update (e.g. `"DA3.1"`, + `"DA2.4"`). See the canonical rows in the checklist template + `src/skills/setup/workday-da/tasks.md`. A skill updates **only** the Step IDs + it owns. +- `NEW_STATE` — `"in-progress"` \| `"done"` \| `"blocked"`. +- `CHECKPOINT_RESULT` — the flightcheck result for the row's checkpoint, one of + `PASSED` \| `FAILED` \| `ERROR` \| `WARNING` \| `MANUAL` \| + `NOT_CONFIGURED` \| `SKIPPED` \| `null` (null = not run yet). +- `GATE` — the row's gate type: `"prog"` \| `"manual"` \| `"attest"` \| + `"advisory"` (from the DA master checklist row; also recorded in config per + `config-schema.md`). +- `ACK` — *(manual/attest rows only)* `true` once the user has explicitly + acknowledged the step and any evidence has been captured; otherwise `false`. +- `RESULT_SOURCE` — `"flightcheck"` (default) when `CHECKPOINT_RESULT` came + from the current FlightCheck results file, or `"external"` when a + programmatic operation produced its own structured evidence. +- `EXTERNAL_EVIDENCE` — required when `RESULT_SOURCE="external"`; a safe + summary proving the operation's target, outcome, and verification. Never + include credentials, tokens, or raw sensitive output. +- `GATE_EVIDENCE` — optional structured evidence returned by + `permission-gate.md`. Preserve it under the row's `gateEvidence` field; do + not store the object in scalar `verifiedBy`. +- `ROW_EVIDENCE` — required before a `manual`/`attest` row can complete. It is + a safe object with `outcome`, `provenance`, `note`, and `capturedAt`; + `provenance` identifies the source such as `flightcheck`, + `user-acknowledgement`, or `external-operation`. + +**Outputs:** +- The matching checklist item in `.local/setup/workday-da/tasks.md` is updated + in place (checkbox + hidden `status:` field). +- The mirror record `setupStatus["{STEP_ID}"]` in + `.local/connect/workday-da/config.json` is updated (see `config-schema.md`). + +--- + +## Files + +- **Working copy (read/write):** `.local/setup/workday-da/tasks.md` — the + rendered, human-readable checklist. Rendered on first run from the template + `src/skills/setup/workday-da/tasks.md` (the canonical row source). If the + working copy doesn't exist yet, render it from the template before updating. +- **Durable mirror:** `setupStatus` in `.local/connect/workday-da/config.json`. + The tasks file is the view; `setupStatus` is the source of truth a later + step reads to know what's already done. + +Row shape in `tasks.md` (each item in the checklist template +`src/skills/setup/workday-da/tasks.md`): a checkbox line the user sees, followed +by an HTML comment the tooling reads. + +``` +- [ ] **** — + +``` + +- `- [ ]` / `- [x]` is the at-a-glance done marker. +- The hidden `id:` field is the `STEP_ID`; the hidden `status:` field carries the + full four-state value a single checkbox can't express. +- **Never surface a Step ID, checkpoint ID, or the hidden comment to the user** — + they see the checkbox and its description only. + +--- + +## U.0 — Show the checkpoint result to the user (in chat) + +**Timing — render this the instant a checkpoint run returns, and for a +manual/attest row *before* you ask the attestation question.** When a +`python scripts/flightcheck/cli.py --checkpoint ` run just produced +`CHECKPOINT_RESULT`, surface that result to the user — the U.0 table **and** the +U.0a manual steps — before touching any state and before any attestation, so every +checkpoint run has a visible outcome. Single-checkpoint runs never open the HTML +report, so this in-chat render is the only place the user sees the outcome; never +ask a user to attest to manual steps they have not been shown. If you already +rendered this checkpoint's result this pass (per a skill's post-checkpoint display +convention), do not repeat it — proceed to U.1. The U.1–U.3 status update below +runs afterwards (once any attestation is answered) and does **not** re-display the +result. + +- Skip this step when `RESULT_SOURCE="external"`; show `EXTERNAL_EVIDENCE` + using the calling playbook's operation-specific result instead. Also skip + when `CHECKPOINT_RESULT` is `null` (the checkpoint was not run this pass), + or when `workspace/flightcheck/results.json` does not exist. + +Read `workspace/flightcheck/results.json` — the run that led here wrote it. It has: + +```json +{ "results": [ { "checkpoint_id": "...", "description": "...", "status": "..." }, ... ] } +``` + +Render a GitHub-flavoured markdown table in chat, **one row per entry** in +`results`, using `description` verbatim for **Check** and `status` verbatim for +**Status**: + +``` +| Check | Status | +| --- | --- | +| | | +``` + +Rules: +- **Never** include `checkpoint_id`, the Step ID, or any other internal + identifier — there is no ID column; `description` is the only label shown. +- If a `description` or `status` contains a `|`, escape it as `\|`; collapse any + newline to a single space. +- If `results` is empty, render **no** table. +- This table is **in addition to** the row's own **Message** blocks and the + manual verification steps below (see U.0a) — it does not replace or alter them. +- Draw the table yourself in chat. Do not mention `results.json`, file paths, or + the tools used to produce it. + +--- + +## U.0a — Show the manual verification steps to the user (in chat) + +Do this right after the U.0 table, before touching any state. A +`python scripts/flightcheck/cli.py --checkpoint ` run **never opens the HTML +report** — for `MANUAL` checks the verification steps must appear **in chat**, not +in a browser popup. This routine is what puts them there. + +- Skip this step when `RESULT_SOURCE="external"`; the calling playbook has + already shown `EXTERNAL_EVIDENCE`. Also skip when `CHECKPOINT_RESULT` is + `null`, or when `workspace/flightcheck/results.json` does not exist. + +Each entry in `results.json` carries the full text of what the operator must do — +not just `description`/`status` but also the finding and the how-to: + +```json +{ "checkpoint_id": "...", "description": "...", "status": "Manual", + "result": "", + "remediation": "" } +``` + +For **every** entry in `results` whose `status` is `Manual` (also `Warning` or +`NotConfigured`, when present), render a block in chat — one per entry, in the +order they appear — using `description` as the heading, then `result`, then +`remediation`: + +``` +**** + + + + +``` + +Rules: +- Copy `result` and `remediation` **verbatim** — keep the numbered/bulleted steps + and every line break. Do **not** summarise, shorten, re-order, or paraphrase the + steps; the operator follows them exactly. +- Still **never** surface `checkpoint_id`, the Step ID, or the hidden comment. +- Do **not** open, mention, or link `report.html` — the steps live in chat now. +- If no entry has a `Manual`/`Warning`/`NotConfigured` status, render no block. +- Do not mention `results.json`, file paths, or the tools used to produce it. + +--- + +## U.1 — Locate the item + +Read `.local/setup/workday-da/tasks.md` (render from the template first if +absent). Find the checklist item whose hidden comment has `id:` equal to +`STEP_ID`. + +- If no such item exists, **stop and report** — a skill must not invent items. + The canonical item set lives in the checklist template + `src/skills/setup/workday-da/tasks.md`; a missing item means the template is + out of date, not that the updater should add one. +- If `STEP_ID` is **not** owned by the calling skill, **stop** — skills update + only their own items. + +--- + +## U.2 — Determine the new Status (the MANUAL/attestation rule) + +This is the load-bearing rule. **A `MANUAL` or attestation-gated row is never +auto-completed by a flightcheck pass.** + +First apply failure precedence: for every non-advisory row, +`CHECKPOINT_RESULT = FAILED` or `ERROR` always produces `blocked`, regardless +of `ACK`, `NEW_STATE`, or gate evidence. An acknowledgement records that a +person saw or performed a step; it never overrides an objective failure. + +Otherwise decide `Status` as follows: + +| `GATE` | Condition | Resulting `Status` | +|--------|-----------|--------------------| +| `prog` | `CHECKPOINT_RESULT` = `PASSED` | `done` | +| `prog` | `CHECKPOINT_RESULT` = `WARNING` / `NOT_CONFIGURED` / `SKIPPED` / `MANUAL` / `null` | `in-progress` | +| `manual` / `attest` | `ACK` = `true`, `ROW_EVIDENCE` is complete, and result is not `FAILED`/`ERROR` | `done` | +| `manual` / `attest` | `ACK` = `false` or `ROW_EVIDENCE` is missing | `in-progress` | +| `advisory` | the advisory step has been run and its report shown (or attempted and skipped) | `done` | + +Notes: +- An `advisory` row is not backed by a flightcheck checkpoint (`CHECKPOINT_RESULT` + is `null`). It **never blocks** — it completes to `done` once its advisory + output has been presented to the user, regardless of what the output found. If + the advisory step can't run, note it and still complete the row (advisory rows + never hold up the setup). +- A `CHECKPOINT_RESULT` of `MANUAL` means "the checkpoint reported what it could, + but completion needs a human." It **never** maps to `done` on its own — it + requires `ACK = true`. +- For `prog` rows, `NEW_STATE` from the caller must be consistent with + `CHECKPOINT_RESULT`; if they conflict, the checkpoint result wins (it's the + objective signal). +- For a `prog` row with `RESULT_SOURCE="external"`, `PASSED` is valid only when + non-empty `EXTERNAL_EVIDENCE` is supplied. Otherwise treat the result as + `null` and leave the row `in-progress`. + +If the row is `manual`/`attest` and `ACK` is `false`, before leaving the row +`in-progress` confirm the user actually saw the manual step. (Precondition: the +manual verification steps — U.0a — for this row's checkpoint must already have been +rendered in chat. If they were not, show them now, then ask.) + +```json +[ + { + "header": "Confirm step", + "question": "Have you completed this step and is the evidence captured?", + "options": [ + { "label": "Yes, it's done", "recommended": true }, + { "label": "Not yet" } + ], + "allowFreeformInput": false + } +] +``` + +Only treat the row as acknowledged (`ACK = true`) on an explicit "Yes, it's +done". Never infer acknowledgement from a flightcheck pass. + +--- + +## U.3 — Write the item + mirror + +**Persist immediately — never batch.** Write **both** files below **now**, as part +of this call, before returning control to the caller and before the caller proceeds +to its next row. A completed row must be durable the instant its checkpoint passes, +so that if a later row in the same skill errors, the progress already made is not +lost — the orchestrator resumes from the first non-`done` row in `setupStatus`. + +1. Update the located item in `.local/setup/workday-da/tasks.md` to the state + from U.2: + - Set the checkbox marker: `- [x]` when the resulting status is `done`, + otherwise `- [ ]`. + - Set the hidden `status:` field in that item's comment to the full value + (`pending` / `in-progress` / `done` / `blocked`). + + Leave the visible title/description and every other item untouched. Do not add + any Step ID, checkpoint ID, or status text to the visible line — the checkbox is + the only at-a-glance marker the user sees. +2. Update the mirror in `.local/connect/workday-da/config.json`: + ```json + { + "setupStatus": { + "{STEP_ID}": { + "state": "", + "checkpoint": "", + "gate": "", + "verifiedBy": "", + "evidence": { + "outcome": "", + "provenance": "", + "note": "", + "capturedAt": "" + }, + "gateEvidence": { + "method": "", + "outcome": "", + "provenance": "", + "note": "", + "capturedAt": "" + } + } + } + } + ``` + Set scalar `verifiedBy` from the resulting completed state: + - `programmatic` for a completed `prog` row, + - `attested` for a completed `manual`/`attest` row, + - `reviewed` for a completed `advisory` row, + - `null` for any row that is not `done`. + + Persist `ROW_EVIDENCE` as `evidence` and `GATE_EVIDENCE` as + `gateEvidence` when supplied. Merge these fields with the existing row; + never replace `verifiedBy` with an object. When a row regresses to + `in-progress` or `blocked`, clear stale completion `verifiedBy` and + `evidence`, while retaining current failure evidence and any still-valid + `gateEvidence`. + Merge — do not drop other `setupStatus` keys (round-trip contract in + `config-schema.md`). + +Return control to the calling file. Do not announce file paths or internal +mechanics to the user. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md new file mode 100644 index 000000000..2df032d58 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md @@ -0,0 +1,187 @@ +# 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", + "evidence": { + "outcome": "PASSED", + "provenance": "flightcheck", + "note": "Required package detected", + "capturedAt": "2026-09-24T10:00:00Z" + }, + "gateEvidence": { + "method": "programmatic", + "outcome": "pass", + "provenance": "role-query", + "note": "Required role confirmed", + "capturedAt": "2026-09-24T09:59:00Z" + } + }, + "DA2.1": { "state": "pending", "checkpoint": "WD-CONN-102", "gate": "manual", "verifiedBy": null } + } +} +``` + +- `state` ∈ `pending` \| `in-progress` \| `done` \| `blocked`. +- `gate` ∈ `prog` \| `manual` \| `attest` \| `advisory` (from the DA master + checklist row). +- `verifiedBy` ∈ `programmatic` \| `attested` \| `reviewed` \| `null`. A + `manual`/`attest` row is **never** set to `done` by a flightcheck pass + alone — it needs an explicit user acknowledgement plus captured evidence + (see `shared/checklist-updater.md` and `shared/permission-gate.md`, reused + unchanged from CEA). An `advisory` row (no checkpoint) completes with + `verifiedBy: "reviewed"` once its report has been shown; it never blocks. +- `evidence` is a structured completion record with `outcome`, `provenance`, + `note`, and `capturedAt`. It records why the row reached its current state; + it never replaces scalar `verifiedBy`. +- `gateEvidence` is the optional role-gate record with `method` + (`programmatic` or `attested`), `outcome` (`pass` or `stop`), `provenance` + (`role-query` or `user-attestation`), `note`, and `capturedAt`. Gate evidence + proves authorization only; it does not by itself complete the row. + +--- + +## Power Platform integration state + +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", + "evidence": { + "outcome": "PASSED", + "provenance": "flightcheck", + "note": "Required package detected", + "capturedAt": "2026-09-24T10:00:00Z" + } + } + } +} +``` + +--- + +## 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..ad02a0a40 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/permission-gate.md @@ -0,0 +1,165 @@ +# Permission Gate (Shared) + +A reusable **role check → specific named error → stop** routine. Every Workday +DA connect skill step applies this fragment before it performs role-restricted +work, so no step duplicates inline role logic. + +Forked from the CEA `setup/shared/permission-gate.md` — the role-gating logic +is identical; only the persisted-state path differs. + +Every **Message** block is the exact text to show the user. Copy it verbatim. +Do not rephrase, add commentary, or tell the user what tools you are calling. + +**Inputs from the calling file:** +- `REQUIRED_ROLE` — the human-readable role name to require (e.g. + `"Workday Administrator"`, `"Power Platform Administrator"`, + `"Application Administrator"`). +- `GATE_MODE` — `"programmatic"` or `"attested"` (see "Choosing a mode" below). +- `STEP_ID` — the master-checklist Step ID this gate protects (e.g. `"DA3.1"`), + used only to record evidence. +- `ROLE_QUERY` — *(programmatic mode only)* the command/check that proves the + caller holds the role (the calling file supplies it; examples below). + +**Outputs to the calling file:** +- `GATE_RESULT` — `"pass"` or `"stop"`. On `"stop"`, the calling file must halt. +- `GATE_EVIDENCE` — an object recording how the gate was satisfied; the caller + passes it to `checklist-updater.md`, which merges it under + `setupStatus["{STEP_ID}"].gateEvidence` in + `.local/connect/workday-da/config.json` (see `config-schema.md`): + - `method` ∈ `"programmatic"` \| `"attested"`. + - `outcome` ∈ `"pass"` \| `"stop"`. + - `provenance` ∈ `"role-query"` \| `"user-attestation"`. + - `note` — short free text (e.g. the role-query result, or the user's + attestation timestamp/identity). + - `capturedAt` — current UTC timestamp. + +--- + +## Choosing a mode + +The gating mechanism differs by role because not every role has a queryable +directory: + +| Role family | Mode | How verified | +|-------------|------|--------------| +| Entra roles (App Admin, Cloud App Admin, Global Admin, Priv Role Admin) | `programmatic` | Microsoft Graph role / privilege query | +| Power Platform Admin | `programmatic` | Power Platform admin API | +| Dataverse maker / system roles | `programmatic` | Dataverse security-role query | +| **Workday Administrator** | `attested` | No directory here → explicit named-role attestation + captured evidence | +| **InfoSec / IT** (firewall allowlisting) | `attested` | No directory here → explicit named-role attestation + captured evidence | + +The calling file picks `GATE_MODE` from this table. **Never** silently pass an +attested role — always require the explicit confirmation in section G.2. + +--- + +## G.1 — Programmatic gate + +Use when `GATE_MODE` is `"programmatic"`. + +Run the `ROLE_QUERY` the calling file supplied. Examples of what a caller passes: + +- **Entra role (Graph):** + ``` + az rest --method GET --url "https://graph.microsoft.com/v1.0/me/memberOf/microsoft.graph.directoryRole?%24select=displayName,roleTemplateId" --query "value[].{displayName:displayName,roleTemplateId:roleTemplateId}" -o json + ``` + (OData options are percent-encoded — `%24select` not `$select` — so the URL + survives PowerShell/bash `$`-expansion and runs first-try on every shell.) + Pass only when the returned `roleTemplateId` equals one of the stable, + caller-approved built-in role template IDs. The + `microsoft.graph.directoryRole` cast excludes ordinary groups; display names + are diagnostic only and must never determine authorization. +- **Power Platform Admin / Dataverse role:** the caller supplies the specific + admin-API or Dataverse query and the expected value. + +**If the query proves the role is held:** +- Set `GATE_RESULT = "pass"`. +- Set `GATE_EVIDENCE = { "method": "programmatic", "outcome": "pass", "provenance": "role-query", "note": "", "capturedAt": "" }`. +- Return to the calling file. + +**If the query proves the role is NOT held** (or returns an +`Insufficient privileges` / `Authorization_RequestDenied` error — mirror the +existing pattern in `connect/azure/app-registration.md` section B.2): + +**Message:** + +This step requires the **{REQUIRED_ROLE}** role, and your account doesn't +have it. Ask your administrator to grant this role, then come back and run +this step again. + +**End message.** + +- Set `GATE_RESULT = "stop"`. +- Return to the calling file. **The caller must halt — do not proceed.** + +**If the query itself fails** for an unrelated reason (network, not logged in): +retry once. If it still fails, **fail closed**: + +**Message:** + +I couldn't verify the **{REQUIRED_ROLE}** role, so I can't safely continue this +step. Sign in again or ask a verified administrator to run it, then retry. + +**End message.** + +- Set `GATE_RESULT = "stop"`. +- Set `GATE_EVIDENCE` with `method: "programmatic"`, `outcome: "stop"`, + `provenance: "role-query"`, the query error in `note`, and the current UTC + timestamp in `capturedAt`. +- Return to the calling file. Never downgrade a programmatic privileged-role + gate to self-attestation. + +--- + +## G.2 — Attestation gate + +Use only when `GATE_MODE` is `"attested"` (Workday Administrator, InfoSec/IT). +Programmatic privileged-role checks never fall back to this section. + +**Message:** + +This step requires the **{REQUIRED_ROLE}** role. I can't verify that +automatically for this system, so I need you to confirm you (or the person +doing this step) hold that role before we continue. + +**End message.** + +Use the `vscode_askQuestions` tool: + +```json +[ + { + "header": "Confirm role", + "question": "Do you have the {REQUIRED_ROLE} role to perform this step?", + "options": [ + { "label": "Yes, I have this role", "recommended": true }, + { "label": "No / not sure" } + ], + "allowFreeformInput": false + } +] +``` + +**If the user chose "Yes, I have this role":** +- Set `GATE_RESULT = "pass"`. +- Set `GATE_EVIDENCE = { "method": "attested", "outcome": "pass", "provenance": "user-attestation", "note": "user attested {REQUIRED_ROLE} for {STEP_ID}", "capturedAt": "" }`. +- Return to the calling file. + +**If the user chose "No / not sure":** + +**Message:** + +No problem — this step needs the **{REQUIRED_ROLE}** role. Ask whoever holds +that role to run it, then come back and continue. + +**End message.** + +- Set `GATE_RESULT = "stop"`. +- Set `GATE_EVIDENCE` with `method: "attested"`, `outcome: "stop"`, + `provenance: "user-attestation"`, a safe note, and the current UTC timestamp. +- Return to the calling file. **The caller must halt — do not proceed.** + +> An attested `"pass"` records that the role was **claimed**, not directory-proven. +> It satisfies the *gate*, but it does **not** by itself complete the checklist row +> — the row still needs its own captured evidence/acknowledgement per +> `checklist-updater.md`. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/tasks.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/tasks.md new file mode 100644 index 000000000..65fcd4f0f --- /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. + +- [ ] **Verify employee SAML sign-in policy** — Confirm an active Workday authentication rule allows SAML for the intended employees, or have the Workday administrator review and activate the required change. + +- [ ] **Match the signing certificate** — Confirm the Workday-side signing certificate matches the one in Entra (validity dates, or an externally-computed SHA-1 — Workday shows no thumbprint). + + +### 4. Power Platform and agent integration + +- [ ] **Create the Workday connection** — Create the signed-in employee Workday connection with the captured Workday endpoints. + +- [ ] **Create the Microsoft Dataverse connection** — Create or select an active Dataverse connection owned by the maker in this environment. + +- [ ] **Bind the extension connections** — Attach the Workday and Dataverse connections to the installed Workday runtime references. + +- [ ] **Turn on the Workday cloud flows** — Enable every Workday runtime flow after its connections are bound. + +- [ ] **Connect Workday to the agent** — Connect each Workday flow in Copilot Studio and allow it to share the connection parameters used for signed-in employee access. + +- [ ] **Authorize the agent to use the Workday flows** — Preview and apply the delegated authorization and workflow sharing required by the ESS 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/tests/flightcheck/checks/test_workday_tenant.py b/tests/flightcheck/checks/test_workday_tenant.py index 18bf70cc6..3a6246eef 100644 --- a/tests/flightcheck/checks/test_workday_tenant.py +++ b/tests/flightcheck/checks/test_workday_tenant.py @@ -98,7 +98,7 @@ def test_api_client_echoes_client_id_and_token_endpoint(self): # Remediation names the Workday screens and the ordering rule. assert "Register API Client" in api.remediation assert "View API Client" in api.remediation - assert "BEFORE" in api.remediation + assert "signed-in employee setup" in api.remediation def test_tenant_echoes_connection_fields(self): by_id = _by_id( @@ -114,7 +114,19 @@ def test_tenant_echoes_connection_fields(self): assert value in tenant.result assert "Service Provider ID" in tenant.result assert "Tenant Setup - Security" in tenant.remediation - assert "Activate All Pending" in tenant.remediation + assert "intended employees" in tenant.remediation + assert "Do not invent an OAuth-client condition" in tenant.remediation + assert "no policy change or activation is required" in tenant.result + + def test_api_client_rejects_legacy_isu_guidance_for_signed_in_setup(self): + by_id = _by_id( + run_workday_tenant_checks(_MinimalRunner(config=_FULL_CONFIG)) + ) + api = by_id["WD-API-CLIENT-001"] + + assert "does not use an ISU" in api.result + assert "RaaS" in api.result + assert "integration-system security-group domain mapping" in api.result class TestConfigAbsentStaysManual: diff --git a/tests/setup/test_da_setup_router.py b/tests/setup/test_da_setup_router.py index ef3582735..a7ff74495 100644 --- a/tests/setup/test_da_setup_router.py +++ b/tests/setup/test_da_setup_router.py @@ -321,11 +321,11 @@ def test_maker_profile_requires_only_canonical_completion() -> None: assert "configPattern" not in text -def test_workday_routing_remains_separate() -> None: +def test_incomplete_workday_da_setup_remains_unrouted() -> 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" not 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 diff --git a/tests/setup/test_workday_da_foundation.py b/tests/setup/test_workday_da_foundation.py new file mode 100644 index 000000000..4aa8d974f --- /dev/null +++ b/tests/setup/test_workday_da_foundation.py @@ -0,0 +1,120 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Contracts for the resumable Workday DA setup foundation.""" + +from pathlib import Path +import re + + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_WORKDAY_DA = ( + _REPO_ROOT + / "solutions" + / "ess-maker-skills" + / "src" + / "skills" + / "setup" + / "workday-da" +) +_SHARED = _WORKDAY_DA / "shared" + + +def test_checklist_has_the_complete_unique_step_set() -> None: + tasks = (_WORKDAY_DA / "tasks.md").read_text(encoding="utf-8") + step_ids = re.findall(r"") == len(expected) + + +def test_checklist_uses_readable_titles_without_visible_internal_ids() -> None: + tasks = (_WORKDAY_DA / "tasks.md").read_text(encoding="utf-8") + visible_rows = [ + line for line in tasks.splitlines() if line.startswith("- [ ] **") + ] + + assert len(visible_rows) == 21 + assert all(not re.search(r"\bDA\d", line) for line in visible_rows) + assert any("Connect Microsoft Entra sign-in to Workday" in line for line in tasks.splitlines()) + assert any("Match the signed-in employee" in line for line in visible_rows) + + +def test_state_contract_requires_immediate_durable_updates() -> None: + updater = (_SHARED / "checklist-updater.md").read_text(encoding="utf-8") + schema = (_SHARED / "config-schema.md").read_text(encoding="utf-8") + + assert "A `MANUAL` or attestation-gated row is never" in updater + assert "**Persist immediately — never batch.**" in updater + assert ".local/setup/workday-da/tasks.md" in updater + assert ".local/connect/workday-da/config.json" in updater + assert "Read" in schema and "Merge" in schema and "Write" in schema + assert "sidecarDataverseEndpoint" in schema + assert '"gateEvidence"' in schema + assert '"provenance"' in schema + assert '`reviewed`' in updater + assert "FAILED` or `ERROR` always produces `blocked`" in updater + + +def test_entra_setup_pins_tenant_and_exact_app_identity() -> None: + entra = (_WORKDAY_DA / "provision-entra-app.md").read_text(encoding="utf-8") + gate = (_SHARED / "permission-gate.md").read_text(encoding="utf-8") + + assert "az account show --query tenantId -o tsv" in entra + assert '--tenant "{SETUP_TENANT_ID}"' in entra + assert "normalized **exact equality**" in entra + assert "contains(@, 'workday.com/{tenant}')" not in entra + assert "microsoft.graph.directoryRole" in entra + assert "roleTemplateId" in entra + assert "9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3" in entra + assert "never auto-select by display name" in entra + assert "Never downgrade a programmatic privileged-role" in gate + assert "user_impersonation" in entra + assert "claimsMappingPolicy" in entra + + +def test_workday_tenant_setup_preserves_manual_gates_and_safe_order() -> None: + tasks = (_WORKDAY_DA / "tasks.md").read_text(encoding="utf-8") + tenant = (_WORKDAY_DA / "configure-tenant.md").read_text(encoding="utf-8") + + register = tenant.index("## DA3.1 + DA3.2 — Register the API client") + policy = tenant.index("## DA3.3 — Verify the signed-in employee authentication policy") + + assert register < policy + assert "Single-tenant SAML pre-gate" in tenant + assert "CHECKPOINT_RESULT=\"MANUAL\"" in tenant + assert "ACK=true" in tenant + assert "Workday cert field is not API-reachable" in tenant + assert "checkpoints: WD-CONN-102 | gate: manual" in tasks + assert "checkpoints: WD-API-CLIENT-001 | gate: attest" in tasks + assert ( + "| DA3.2 | `WD-API-CLIENT-001` — Workday connection fields captured" + in tenant + ) + assert "There is no separate domain-to-integration-security-group" in tenant + assert "Do not look for an OAuth-client restriction" in tenant + assert "Existing active policy already allows employee SAML" in tenant + + +def test_workday_portal_tasks_start_only_after_the_admin_gate() -> None: + entra = (_WORKDAY_DA / "provision-entra-app.md").read_text(encoding="utf-8") + tenant = (_WORKDAY_DA / "configure-tenant.md").read_text(encoding="utf-8") + normalized_entra = " ".join(entra.split()) + + assert "Workday-side issuer, service-provider ID, and certificate" in normalized_entra + assert ( + "happens in the next phase, after the Workday-administrator gate" + in normalized_entra + ) + assert "Do not ask the maker to open Workday" in entra + assert tenant.index("## DA3.0 — Workday administrator gate") < tenant.index( + "## DA3.0b — Single-tenant SAML pre-gate" + )