From 61ef76271f34f1cf0372eb23d46cecc35e6b7d8d Mon Sep 17 00:00:00 2001 From: is-goutham Date: Fri, 25 Sep 2026 22:37:44 -0700 Subject: [PATCH 01/20] Establish simplified Workday lifecycle model --- .../scripts/workday_connect.py | 201 +++++++ .../scripts/workday_connect_catalog.json | 43 ++ .../scripts/workday_connect_model.py | 386 ++++++++++++++ .../scripts/workday_connect_store.py | 504 ++++++++++++++++++ tests/scripts/test_workday_connect_model.py | 78 +++ tests/scripts/test_workday_connect_store.py | 148 +++++ 6 files changed, 1360 insertions(+) create mode 100644 solutions/ess-maker-skills/scripts/workday_connect.py create mode 100644 solutions/ess-maker-skills/scripts/workday_connect_catalog.json create mode 100644 solutions/ess-maker-skills/scripts/workday_connect_model.py create mode 100644 solutions/ess-maker-skills/scripts/workday_connect_store.py create mode 100644 tests/scripts/test_workday_connect_model.py create mode 100644 tests/scripts/test_workday_connect_store.py diff --git a/solutions/ess-maker-skills/scripts/workday_connect.py b/solutions/ess-maker-skills/scripts/workday_connect.py new file mode 100644 index 00000000..2005e85d --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_connect.py @@ -0,0 +1,201 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Deterministic controller for the six-phase Workday connect lifecycle.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys +from typing import Any + +from workday_connect_model import ( + CONTROLLER_CONTRACT_VERSION, + WorkdayConnectModelError, + plan_hash, +) +from workday_connect_store import ( + WorkdayConnectPlanChangedError, + WorkdayConnectStore, + WorkdayConnectStoreError, +) + + +RESULT_MARKER = "WORKDAY_CONNECT_RESULT_JSON:" +ERROR_MARKER = "WORKDAY_CONNECT_ERROR_JSON:" + + +def _json_object(value: str, label: str) -> dict[str, Any]: + try: + document = json.loads(value) + except json.JSONDecodeError as exc: + raise WorkdayConnectStoreError( + f"{label} must be valid JSON: {exc}" + ) from exc + if not isinstance(document, dict): + raise WorkdayConnectStoreError(f"{label} must be a JSON object.") + return document + + +def _emit(operation: str, result: dict[str, Any]) -> None: + print( + RESULT_MARKER + + json.dumps( + { + "contractVersion": CONTROLLER_CONTRACT_VERSION, + "operation": operation, + **result, + }, + sort_keys=True, + ) + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Manage the Workday connect lifecycle." + ) + parser.add_argument( + "--root", + default=".", + help="Workspace root containing .local state.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("initialize") + subparsers.add_parser("status") + + merge = subparsers.add_parser("merge-section") + merge.add_argument( + "--section", + required=True, + choices=["scope", "identifiers", "endpoints", "operators"], + ) + merge.add_argument("--json", required=True) + + phase_status = subparsers.add_parser("set-phase-status") + phase_status.add_argument("--phase", required=True) + phase_status.add_argument("--status", required=True) + phase_status.add_argument("--blocker-json") + + complete = subparsers.add_parser("complete-action") + complete.add_argument("--phase", required=True) + complete.add_argument("--action", required=True) + complete.add_argument("--evidence-json") + + handoff = subparsers.add_parser("record-handoff") + handoff.add_argument("--phase", required=True) + handoff.add_argument("--json", required=True) + + approve = subparsers.add_parser("approve-plan") + approve.add_argument("--phase", required=True) + approve.add_argument("--plan-json", required=True) + + verify = subparsers.add_parser("verify-plan") + verify.add_argument("--phase", required=True) + verify.add_argument("--plan-json", required=True) + verify.add_argument("--plan-hash", required=True) + + calculate = subparsers.add_parser("plan-hash") + calculate.add_argument("--plan-json", required=True) + return parser + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + store = WorkdayConnectStore(Path(args.root)) + try: + if args.command == "initialize": + state = store.initialize() + _emit("initialize", {"state": state, "status": store.status()}) + elif args.command == "status": + _emit("status", store.status()) + elif args.command == "merge-section": + state = store.merge_section( + args.section, + _json_object(args.json, "section data"), + ) + _emit("merge-section", {"state": state}) + elif args.command == "set-phase-status": + blocker = ( + _json_object(args.blocker_json, "blocker") + if args.blocker_json + else None + ) + state = store.set_phase_status( + args.phase, + args.status, + blocker=blocker, + ) + _emit("set-phase-status", {"state": state}) + elif args.command == "complete-action": + evidence = ( + _json_object(args.evidence_json, "evidence") + if args.evidence_json + else None + ) + state = store.complete_action( + args.phase, + args.action, + evidence=evidence, + ) + _emit("complete-action", {"state": state}) + elif args.command == "record-handoff": + state = store.record_handoff( + args.phase, + _json_object(args.json, "handoff"), + ) + _emit("record-handoff", {"state": state}) + elif args.command == "approve-plan": + state, approved_hash = store.approve_plan( + args.phase, + _json_object(args.plan_json, "plan"), + ) + _emit( + "approve-plan", + {"state": state, "planHash": approved_hash}, + ) + elif args.command == "verify-plan": + verified_hash = store.verify_plan( + args.phase, + _json_object(args.plan_json, "plan"), + args.plan_hash, + ) + _emit("verify-plan", {"planHash": verified_hash, "verified": True}) + elif args.command == "plan-hash": + _emit( + "plan-hash", + { + "planHash": plan_hash( + _json_object(args.plan_json, "plan") + ) + }, + ) + else: + parser.error(f"Unsupported command: {args.command}") + except ( + OSError, + WorkdayConnectModelError, + WorkdayConnectPlanChangedError, + WorkdayConnectStoreError, + ) as exc: + print( + ERROR_MARKER + + json.dumps( + { + "contractVersion": CONTROLLER_CONTRACT_VERSION, + "operation": args.command, + "error": str(exc), + "errorType": type(exc).__name__, + }, + sort_keys=True, + ), + file=sys.stderr, + ) + raise SystemExit(1) from exc + + +if __name__ == "__main__": + main() diff --git a/solutions/ess-maker-skills/scripts/workday_connect_catalog.json b/solutions/ess-maker-skills/scripts/workday_connect_catalog.json new file mode 100644 index 00000000..e102dd01 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_connect_catalog.json @@ -0,0 +1,43 @@ +{ + "catalogVersion": 1, + "provider": "workday", + "supportedAgents": { + "gptagent_copilotforemployeeselfservicehr": { + "architecture": "native-da", + "packageFlavor": "runtime" + }, + "msdyn_copilotforemployeeselfservicedahr": { + "architecture": "classic-da", + "packageFlavor": "legacy-da" + } + }, + "unsupportedAgents": [ + "gptagent_copilotforemployeeselfserviceit", + "msdyn_copilotforemployeeselfservicedait" + ], + "packages": { + "runtime": { + "applicationName": "msdyn_EssWorkdayRuntime", + "solutionSchemaName": "msdyn_EssWorkdayRuntime", + "flowNames": [ + "ESS Workday Runtime References", + "ESS Workday Runtime REST Execution", + "ESS Workday Runtime" + ] + }, + "legacy-da": { + "applicationName": "msdyn_EssDAHRWorkdayHCM", + "solutionSchemaName": "msdyn_EssDAHRWorkday" + } + }, + "connectionReferences": { + "workday": { + "logicalName": "msdyn_sharedworkdaysoap_workdayruntime", + "connectorName": "shared_workdaysoap" + }, + "dataverse": { + "logicalName": "msdyn_sharedcommondataserviceforapps_workdayruntime", + "connectorName": "shared_commondataserviceforapps" + } + } +} diff --git a/solutions/ess-maker-skills/scripts/workday_connect_model.py b/solutions/ess-maker-skills/scripts/workday_connect_model.py new file mode 100644 index 00000000..95d80120 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_connect_model.py @@ -0,0 +1,386 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Typed lifecycle contracts for the Workday connect skill.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from enum import Enum +import hashlib +import json +from pathlib import Path +import re +from typing import Any, Mapping + + +STATE_SCHEMA_VERSION = 2 +CONTROLLER_CONTRACT_VERSION = 1 +CATALOG_PATH = Path(__file__).with_name("workday_connect_catalog.json") + + +class WorkdayConnectModelError(ValueError): + """Raised when lifecycle data violates the Workday connect contract.""" + + +class Phase(str, Enum): + PREFLIGHT = "preflight" + ENTRA = "entra" + WORKDAY_ADMIN = "workday-admin" + CONNECTIONS = "connections" + RUNTIME = "runtime" + EMPLOYEE_VALIDATION = "employee-validation" + + +class PhaseStatus(str, Enum): + PENDING = "pending" + ACTIVE = "active" + WAITING = "waiting" + BLOCKED = "blocked" + COMPLETE = "complete" + + +@dataclass(frozen=True) +class PhaseDefinition: + identifier: Phase + title: str + short_title: str + prerequisite: Phase | None + + +PHASE_DEFINITIONS = ( + PhaseDefinition(Phase.PREFLIGHT, "Preflight", "Preflight", None), + PhaseDefinition( + Phase.ENTRA, + "Microsoft Entra", + "Entra", + Phase.PREFLIGHT, + ), + PhaseDefinition( + Phase.WORKDAY_ADMIN, + "Workday administrator", + "Workday", + Phase.ENTRA, + ), + PhaseDefinition( + Phase.CONNECTIONS, + "Connections", + "Connections", + Phase.WORKDAY_ADMIN, + ), + PhaseDefinition( + Phase.RUNTIME, + "Runtime configuration", + "Runtime", + Phase.CONNECTIONS, + ), + PhaseDefinition( + Phase.EMPLOYEE_VALIDATION, + "Employee validation", + "Validate", + Phase.RUNTIME, + ), +) +PHASE_BY_ID = { + definition.identifier.value: definition + for definition in PHASE_DEFINITIONS +} + +LEGACY_PHASE_ROWS = { + Phase.PREFLIGHT: ("DA1.1",), + Phase.ENTRA: ( + "DA2.1", + "DA2.2", + "DA2.3", + "DA2.4", + "DA2.5", + "DA2.6", + "DA2.7", + ), + Phase.WORKDAY_ADMIN: ("DA3.1", "DA3.2", "DA3.3", "DA3.4"), + Phase.CONNECTIONS: ("DA4.1", "DA4.2"), + Phase.RUNTIME: ( + "DA4.3", + "DA4.4", + "DA4.5", + "DA4.6", + "DA4.7", + "DA4.8", + ), + Phase.EMPLOYEE_VALIDATION: ("DA5.1",), +} + +_TENANT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") +_SECRET_KEYS = { + "password", + "clientsecret", + "access_token", + "accesstoken", + "refresh_token", + "refreshtoken", + "cookie", + "certificatebody", + "privatekey", +} + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def load_catalog(path: Path = CATALOG_PATH) -> dict[str, Any]: + try: + catalog = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise WorkdayConnectModelError( + f"Workday connect catalog could not be loaded: {exc}" + ) from exc + if not isinstance(catalog, dict): + raise WorkdayConnectModelError( + "Workday connect catalog must contain a JSON object." + ) + required = { + "catalogVersion", + "provider", + "supportedAgents", + "unsupportedAgents", + "packages", + "connectionReferences", + } + missing = sorted(required - catalog.keys()) + if missing: + raise WorkdayConnectModelError( + "Workday connect catalog is missing: " + ", ".join(missing) + ) + if catalog["provider"] != "workday": + raise WorkdayConnectModelError( + "Workday connect catalog provider must be 'workday'." + ) + return catalog + + +def workday_saml_entity_id(tenant: str) -> str: + normalized = str(tenant or "").strip() + if not _TENANT_RE.fullmatch(normalized): + raise WorkdayConnectModelError( + "Workday tenant must contain only letters, numbers, hyphens, " + "and underscores." + ) + return f"http://www.workday.com/{normalized}" + + +def canonical_plan(plan: Mapping[str, Any]) -> dict[str, Any]: + if not isinstance(plan, Mapping): + raise WorkdayConnectModelError("A Workday change plan must be an object.") + document = dict(plan) + document.pop("planHash", None) + reject_sensitive_data(document) + phase = str(document.get("phase") or "") + if phase not in PHASE_BY_ID: + raise WorkdayConnectModelError(f"Unknown Workday phase: {phase}.") + actions = document.get("actions") + if not isinstance(actions, list) or not actions: + raise WorkdayConnectModelError( + "A Workday change plan must contain at least one action." + ) + if any(not isinstance(action, str) or not action.strip() for action in actions): + raise WorkdayConnectModelError( + "Workday change-plan actions must be non-empty strings." + ) + scope = document.get("scope") + if not isinstance(scope, dict) or not scope: + raise WorkdayConnectModelError( + "A Workday change plan must contain an exact scope." + ) + return document + + +def plan_hash(plan: Mapping[str, Any]) -> str: + encoded = json.dumps( + canonical_plan(plan), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def scope_hash(scope: Mapping[str, Any]) -> str: + if not isinstance(scope, Mapping): + raise WorkdayConnectModelError("Workday scope must be an object.") + reject_sensitive_data(scope) + encoded = json.dumps( + scope, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def reject_sensitive_data(document: Any, path: str = "state") -> None: + if isinstance(document, dict): + for key, value in document.items(): + normalized = str(key).replace("-", "").replace("_", "").casefold() + if normalized in _SECRET_KEYS: + raise WorkdayConnectModelError( + f"Sensitive field '{path}.{key}' must not be persisted." + ) + reject_sensitive_data(value, f"{path}.{key}") + elif isinstance(document, list): + for index, value in enumerate(document): + reject_sensitive_data(value, f"{path}[{index}]") + + +def default_phase_state() -> dict[str, Any]: + return { + "status": PhaseStatus.PENDING.value, + "scopeHash": None, + "completedActions": [], + "approvedPlanHash": None, + "approvedPlan": None, + "manualHandoff": None, + "evidence": [], + "blocker": None, + "updatedAt": None, + } + + +def default_state() -> dict[str, Any]: + return { + "schemaVersion": STATE_SCHEMA_VERSION, + "provider": "workday", + "status": "in-progress", + "scope": {}, + "identifiers": {}, + "endpoints": {}, + "operators": {}, + "phases": { + definition.identifier.value: default_phase_state() + for definition in PHASE_DEFINITIONS + }, + "migration": None, + "updatedAt": utc_now(), + } + + +def _validate_phase_state(phase_id: str, value: Any) -> None: + if not isinstance(value, dict): + raise WorkdayConnectModelError( + f"Phase '{phase_id}' state must be an object." + ) + required = { + "status", + "scopeHash", + "completedActions", + "approvedPlanHash", + "approvedPlan", + "manualHandoff", + "evidence", + "blocker", + "updatedAt", + } + missing = sorted(required - value.keys()) + if missing: + raise WorkdayConnectModelError( + f"Phase '{phase_id}' is missing: " + ", ".join(missing) + ) + if value["status"] not in {status.value for status in PhaseStatus}: + raise WorkdayConnectModelError( + f"Phase '{phase_id}' has invalid status '{value['status']}'." + ) + if not isinstance(value["completedActions"], list) or any( + not isinstance(action, str) or not action + for action in value["completedActions"] + ): + raise WorkdayConnectModelError( + f"Phase '{phase_id}' completedActions must contain strings." + ) + if len(value["completedActions"]) != len(set(value["completedActions"])): + raise WorkdayConnectModelError( + f"Phase '{phase_id}' completedActions contains duplicates." + ) + if not isinstance(value["evidence"], list): + raise WorkdayConnectModelError( + f"Phase '{phase_id}' evidence must be an array." + ) + approved_plan = value["approvedPlan"] + approved_hash = value["approvedPlanHash"] + if approved_plan is not None: + observed_hash = plan_hash(approved_plan) + if approved_hash != observed_hash: + raise WorkdayConnectModelError( + f"Phase '{phase_id}' approved plan hash does not match." + ) + + +def validate_state(state: Any) -> dict[str, Any]: + if not isinstance(state, dict): + raise WorkdayConnectModelError( + "Workday connect state must contain a JSON object." + ) + reject_sensitive_data(state) + if state.get("schemaVersion") != STATE_SCHEMA_VERSION: + raise WorkdayConnectModelError( + "Unsupported Workday connect state schema version: " + f"{state.get('schemaVersion')!r}." + ) + if state.get("provider") != "workday": + raise WorkdayConnectModelError( + "Workday connect state provider must be 'workday'." + ) + for field in ("scope", "identifiers", "endpoints", "operators", "phases"): + if not isinstance(state.get(field), dict): + raise WorkdayConnectModelError( + f"Workday connect state '{field}' must be an object." + ) + phases = state["phases"] + if set(phases) != set(PHASE_BY_ID): + raise WorkdayConnectModelError( + "Workday connect state must contain exactly the six phases." + ) + for phase_id, value in phases.items(): + _validate_phase_state(phase_id, value) + expected_status = ( + "ready" + if all( + phase["status"] == PhaseStatus.COMPLETE.value + for phase in phases.values() + ) + else "in-progress" + ) + if state.get("status") != expected_status: + raise WorkdayConnectModelError( + f"Workday connect status must be '{expected_status}'." + ) + return state + + +def next_phase_id(state: Mapping[str, Any]) -> str | None: + phases = state["phases"] + for definition in PHASE_DEFINITIONS: + if ( + phases[definition.identifier.value]["status"] + != PhaseStatus.COMPLETE.value + ): + return definition.identifier.value + return None + + +def progress_text(state: Mapping[str, Any]) -> str: + markers = { + PhaseStatus.PENDING.value: "", + PhaseStatus.ACTIVE.value: "→", + PhaseStatus.WAITING.value: "…", + PhaseStatus.BLOCKED.value: "!", + PhaseStatus.COMPLETE.value: "✓", + } + parts = [] + for definition in PHASE_DEFINITIONS: + status = state["phases"][definition.identifier.value]["status"] + marker = markers[status] + parts.append( + f"{definition.short_title}{f' {marker}' if marker else ''}" + ) + return "Progress: " + " · ".join(parts) diff --git a/solutions/ess-maker-skills/scripts/workday_connect_store.py b/solutions/ess-maker-skills/scripts/workday_connect_store.py new file mode 100644 index 00000000..58b1c0ed --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_connect_store.py @@ -0,0 +1,504 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Atomic persisted-state operations for the Workday connect lifecycle.""" + +from __future__ import annotations + +from contextlib import contextmanager +import copy +import json +import os +from pathlib import Path +import shutil +import tempfile +import time +from typing import Any, Iterator, Mapping + +from workday_connect_model import ( + LEGACY_PHASE_ROWS, + PHASE_BY_ID, + PHASE_DEFINITIONS, + PhaseStatus, + WorkdayConnectModelError, + default_state, + plan_hash, + progress_text, + scope_hash, + utc_now, + validate_state, + workday_saml_entity_id, +) + + +CONFIG_PATH = Path(".local/connect/workday-da/config.json") + + +class WorkdayConnectStoreError(RuntimeError): + """Raised when Workday connect state cannot be persisted safely.""" + + +class WorkdayConnectPlanChangedError(WorkdayConnectStoreError): + """Raised when an approved plan no longer matches the current plan.""" + + +def _read_json(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise WorkdayConnectStoreError( + f"Workday connect state could not be read: {path}: {exc}" + ) from exc + if not isinstance(document, dict): + raise WorkdayConnectStoreError( + f"Workday connect state must contain an object: {path}" + ) + return document + + +def _atomic_write_json(path: Path, document: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", + suffix=".tmp", + dir=path.parent, + ) + temporary_path = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream: + json.dump(document, stream, indent=2, sort_keys=True) + stream.write("\n") + stream.flush() + try: + os.fsync(stream.fileno()) + except OSError: + pass + os.replace(temporary_path, path) + finally: + temporary_path.unlink(missing_ok=True) + + +@contextmanager +def _file_lock(path: Path, timeout: float) -> Iterator[None]: + path.parent.mkdir(parents=True, exist_ok=True) + stream = path.open("a+b") + stream.seek(0, os.SEEK_END) + if stream.tell() == 0: + stream.write(b"\0") + stream.flush() + deadline = time.monotonic() + timeout + locked = False + try: + while not locked: + try: + stream.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + locked = True + except (OSError, BlockingIOError): + if time.monotonic() >= deadline: + raise WorkdayConnectStoreError( + "Another /connect workday session is updating state. " + "Wait for it to finish, then retry." + ) + time.sleep(0.05) + yield + finally: + if locked: + stream.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(stream.fileno(), fcntl.LOCK_UN) + stream.close() + + +def _legacy_phase_status( + setup_status: Mapping[str, Any], + rows: tuple[str, ...], +) -> str: + values = [ + setup_status.get(row) + for row in rows + if isinstance(setup_status.get(row), dict) + ] + statuses = {str(value.get("state") or "pending") for value in values} + if values and len(values) == len(rows) and statuses == {"done"}: + return PhaseStatus.COMPLETE.value + if "blocked" in statuses: + return PhaseStatus.BLOCKED.value + if statuses & {"done", "in-progress"}: + return PhaseStatus.ACTIVE.value + return PhaseStatus.PENDING.value + + +def _legacy_evidence( + setup_status: Mapping[str, Any], + rows: tuple[str, ...], +) -> list[dict[str, Any]]: + evidence = [] + for row in rows: + value = setup_status.get(row) + if not isinstance(value, dict) or value.get("state") != "done": + continue + record = { + "source": "legacy-state", + "action": f"legacy:{row}", + "verifiedBy": value.get("verifiedBy"), + } + legacy_evidence = value.get("evidence") + if isinstance(legacy_evidence, dict): + for key in ("outcome", "provenance", "capturedAt"): + if legacy_evidence.get(key) is not None: + record[key] = legacy_evidence[key] + evidence.append(record) + return evidence + + +def migrate_legacy_state(document: Mapping[str, Any]) -> dict[str, Any]: + state = default_state() + setup_status = document.get("setupStatus") + if not isinstance(setup_status, dict): + setup_status = {} + + scope = state["scope"] + if document.get("sidecarDataverseEndpoint"): + scope["dataverseUrl"] = document["sidecarDataverseEndpoint"] + if document.get("tenantId"): + scope["entraTenantId"] = document["tenantId"] + if document.get("tenant"): + scope["workdayTenant"] = document["tenant"] + if document.get("vertical"): + scope["vertical"] = document["vertical"] + if document.get("packageFlavor"): + scope["packageFlavor"] = document["packageFlavor"] + active_agent = document.get("activeAgent") + if isinstance(active_agent, dict): + scope["agent"] = { + key: active_agent[key] + for key in ("slug", "botId", "schemaName") + if active_agent.get(key) + } + + identifiers = state["identifiers"] + field_map = { + "entraAppId": "entraAppId", + "entraAppObjectId": "entraAppObjectId", + "scopeGuid": "scopeGuid", + "oauthClientId": "oauthClientId", + "entraSSO": "entraSSO", + } + for legacy, current in field_map.items(): + if document.get(legacy) is not None: + identifiers[current] = document[legacy] + app_id_uri = document.get("entraAppIdUri") or document.get("appIdUri") + if app_id_uri: + identifiers["entraAppIdUri"] = app_id_uri + if scope.get("workdayTenant"): + try: + identifiers["workdaySamlEntityId"] = workday_saml_entity_id( + scope["workdayTenant"] + ) + except WorkdayConnectModelError: + pass + + endpoints = state["endpoints"] + endpoint_map = { + "baseUrl": "workdayBaseUrl", + "tokenHost": "tokenHost", + "oauthTokenUrl": "oauthTokenUrl", + "tokenEndpoint": "oauthTokenUrl", + "restBaseUrl": "restBaseUrl", + "soapBaseUrl": "soapBaseUrl", + "domainName": "domainName", + } + for legacy, current in endpoint_map.items(): + if document.get(legacy) and current not in endpoints: + endpoints[current] = document[legacy] + + operator_map = { + "entraAdminUsername": ("entraAdmin", "username"), + "makerUsername": ("powerPlatformMaker", "username"), + } + for legacy, (operator, field) in operator_map.items(): + if document.get(legacy): + state["operators"].setdefault(operator, {})[field] = document[legacy] + + for phase, rows in LEGACY_PHASE_ROWS.items(): + phase_state = state["phases"][phase.value] + phase_state["status"] = _legacy_phase_status(setup_status, rows) + phase_state["completedActions"] = [ + f"legacy:{row}" + for row in rows + if isinstance(setup_status.get(row), dict) + and setup_status[row].get("state") == "done" + ] + phase_state["evidence"] = _legacy_evidence(setup_status, rows) + if phase_state["status"] != PhaseStatus.PENDING.value: + phase_state["updatedAt"] = utc_now() + + if all( + phase["status"] == PhaseStatus.COMPLETE.value + for phase in state["phases"].values() + ): + state["status"] = "ready" + state["migration"] = { + "source": ( + "workday-da-state-v1" + if document.get("stateSchemaVersion") + else "legacy-workday-da-config" + ), + "migratedAt": utc_now(), + } + state["updatedAt"] = utc_now() + return validate_state(state) + + +class WorkdayConnectStore: + """Own the single durable Workday connect state file.""" + + def __init__( + self, + workspace_root: Path, + *, + lock_timeout: float = 5.0, + ) -> None: + self.workspace_root = workspace_root.resolve() + self.config_path = self.workspace_root / CONFIG_PATH + self.lock_path = self.config_path.with_name("state.lock") + self.backup_path = self.config_path.with_name("config.pre-v2.json") + self.lock_timeout = lock_timeout + + def initialize(self) -> dict[str, Any]: + with _file_lock(self.lock_path, self.lock_timeout): + existing = _read_json(self.config_path) + if not existing: + state = default_state() + _atomic_write_json(self.config_path, state) + return state + if existing.get("schemaVersion") == 2: + return validate_state(existing) + if not self.backup_path.exists(): + self.backup_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(self.config_path, self.backup_path) + state = migrate_legacy_state(existing) + _atomic_write_json(self.config_path, state) + return state + + def load(self) -> dict[str, Any]: + if not self.config_path.exists(): + return self.initialize() + return validate_state(_read_json(self.config_path)) + + def _mutate(self, mutation) -> dict[str, Any]: + with _file_lock(self.lock_path, self.lock_timeout): + current = _read_json(self.config_path) + if not current: + current = default_state() + elif current.get("schemaVersion") != 2: + if not self.backup_path.exists(): + shutil.copy2(self.config_path, self.backup_path) + current = migrate_legacy_state(current) + state = copy.deepcopy(validate_state(current)) + mutation(state) + state["status"] = ( + "ready" + if all( + phase["status"] == PhaseStatus.COMPLETE.value + for phase in state["phases"].values() + ) + else "in-progress" + ) + state["updatedAt"] = utc_now() + validate_state(state) + _atomic_write_json(self.config_path, state) + return state + + def merge_section( + self, + section: str, + values: Mapping[str, Any], + ) -> dict[str, Any]: + if section not in {"scope", "identifiers", "endpoints", "operators"}: + raise WorkdayConnectStoreError( + f"Unsupported Workday state section: {section}." + ) + if not isinstance(values, Mapping): + raise WorkdayConnectStoreError( + f"Workday state section '{section}' must be an object." + ) + + def mutation(state: dict[str, Any]) -> None: + state[section].update(dict(values)) + + return self._mutate(mutation) + + def set_phase_status( + self, + phase_id: str, + status: str, + *, + blocker: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: + if phase_id not in PHASE_BY_ID: + raise WorkdayConnectStoreError(f"Unknown Workday phase: {phase_id}.") + if status not in {value.value for value in PhaseStatus}: + raise WorkdayConnectStoreError( + f"Unknown Workday phase status: {status}." + ) + + def mutation(state: dict[str, Any]) -> None: + definition = PHASE_BY_ID[phase_id] + prerequisite = definition.prerequisite + if status == PhaseStatus.COMPLETE.value and prerequisite: + prerequisite_status = state["phases"][prerequisite.value]["status"] + if prerequisite_status != PhaseStatus.COMPLETE.value: + raise WorkdayConnectStoreError( + f"Complete '{prerequisite.value}' before '{phase_id}'." + ) + phase = state["phases"][phase_id] + phase["status"] = status + phase["blocker"] = dict(blocker) if blocker else None + phase["updatedAt"] = utc_now() + if status == PhaseStatus.COMPLETE.value: + phase["scopeHash"] = scope_hash(state["scope"]) + + return self._mutate(mutation) + + def complete_action( + self, + phase_id: str, + action: str, + *, + evidence: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: + if phase_id not in PHASE_BY_ID: + raise WorkdayConnectStoreError(f"Unknown Workday phase: {phase_id}.") + if not action or not isinstance(action, str): + raise WorkdayConnectStoreError( + "Workday completed action must be a non-empty string." + ) + + def mutation(state: dict[str, Any]) -> None: + phase = state["phases"][phase_id] + if action not in phase["completedActions"]: + phase["completedActions"].append(action) + if evidence is not None: + phase["evidence"].append( + { + "action": action, + "capturedAt": utc_now(), + **dict(evidence), + } + ) + if phase["status"] == PhaseStatus.PENDING.value: + phase["status"] = PhaseStatus.ACTIVE.value + phase["blocker"] = None + phase["updatedAt"] = utc_now() + + return self._mutate(mutation) + + def record_handoff( + self, + phase_id: str, + handoff: Mapping[str, Any], + ) -> dict[str, Any]: + if phase_id not in PHASE_BY_ID: + raise WorkdayConnectStoreError(f"Unknown Workday phase: {phase_id}.") + + def mutation(state: dict[str, Any]) -> None: + phase = state["phases"][phase_id] + phase["manualHandoff"] = { + **dict(handoff), + "capturedAt": utc_now(), + } + phase["status"] = PhaseStatus.WAITING.value + phase["updatedAt"] = utc_now() + + return self._mutate(mutation) + + def approve_plan( + self, + phase_id: str, + plan: Mapping[str, Any], + ) -> tuple[dict[str, Any], str]: + if phase_id not in PHASE_BY_ID: + raise WorkdayConnectStoreError(f"Unknown Workday phase: {phase_id}.") + if plan.get("phase") != phase_id: + raise WorkdayConnectStoreError( + "Workday plan phase does not match the requested phase." + ) + approved_hash = plan_hash(plan) + + def mutation(state: dict[str, Any]) -> None: + phase = state["phases"][phase_id] + phase["approvedPlan"] = dict(plan) + phase["approvedPlanHash"] = approved_hash + phase["status"] = PhaseStatus.ACTIVE.value + phase["blocker"] = None + phase["updatedAt"] = utc_now() + + return self._mutate(mutation), approved_hash + + def verify_plan( + self, + phase_id: str, + current_plan: Mapping[str, Any], + approved_hash: str, + ) -> str: + state = self.load() + phase = state["phases"].get(phase_id) + if phase is None: + raise WorkdayConnectStoreError(f"Unknown Workday phase: {phase_id}.") + current_hash = plan_hash(current_plan) + stored_hash = phase.get("approvedPlanHash") + if current_hash != approved_hash or stored_hash != approved_hash: + raise WorkdayConnectPlanChangedError( + "The Workday change plan or target changed after approval. " + "Review and approve the current plan before applying it." + ) + return current_hash + + def status(self) -> dict[str, Any]: + state = self.load() + phases = [] + next_phase = None + for definition in PHASE_DEFINITIONS: + phase = state["phases"][definition.identifier.value] + phases.append( + { + "id": definition.identifier.value, + "title": definition.title, + "status": phase["status"], + } + ) + if ( + next_phase is None + and phase["status"] != PhaseStatus.COMPLETE.value + ): + next_phase = definition.identifier.value + return { + "schemaVersion": 1, + "provider": "workday", + "status": state["status"], + "phases": phases, + "nextPhaseId": next_phase, + "progressText": progress_text(state), + "blocker": ( + state["phases"][next_phase]["blocker"] + if next_phase is not None + else None + ), + } diff --git a/tests/scripts/test_workday_connect_model.py b/tests/scripts/test_workday_connect_model.py new file mode 100644 index 00000000..98cba792 --- /dev/null +++ b/tests/scripts/test_workday_connect_model.py @@ -0,0 +1,78 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for the compact Workday connect lifecycle model.""" + +from __future__ import annotations + +import pytest + + +def test_default_state_has_six_primary_phases() -> None: + import workday_connect_model as model + + state = model.default_state() + + assert list(state["phases"]) == [ + "preflight", + "entra", + "workday-admin", + "connections", + "runtime", + "employee-validation", + ] + assert model.next_phase_id(state) == "preflight" + assert state["status"] == "in-progress" + + +def test_workday_saml_entity_id_is_not_the_entra_app_uri() -> None: + import workday_connect_model as model + + entity_id = model.workday_saml_entity_id("contoso_prod") + + assert entity_id == "http://www.workday.com/contoso_prod" + assert not entity_id.startswith("api://") + + +@pytest.mark.parametrize("tenant", ["", "https://tenant", "tenant value", "api://id"]) +def test_workday_saml_entity_id_rejects_invalid_tenant(tenant: str) -> None: + import workday_connect_model as model + + with pytest.raises(model.WorkdayConnectModelError): + model.workday_saml_entity_id(tenant) + + +def test_plan_hash_is_stable_and_ignores_embedded_hash() -> None: + import workday_connect_model as model + + plan = { + "phase": "entra", + "scope": {"tenantId": "tenant", "applicationId": "app"}, + "actions": ["configure-saml", "configure-scope"], + } + observed = model.plan_hash(plan) + + assert observed == model.plan_hash({**plan, "planHash": observed}) + assert observed != model.plan_hash( + {**plan, "actions": ["configure-saml"]} + ) + + +def test_sensitive_fields_are_rejected() -> None: + import workday_connect_model as model + + with pytest.raises(model.WorkdayConnectModelError, match="must not be persisted"): + model.reject_sensitive_data({"nested": {"access_token": "secret"}}) + + +def test_progress_text_is_compact() -> None: + import workday_connect_model as model + + state = model.default_state() + state["phases"]["preflight"]["status"] = "complete" + state["phases"]["entra"]["status"] = "active" + + assert model.progress_text(state) == ( + "Progress: Preflight ✓ · Entra → · Workday · Connections · " + "Runtime · Validate" + ) diff --git a/tests/scripts/test_workday_connect_store.py b/tests/scripts/test_workday_connect_store.py new file mode 100644 index 00000000..1357cd4d --- /dev/null +++ b/tests/scripts/test_workday_connect_store.py @@ -0,0 +1,148 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for Workday connect state, migration, and approval safety.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +def _config_path(root: Path) -> Path: + return root / ".local" / "connect" / "workday-da" / "config.json" + + +def test_initialize_creates_only_json_state(tmp_path: Path) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + state = store.initialize() + + assert state["schemaVersion"] == 2 + assert _config_path(tmp_path).exists() + assert not (tmp_path / ".local/connect/workday-da/tasks.md").exists() + assert not (tmp_path / ".local/setup/workday-da/tasks.md").exists() + + +def test_migrates_legacy_rows_without_using_app_uri_as_saml_id( + tmp_path: Path, +) -> None: + import workday_connect_store as store_module + + path = _config_path(tmp_path) + path.parent.mkdir(parents=True) + path.write_text( + json.dumps( + { + "tenant": "contoso_prod", + "tenantId": "entra-tenant", + "appIdUri": "api://application-id", + "setupStatus": { + "DA1.1": {"state": "done", "verifiedBy": "programmatic"}, + "DA2.1": {"state": "in-progress"}, + }, + } + ), + encoding="utf-8", + ) + + state = store_module.WorkdayConnectStore(tmp_path).initialize() + + assert state["phases"]["preflight"]["status"] == "complete" + assert state["phases"]["entra"]["status"] == "active" + assert state["identifiers"]["entraAppIdUri"] == "api://application-id" + assert ( + state["identifiers"]["workdaySamlEntityId"] + == "http://www.workday.com/contoso_prod" + ) + assert state["migration"]["source"] == "legacy-workday-da-config" + assert path.with_name("config.pre-v2.json").exists() + + +def test_migration_preserves_existing_tasks_as_snapshot(tmp_path: Path) -> None: + import workday_connect_store as store_module + + path = _config_path(tmp_path) + path.parent.mkdir(parents=True) + path.write_text("{}", encoding="utf-8") + tasks = tmp_path / ".local/setup/workday-da/tasks.md" + tasks.parent.mkdir(parents=True) + tasks.write_text("legacy checklist", encoding="utf-8") + + store_module.WorkdayConnectStore(tmp_path).initialize() + + assert tasks.read_text(encoding="utf-8") == "legacy checklist" + assert not (tmp_path / ".local/connect/workday-da/tasks.md").exists() + + +def test_phase_completion_requires_prerequisite(tmp_path: Path) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + + with pytest.raises( + store_module.WorkdayConnectStoreError, + match="Complete 'preflight'", + ): + store.set_phase_status("entra", "complete") + + +def test_complete_action_is_idempotent(tmp_path: Path) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + store.complete_action( + "preflight", + "verify-target", + evidence={"outcome": "passed"}, + ) + state = store.complete_action( + "preflight", + "verify-target", + evidence={"outcome": "passed"}, + ) + + assert state["phases"]["preflight"]["completedActions"] == [ + "verify-target" + ] + assert len(state["phases"]["preflight"]["evidence"]) == 2 + + +def test_approved_plan_rejects_changed_target(tmp_path: Path) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + plan = { + "phase": "entra", + "scope": {"tenantId": "tenant-a", "applicationId": "app-a"}, + "actions": ["configure-saml"], + } + _state, approved_hash = store.approve_plan("entra", plan) + + assert store.verify_plan("entra", plan, approved_hash) == approved_hash + changed = { + **plan, + "scope": {"tenantId": "tenant-a", "applicationId": "app-b"}, + } + with pytest.raises(store_module.WorkdayConnectPlanChangedError): + store.verify_plan("entra", changed, approved_hash) + + +def test_status_returns_one_progress_line_and_next_phase(tmp_path: Path) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + store.set_phase_status("preflight", "complete") + + status = store.status() + + assert status["nextPhaseId"] == "entra" + assert status["progressText"].startswith("Progress: Preflight ✓ · Entra") + assert len(status["phases"]) == 6 From 0b599b75018c10a62482ff4c9bcc7d99b29fe76a Mon Sep 17 00:00:00 2001 From: is-goutham Date: Fri, 25 Sep 2026 22:40:43 -0700 Subject: [PATCH 02/20] Add identity-aware Workday preflight --- .../ess-maker-skills/scripts/discover.py | 9 +- .../scripts/install_workday_da_extension.py | 145 +++++--- .../scripts/list_environments.py | 4 +- .../scripts/workday_connect.py | 26 ++ .../scripts/workday_connect_auth.py | 82 +++++ .../scripts/workday_connect_preflight.py | 332 ++++++++++++++++++ .../test_install_workday_da_extension.py | 79 ++++- tests/scripts/test_workday_connect_auth.py | 52 +++ .../scripts/test_workday_connect_preflight.py | 185 ++++++++++ 9 files changed, 864 insertions(+), 50 deletions(-) create mode 100644 solutions/ess-maker-skills/scripts/workday_connect_auth.py create mode 100644 solutions/ess-maker-skills/scripts/workday_connect_preflight.py create mode 100644 tests/scripts/test_workday_connect_auth.py create mode 100644 tests/scripts/test_workday_connect_preflight.py diff --git a/solutions/ess-maker-skills/scripts/discover.py b/solutions/ess-maker-skills/scripts/discover.py index 0263d51a..a7fd89bf 100644 --- a/solutions/ess-maker-skills/scripts/discover.py +++ b/solutions/ess-maker-skills/scripts/discover.py @@ -100,6 +100,10 @@ def main(): "--resolve-environment-url", help="Resolve one environment URL to its Power Platform metadata", ) + parser.add_argument( + "--preferred-username", + help="Account to reuse for environment resolution when available", + ) parser.add_argument("--select", type=int, default=None, help="Select environment by number and output JSON") args = parser.parse_args() @@ -107,7 +111,10 @@ def main(): if args.resolve_environment_url: from list_environments import resolve_environment_for_user - selected = resolve_environment_for_user(args.resolve_environment_url) + selected = resolve_environment_for_user( + args.resolve_environment_url, + preferred_username=args.preferred_username, + ) if selected is None: print( "ERROR: The provided URL did not match a Dataverse-linked " diff --git a/solutions/ess-maker-skills/scripts/install_workday_da_extension.py b/solutions/ess-maker-skills/scripts/install_workday_da_extension.py index c338aedd..03182ffa 100644 --- a/solutions/ess-maker-skills/scripts/install_workday_da_extension.py +++ b/solutions/ess-maker-skills/scripts/install_workday_da_extension.py @@ -14,21 +14,16 @@ import subprocess import sys -from flightcheck.checks.workday_da import ( - _DA_HR_WORKDAY_CHILD_SCHEMA, - _MOS_WORKDAY_RUNTIME_SCHEMA, -) +from workday_connect_model import load_catalog +_CATALOG = load_catalog() WORKDAY_PACKAGES = { - "runtime": { - "applicationName": _MOS_WORKDAY_RUNTIME_SCHEMA, - "schemaName": _MOS_WORKDAY_RUNTIME_SCHEMA, - }, - "legacy-da": { - "applicationName": "msdyn_EssDAHRWorkdayHCM", - "schemaName": _DA_HR_WORKDAY_CHILD_SCHEMA, - }, + flavor: { + "applicationName": package["applicationName"], + "schemaName": package["solutionSchemaName"], + } + for flavor, package in _CATALOG["packages"].items() } CLOUD_FOR_RING = { "preprod": "Preprod", @@ -101,10 +96,19 @@ def _parse_profiles(output: str) -> list[dict]: None, ) if cloud: + username = next( + ( + token + for token in re.split(r"\s+", remainder) + if "@" in token and not token.casefold().startswith("http") + ), + None, + ) profiles.append( { "index": match.group(1), "active": bool(match.group(2)), + "username": username, "cloud": cloud, "environment_url": environment_url, } @@ -117,38 +121,47 @@ def ensure_pac_auth( *, ring: str, environment_url: str, + preferred_username: str | None = None, runner=_run, -) -> None: +) -> dict: """Select or create a PAC profile for the requested Power Platform ring.""" cloud = CLOUD_FOR_RING[ring] - listed = runner( - [pac_executable, "auth", "list"], - capture_output=True, - timeout=60, - ) - profiles = ( - _parse_profiles(listed.stdout or "") - if listed.returncode == 0 - else [] + normalized_environment = environment_url.rstrip("/").casefold() + normalized_username = ( + preferred_username.casefold() if preferred_username else None ) - cloud_matching = [ - profile - for profile in profiles - if profile["cloud"].casefold() == cloud.casefold() + + def matches_target(profile: dict) -> bool: + if profile["cloud"].casefold() != cloud.casefold(): + return False + if ring == "preprod" and ( + profile["environment_url"] or "" + ).casefold() != normalized_environment: + return False + if normalized_username and ( + profile["username"] or "" + ).casefold() != normalized_username: + return False + return True + + def list_profiles() -> list[dict]: + listed = runner( + [pac_executable, "auth", "list"], + capture_output=True, + timeout=60, + ) + return ( + _parse_profiles(listed.stdout or "") + if listed.returncode == 0 + else [] + ) + + matching = [ + profile for profile in list_profiles() if matches_target(profile) ] - if ring == "preprod": - normalized_environment = environment_url.rstrip("/").casefold() - matching = [ - profile - for profile in cloud_matching - if (profile["environment_url"] or "").casefold() - == normalized_environment - ] - else: - matching = cloud_matching active = [profile for profile in matching if profile["active"]] if len(active) == 1: - return + return active[0] if len(matching) == 1: selected = runner( [ @@ -163,7 +176,19 @@ def ensure_pac_auth( ) if selected.returncode != 0: raise PacCliError("PAC could not select the required auth profile.") - return + if not preferred_username: + return {**matching[0], "active": True} + verified = [ + profile + for profile in list_profiles() + if profile["active"] and matches_target(profile) + ] + if len(verified) != 1: + raise PacCliError( + "PAC selected a profile, but the active profile could not be " + "verified for the requested environment and account." + ) + return verified[0] if len(matching) > 1: raise PacCliError( f"Multiple PAC profiles exist for {cloud}. Select the correct " @@ -189,6 +214,27 @@ def ensure_pac_auth( raise PacCliError( f"PAC authentication for {cloud} did not complete successfully." ) + if not preferred_username: + return { + "index": None, + "active": True, + "username": None, + "cloud": cloud, + "environment_url": ( + environment_url.rstrip("/") if ring == "preprod" else None + ), + } + verified = [ + profile + for profile in list_profiles() + if profile["active"] and matches_target(profile) + ] + if len(verified) != 1: + raise PacCliError( + "PAC authentication completed, but the active profile does not " + "match the requested environment and maker account." + ) + return verified[0] def install_workday_package( @@ -196,9 +242,10 @@ def install_workday_package( package_flavor: str, *, ring: str, + preferred_username: str | None = None, pac_resolver=resolve_pac_executable, runner=_run, -) -> str: +) -> dict: """Install one Workday AppSource package through the supported PAC flow.""" if package_flavor not in WORKDAY_PACKAGES: raise ValueError(f"Unsupported Workday package flavor: {package_flavor}") @@ -208,10 +255,11 @@ def install_workday_package( package = WORKDAY_PACKAGES[package_flavor] pac_executable = pac_resolver() - ensure_pac_auth( + profile = ensure_pac_auth( pac_executable, ring=ring, environment_url=environment_url, + preferred_username=preferred_username, runner=runner, ) installed = runner( @@ -232,7 +280,13 @@ def install_workday_package( "PAC could not install the Workday package. Review the PAC output " "above, confirm environment access, and retry /connect workday." ) - return package["schemaName"] + return { + "schemaName": package["schemaName"], + "authenticatedAccount": profile.get("username"), + "pacProfileIndex": profile.get("index"), + "cloud": profile.get("cloud"), + "environmentUrl": environment_url, + } def main() -> None: @@ -262,6 +316,10 @@ def main() -> None: default="prod", help="Power Platform ring captured during setup.", ) + parser.add_argument( + "--preferred-username", + help="Environment Maker account that PAC must use.", + ) args = parser.parse_args() package = WORKDAY_PACKAGES[args.package_flavor] @@ -274,10 +332,11 @@ def main() -> None: "applicationName": package["applicationName"], } try: - schema_name = install_workday_package( + result = install_workday_package( args.url, args.package_flavor, ring=args.ring, + preferred_username=args.preferred_username, ) except (OSError, PacCliError, RuntimeError, ValueError) as error: print( @@ -290,7 +349,7 @@ def main() -> None: print( "INSTALLED_WORKDAY_DA_EXTENSION_JSON:" - f"{json.dumps({**base_result, 'schemaName': schema_name})}" + f"{json.dumps({**base_result, **result})}" ) diff --git a/solutions/ess-maker-skills/scripts/list_environments.py b/solutions/ess-maker-skills/scripts/list_environments.py index 701bf064..b00dc3fa 100644 --- a/solutions/ess-maker-skills/scripts/list_environments.py +++ b/solutions/ess-maker-skills/scripts/list_environments.py @@ -162,11 +162,11 @@ def find_environment_by_url(environments, env_url): return None -def resolve_environment_for_user(env_url): +def resolve_environment_for_user(env_url, preferred_username=None): """Resolve one Dataverse URL through the user-scoped Power Platform API.""" try: client = PowerPlatformClient(discover_tenant(env_url)) - client.authenticate() + client.authenticate(preferred_username=preferred_username) except (OSError, RuntimeError, ValueError) as exc: print(f"ERROR: Power Platform authentication failed - {exc}") sys.exit(1) diff --git a/solutions/ess-maker-skills/scripts/workday_connect.py b/solutions/ess-maker-skills/scripts/workday_connect.py index 2005e85d..e9063196 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect.py +++ b/solutions/ess-maker-skills/scripts/workday_connect.py @@ -16,6 +16,11 @@ WorkdayConnectModelError, plan_hash, ) +from workday_connect_auth import authentication_plan +from workday_connect_preflight import ( + WorkdayConnectPreflightError, + run_preflight, +) from workday_connect_store import ( WorkdayConnectPlanChangedError, WorkdayConnectStore, @@ -65,6 +70,11 @@ def build_parser() -> argparse.ArgumentParser: subparsers = parser.add_subparsers(dest="command", required=True) subparsers.add_parser("initialize") subparsers.add_parser("status") + subparsers.add_parser("auth-plan") + + preflight = subparsers.add_parser("preflight") + preflight.add_argument("--dataverse-url") + preflight.add_argument("--maker-username") merge = subparsers.add_parser("merge-section") merge.add_argument( @@ -112,6 +122,21 @@ def main() -> None: _emit("initialize", {"state": state, "status": store.status()}) elif args.command == "status": _emit("status", store.status()) + elif args.command == "auth-plan": + _emit( + "auth-plan", + {"authenticationPlan": authentication_plan()}, + ) + elif args.command == "preflight": + _emit( + "preflight", + run_preflight( + Path(args.root), + dataverse_url=args.dataverse_url, + maker_username=args.maker_username, + store=store, + ), + ) elif args.command == "merge-section": state = store.merge_section( args.section, @@ -179,6 +204,7 @@ def main() -> None: OSError, WorkdayConnectModelError, WorkdayConnectPlanChangedError, + WorkdayConnectPreflightError, WorkdayConnectStoreError, ) as exc: print( diff --git a/solutions/ess-maker-skills/scripts/workday_connect_auth.py b/solutions/ess-maker-skills/scripts/workday_connect_auth.py new file mode 100644 index 00000000..e3102c8b --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_connect_auth.py @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Identity continuity and authentication planning for Workday connect.""" + +from __future__ import annotations + +import base64 +import json +from typing import Any + + +class WorkdayConnectIdentityError(RuntimeError): + """Raised when an authenticated account does not match the intended user.""" + + +def authentication_plan() -> list[dict[str, Any]]: + """Describe credential stores without pretending one token serves all.""" + return [ + { + "store": "azure-cli-graph", + "role": "Microsoft Entra administrator", + "purpose": "Discover, configure, and verify the Workday application", + }, + { + "store": "pac", + "role": "Power Platform Environment Maker", + "purpose": "Install the Workday package and inspect connections", + }, + { + "store": "dataverse-msal", + "role": "Power Platform Environment Maker", + "purpose": "Verify and configure Workday runtime resources", + }, + { + "store": "workday-connector", + "role": "Workday employee", + "purpose": "Create the signed-in employee Workday connection", + }, + ] + + +def token_identity(access_token: str) -> dict[str, str]: + """Read safe identity claims from an access token without storing it.""" + try: + payload = access_token.split(".")[1] + payload += "=" * (-len(payload) % 4) + claims = json.loads(base64.urlsafe_b64decode(payload)) + except (IndexError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise WorkdayConnectIdentityError( + "The authenticated Dataverse token did not contain readable " + "identity claims." + ) from exc + username = str( + claims.get("preferred_username") + or claims.get("upn") + or claims.get("unique_name") + or "" + ).strip() + tenant_id = str(claims.get("tid") or "").strip() + if not username or not tenant_id: + raise WorkdayConnectIdentityError( + "The authenticated Dataverse token did not identify both the " + "account and Microsoft Entra tenant." + ) + return {"username": username, "tenantId": tenant_id} + + +def require_identity( + access_token: str, + *, + preferred_username: str | None, +) -> dict[str, str]: + identity = token_identity(access_token) + if preferred_username and ( + identity["username"].casefold() != preferred_username.casefold() + ): + raise WorkdayConnectIdentityError( + "Dataverse authentication used a different account from the " + "selected Environment Maker." + ) + return identity diff --git a/solutions/ess-maker-skills/scripts/workday_connect_preflight.py b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py new file mode 100644 index 00000000..63437eb1 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py @@ -0,0 +1,332 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Identity-aware preflight for the Workday connect lifecycle.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +from typing import Any, Callable + +from auth import authenticate, query_all +from install_workday_da_extension import install_workday_package +from workday_connect_auth import authentication_plan, require_identity +from workday_connect_model import load_catalog +from workday_connect_store import WorkdayConnectStore + + +class WorkdayConnectPreflightError(RuntimeError): + """Raised when the Workday target cannot be proven safely.""" + + +@dataclass(frozen=True) +class PreflightTarget: + agent: dict[str, Any] + architecture: str + package_flavor: str + dataverse_url: str + foundation_ring: str + pac_ring: str + + +def _read_json(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise WorkdayConnectPreflightError( + f"Required workspace state could not be read: {path}: {exc}" + ) from exc + if not isinstance(document, dict): + raise WorkdayConnectPreflightError( + f"Required workspace state must contain an object: {path}" + ) + return document + + +def _active_agent(config: dict[str, Any]) -> dict[str, Any]: + active_slug = str(config.get("activeAgent") or "").strip() + candidates = config.get("agents") + if isinstance(candidates, list) and active_slug: + matches = [ + agent + for agent in candidates + if isinstance(agent, dict) + and str(agent.get("slug") or "") == active_slug + ] + if len(matches) == 1: + return matches[0] + legacy = config.get("agent") + if ( + isinstance(legacy, dict) + and str(legacy.get("slug") or "") == active_slug + ): + return legacy + raise WorkdayConnectPreflightError( + "Select one setup-complete ESS HR agent before connecting Workday." + ) + + +def _require_materialized_workspace( + setup_state: dict[str, Any], + agent: dict[str, Any], +) -> None: + if setup_state.get("schema_version") != 4: + raise WorkdayConnectPreflightError( + "The selected agent workspace is not on the supported setup schema." + ) + bot_id = str(agent.get("botId") or "").casefold() + slug = str(agent.get("slug") or "") + agents = setup_state.get("agents") + if not bot_id or not slug or not isinstance(agents, dict): + raise WorkdayConnectPreflightError( + "The selected agent is missing canonical workspace identity." + ) + candidate = next( + ( + value + for key, value in agents.items() + if isinstance(value, dict) + and ( + str(key).casefold() == bot_id + or str((value.get("agent") or {}).get("id") or "").casefold() + == bot_id + ) + and str( + (value.get("agent") or {}).get("workspace_slug") or "" + ) + == slug + ), + None, + ) + if not isinstance(candidate, dict): + raise WorkdayConnectPreflightError( + "The selected agent does not have canonical workspace evidence." + ) + steps = candidate.get("steps") + setup_07 = steps.get("SETUP-07") if isinstance(steps, dict) else None + if not isinstance(setup_07, dict) or setup_07.get("state") != "done": + raise WorkdayConnectPreflightError( + "Finish the selected agent's workspace setup before connecting " + "Workday." + ) + + +def _pac_ring(foundation_ring: str) -> str: + normalized = str(foundation_ring or "prod").casefold() + if normalized in {"test", "preprod"}: + return "preprod" + if normalized == "prod": + return "prod" + raise WorkdayConnectPreflightError( + f"Unsupported Power Platform ring: {foundation_ring!r}." + ) + + +def resolve_target( + workspace_root: Path, + *, + dataverse_url: str | None, + state: dict[str, Any], + catalog: dict[str, Any] | None = None, +) -> PreflightTarget: + active_catalog = catalog or load_catalog() + foundation = _read_json(workspace_root / ".local" / "config.json") + setup_state = _read_json( + workspace_root / ".local" / "setup" / "config.json" + ) + agent = _active_agent(foundation) + schema = str(agent.get("schemaName") or "").casefold() + supported = active_catalog["supportedAgents"].get(schema) + if supported is None: + if schema in set(active_catalog["unsupportedAgents"]): + raise WorkdayConnectPreflightError( + "Workday connection supports the ESS HR agent only." + ) + raise WorkdayConnectPreflightError( + "The selected agent is not a supported ESS HR architecture." + ) + _require_materialized_workspace(setup_state, agent) + + exact_url = ( + str(foundation.get("dataverseEndpoint") or "").strip() + or str(state.get("scope", {}).get("dataverseUrl") or "").strip() + or str(dataverse_url or "").strip() + ).rstrip("/") + if not exact_url: + raise WorkdayConnectPreflightError( + "Select the Dataverse environment that will host the Workday " + "runtime package." + ) + if not exact_url.startswith("https://"): + raise WorkdayConnectPreflightError( + "The Workday Dataverse environment URL must use HTTPS." + ) + foundation_ring = str(foundation.get("ring") or "prod").casefold() + return PreflightTarget( + agent={ + key: agent[key] + for key in ("slug", "botId", "schemaName", "name") + if agent.get(key) + }, + architecture=supported["architecture"], + package_flavor=supported["packageFlavor"], + dataverse_url=exact_url, + foundation_ring=foundation_ring, + pac_ring=_pac_ring(foundation_ring), + ) + + +def _installed_solutions( + environment_url: str, + token: str, + *, + query: Callable[..., list[dict[str, Any]]], + catalog: dict[str, Any], +) -> dict[str, dict[str, Any]]: + schemas = [ + package["solutionSchemaName"] + for package in catalog["packages"].values() + ] + filter_expression = " or ".join( + f"uniquename eq '{schema}'" for schema in schemas + ) + rows = query( + environment_url, + token, + "solutions", + "solutionid,uniquename,friendlyname,ismanaged,version", + filter_expression, + ) + return { + str(row.get("uniquename") or "").casefold(): row + for row in rows + if isinstance(row, dict) + } + + +def run_preflight( + workspace_root: Path, + *, + dataverse_url: str | None, + maker_username: str | None, + store: WorkdayConnectStore | None = None, + token_provider: Callable[..., str] = authenticate, + query: Callable[..., list[dict[str, Any]]] = query_all, + installer: Callable[..., dict[str, Any]] = install_workday_package, + identity_provider: Callable[..., dict[str, str]] = require_identity, + catalog: dict[str, Any] | None = None, +) -> dict[str, Any]: + active_catalog = catalog or load_catalog() + state_store = store or WorkdayConnectStore(workspace_root) + state = state_store.initialize() + target = resolve_target( + workspace_root, + dataverse_url=dataverse_url, + state=state, + catalog=active_catalog, + ) + token = token_provider( + target.dataverse_url, + preferred_username=maker_username, + ) + identity = identity_provider( + token, + preferred_username=maker_username, + ) + installed = _installed_solutions( + target.dataverse_url, + token, + query=query, + catalog=active_catalog, + ) + package = active_catalog["packages"][target.package_flavor] + required_schema = package["solutionSchemaName"] + package_action = "unchanged" + pac_identity = None + if required_schema.casefold() not in installed: + install_result = installer( + target.dataverse_url, + target.package_flavor, + ring=target.pac_ring, + preferred_username=identity["username"], + ) + pac_identity = install_result.get("authenticatedAccount") + if not pac_identity or ( + pac_identity.casefold() != identity["username"].casefold() + ): + raise WorkdayConnectPreflightError( + "PAC package installation did not prove the intended " + "Environment Maker account." + ) + installed = _installed_solutions( + target.dataverse_url, + token, + query=query, + catalog=active_catalog, + ) + if required_schema.casefold() not in installed: + raise WorkdayConnectPreflightError( + "PAC completed, but the required Workday package was not " + "found during post-install verification." + ) + package_action = "installed" + + state_store.merge_section( + "scope", + { + "agent": target.agent, + "dataverseUrl": target.dataverse_url, + "architecture": target.architecture, + "packageFlavor": target.package_flavor, + "ring": target.foundation_ring, + "vertical": "hr", + }, + ) + state_store.merge_section( + "operators", + { + "powerPlatformMaker": { + "username": identity["username"], + "tenantId": identity["tenantId"], + "credentialStores": { + "dataverse-msal": "verified", + "pac": "verified" if pac_identity else "not-required", + }, + } + }, + ) + state_store.complete_action( + "preflight", + "verify-target", + evidence={ + "outcome": "passed", + "environmentUrl": target.dataverse_url, + "account": identity["username"], + }, + ) + state_store.complete_action( + "preflight", + "verify-package", + evidence={ + "outcome": "passed", + "packageSchema": required_schema, + "action": package_action, + "pacAccount": pac_identity, + }, + ) + final_state = state_store.set_phase_status("preflight", "complete") + return { + "scope": final_state["scope"], + "operator": final_state["operators"]["powerPlatformMaker"], + "package": { + "flavor": target.package_flavor, + "schemaName": required_schema, + "action": package_action, + }, + "authenticationPlan": authentication_plan(), + "status": state_store.status(), + } diff --git a/tests/scripts/test_install_workday_da_extension.py b/tests/scripts/test_install_workday_da_extension.py index 99a6776f..f851852b 100644 --- a/tests/scripts/test_install_workday_da_extension.py +++ b/tests/scripts/test_install_workday_da_extension.py @@ -31,12 +31,14 @@ def test_parse_profiles_reads_cloud_and_active_marker(): { "index": "1", "active": False, + "username": "user@contoso.com", "cloud": "Public", "environment_url": None, }, { "index": "2", "active": True, + "username": "user@contoso.com", "cloud": "Preprod", "environment_url": "https://org.crm10.dynamics.com", }, @@ -59,7 +61,7 @@ def runner(command, *, capture_output, timeout): ) return _result() - schema = m.install_workday_package( + result = m.install_workday_package( "https://org.crm10.dynamics.com", "runtime", ring="preprod", @@ -67,7 +69,8 @@ def runner(command, *, capture_output, timeout): runner=runner, ) - assert schema == "msdyn_EssWorkdayRuntime" + assert result["schemaName"] == "msdyn_EssWorkdayRuntime" + assert result["authenticatedAccount"] == "user@contoso.com" assert calls[-1][0] == [ "pac.exe", "application", @@ -262,6 +265,74 @@ def runner(command, *, capture_output, timeout): ) +def test_preprod_auth_selects_exact_environment_and_username(): + import install_workday_da_extension as m + + calls = [] + auth_lists = iter( + [ + ( + "[1] user1@contoso.com Preprod " + "https://org.crm10.dynamics.com\n" + "[2] user2@contoso.com Preprod " + "https://org.crm10.dynamics.com/\n" + ), + ( + "[1] user1@contoso.com Preprod " + "https://org.crm10.dynamics.com\n" + "[2] * user2@contoso.com Preprod " + "https://org.crm10.dynamics.com/\n" + ), + ] + ) + + def runner(command, *, capture_output, timeout): + calls.append([str(part) for part in command]) + if command[1:3] == ["auth", "list"]: + return _result(stdout=next(auth_lists)) + return _result() + + profile = m.ensure_pac_auth( + Path("pac.exe"), + ring="preprod", + environment_url="https://org.crm10.dynamics.com", + preferred_username="user2@contoso.com", + runner=runner, + ) + + assert calls[1] == ["pac.exe", "auth", "select", "--index", "2"] + assert profile["username"] == "user2@contoso.com" + assert profile["active"] is True + + +def test_preprod_auth_rejects_wrong_account_after_profile_creation(): + import install_workday_da_extension as m + + auth_lists = iter( + [ + "", + ( + "[1] * wrong@contoso.com Preprod " + "https://org.crm10.dynamics.com\n" + ), + ] + ) + + def runner(command, *, capture_output, timeout): + if command[1:3] == ["auth", "list"]: + return _result(stdout=next(auth_lists)) + return _result() + + with pytest.raises(m.PacCliError, match="does not match the requested"): + m.ensure_pac_auth( + Path("pac.exe"), + ring="preprod", + environment_url="https://org.crm10.dynamics.com", + preferred_username="maker@contoso.com", + runner=runner, + ) + + def test_legacy_da_uses_targeted_appsource_application(): import install_workday_da_extension as m @@ -273,7 +344,7 @@ def runner(command, *, capture_output, timeout): return _result(stdout="[1] * user@contoso.com Public\n") return _result() - schema = m.install_workday_package( + result = m.install_workday_package( "https://org.crm.dynamics.com", "legacy-da", ring="prod", @@ -281,7 +352,7 @@ def runner(command, *, capture_output, timeout): runner=runner, ) - assert schema == "msdyn_EssDAHRWorkday" + assert result["schemaName"] == "msdyn_EssDAHRWorkday" assert calls[-1][-1] == "msdyn_EssDAHRWorkdayHCM" diff --git a/tests/scripts/test_workday_connect_auth.py b/tests/scripts/test_workday_connect_auth.py new file mode 100644 index 00000000..ec632238 --- /dev/null +++ b/tests/scripts/test_workday_connect_auth.py @@ -0,0 +1,52 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for Workday connect identity continuity.""" + +from __future__ import annotations + +import base64 +import json + +import pytest + + +def _token(username: str, tenant_id: str = "tenant-id") -> str: + payload = base64.urlsafe_b64encode( + json.dumps( + {"preferred_username": username, "tid": tenant_id} + ).encode() + ).decode().rstrip("=") + return f"header.{payload}.signature" + + +def test_token_identity_returns_only_safe_provenance() -> None: + import workday_connect_auth as auth + + assert auth.token_identity(_token("maker@example.com")) == { + "username": "maker@example.com", + "tenantId": "tenant-id", + } + + +def test_require_identity_rejects_wrong_account() -> None: + import workday_connect_auth as auth + + with pytest.raises(auth.WorkdayConnectIdentityError, match="different account"): + auth.require_identity( + _token("other@example.com"), + preferred_username="maker@example.com", + ) + + +def test_authentication_plan_distinguishes_credential_stores() -> None: + import workday_connect_auth as auth + + stores = {item["store"] for item in auth.authentication_plan()} + + assert stores == { + "azure-cli-graph", + "pac", + "dataverse-msal", + "workday-connector", + } diff --git a/tests/scripts/test_workday_connect_preflight.py b/tests/scripts/test_workday_connect_preflight.py new file mode 100644 index 00000000..4d76d53c --- /dev/null +++ b/tests/scripts/test_workday_connect_preflight.py @@ -0,0 +1,185 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for identity-aware Workday connect preflight.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +BOT_ID = "00000000-0000-4000-8000-000000000001" +ENV_URL = "https://target.crm.dynamics.com" + + +def _write_foundation(root: Path, *, dataverse_url: str | None = None) -> None: + local = root / ".local" + local.mkdir(parents=True, exist_ok=True) + config = { + "configVersion": 1, + "setup": "complete", + "releaseLine": "da", + "environmentId": "agent-environment", + "powerPlatformApiEndpoint": "https://api.powerplatform.com", + "ring": "prod", + "activeAgent": "ess-hr", + "agent": { + "slug": "ess-hr", + "botId": BOT_ID, + "schemaName": "gptagent_copilotforemployeeselfservicehr", + "name": "Employee Self-Service HR", + }, + "agents": [ + { + "slug": "ess-hr", + "botId": BOT_ID, + "schemaName": "gptagent_copilotforemployeeselfservicehr", + "name": "Employee Self-Service HR", + } + ], + } + if dataverse_url: + config["dataverseEndpoint"] = dataverse_url + (local / "config.json").write_text(json.dumps(config), encoding="utf-8") + setup = local / "setup" + setup.mkdir() + (setup / "config.json").write_text( + json.dumps( + { + "schema_version": 4, + "agents": { + BOT_ID: { + "agent": { + "id": BOT_ID, + "workspace_slug": "ess-hr", + }, + "steps": {"SETUP-07": {"state": "done"}}, + } + }, + } + ), + encoding="utf-8", + ) + + +def test_resolve_target_prefers_canonical_dataverse_url(tmp_path: Path) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + _write_foundation(tmp_path, dataverse_url=ENV_URL) + + target = preflight.resolve_target( + tmp_path, + dataverse_url="https://other.crm.dynamics.com", + state=model.default_state(), + ) + + assert target.dataverse_url == ENV_URL + assert target.package_flavor == "runtime" + + +def test_resolve_target_accepts_exact_url_without_inventory_lookup( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + _write_foundation(tmp_path) + + target = preflight.resolve_target( + tmp_path, + dataverse_url=ENV_URL, + state=model.default_state(), + ) + + assert target.dataverse_url == ENV_URL + + +def test_preflight_skips_install_when_package_exists(tmp_path: Path) -> None: + import workday_connect_preflight as preflight + import workday_connect_store as store_module + + _write_foundation(tmp_path) + installer_calls = [] + + def query(_url, _token, entity_set, _select, _filter): + assert entity_set == "solutions" + return [{"uniquename": "msdyn_EssWorkdayRuntime"}] + + result = preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username="maker@example.com", + store=store_module.WorkdayConnectStore(tmp_path), + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=lambda *_args, **_kwargs: { + "username": "maker@example.com", + "tenantId": "tenant-id", + }, + query=query, + installer=lambda *_args, **_kwargs: installer_calls.append(True), + ) + + assert installer_calls == [] + assert result["package"]["action"] == "unchanged" + assert result["status"]["nextPhaseId"] == "entra" + + +def test_preflight_installs_and_reverifies_with_same_account( + tmp_path: Path, +) -> None: + import workday_connect_preflight as preflight + + _write_foundation(tmp_path) + query_results = iter( + [[], [{"uniquename": "msdyn_EssWorkdayRuntime"}]] + ) + + result = preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username="maker@example.com", + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=lambda *_args, **_kwargs: { + "username": "maker@example.com", + "tenantId": "tenant-id", + }, + query=lambda *_args, **_kwargs: next(query_results), + installer=lambda *_args, **kwargs: { + "schemaName": "msdyn_EssWorkdayRuntime", + "authenticatedAccount": kwargs["preferred_username"], + }, + ) + + assert result["package"]["action"] == "installed" + assert result["operator"]["username"] == "maker@example.com" + assert result["operator"]["credentialStores"]["pac"] == "verified" + + +def test_preflight_rejects_unproven_pac_account(tmp_path: Path) -> None: + import workday_connect_preflight as preflight + + _write_foundation(tmp_path) + + with pytest.raises( + preflight.WorkdayConnectPreflightError, + match="did not prove", + ): + preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username="maker@example.com", + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=lambda *_args, **_kwargs: { + "username": "maker@example.com", + "tenantId": "tenant-id", + }, + query=lambda *_args, **_kwargs: [], + installer=lambda *_args, **_kwargs: { + "schemaName": "msdyn_EssWorkdayRuntime", + "authenticatedAccount": "other@example.com", + }, + ) From 32080f06273d4f30c77500b8db57e2b60a56616c Mon Sep 17 00:00:00 2001 From: is-goutham Date: Fri, 25 Sep 2026 22:42:37 -0700 Subject: [PATCH 03/20] Tighten Workday identity contracts --- .../flightcheck/checks/workday_tenant.py | 19 +- .../scripts/flightcheck/cli.py | 15 ++ .../scripts/workday_connect.py | 19 ++ .../scripts/workday_connect_contracts.py | 242 ++++++++++++++++++ .../setup/workday-da/configure-tenant.md | 25 +- .../setup/workday-da/provision-entra-app.md | 18 +- .../setup/workday-da/shared/config-schema.md | 6 +- .../workday-da/shared/connection-fields.md | 20 +- .../flightcheck/checks/test_workday_tenant.py | 5 +- .../flightcheck/test_cli_single_checkpoint.py | 43 ++++ .../scripts/test_workday_connect_contracts.py | 139 ++++++++++ 11 files changed, 516 insertions(+), 35 deletions(-) create mode 100644 solutions/ess-maker-skills/scripts/workday_connect_contracts.py create mode 100644 tests/scripts/test_workday_connect_contracts.py 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 0eb19efb..50012d9b 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_tenant.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_tenant.py @@ -15,9 +15,10 @@ ``oauthClientId`` / ``tokenEndpoint``. * ``WD-TENANT-001`` — Tenant Setup - Security is configured (redirect URL set; OAuth 2.0 Clients + SAML enabled; SAML Service Provider ID matches - the Entra Identifier) AND an active authentication rule allows SAML for + the exact Workday SAML entity ID) AND an active authentication rule allows + SAML for the intended employee population. Echoes the captured ``restBaseUrl`` / - ``soapBaseUrl`` / ``tenant`` / ``appIdUri``. + ``soapBaseUrl`` / ``tenant`` / ``workdaySamlEntityId``. Design invariants (per ``scripts/flightcheck/AGENTS.md``): * **Always MANUAL.** Workday exposes no queryable admin API the kit can @@ -158,15 +159,16 @@ def _check_tenant_security(config) -> list[CheckResult]: tenant = _fmt(config, "tenant") rest_base = _fmt(config, "restBaseUrl") soap_base = _fmt(config, "soapBaseUrl") - app_id_uri = _fmt(config, "appIdUri") + saml_entity_id = _fmt(config, "workdaySamlEntityId") result = ( "Workday admin task — verify in the Workday tenant, not " f"programmatically. Captured connection fields: tenant = {tenant}; " - f"REST base = {rest_base}; SOAP base = {soap_base}; Entra Identifier " - f"(App ID URI) = {app_id_uri}. Confirm Tenant Setup - Security has the " + f"REST base = {rest_base}; SOAP base = {soap_base}; Workday SAML " + f"Service Provider ID = {saml_entity_id}. Confirm Tenant Setup - " + "Security has the " "redirection URL set, OAuth 2.0 Clients and SAML enabled, and the " - "SAML Service Provider ID matching the Entra Identifier above — and " + "enabled SAML row uses the exact Service Provider ID above — and " "that an active authentication rule allows SAML for the intended " "employee population. If the existing active policy already provides " "that access, no policy change or activation is required." @@ -180,8 +182,9 @@ def _check_tenant_security(config) -> list[CheckResult]: remediation=( "In Workday: (1) edit 'Tenant Setup - Security' — set the " "redirection URL, enable OAuth 2.0 Clients and SAML, and verify " - "the SAML Service Provider ID equals the Entra Identifier / Entity " - "ID; (2) open 'Manage Authentication Policies' and verify an active " + "the SAML Service Provider ID equals " + "http://www.workday.com/{tenant}, not the api:// Entra application " + "ID URI; (2) open 'Manage Authentication Policies' and verify an active " "rule allows SAML for the intended employees. Do not invent an " "OAuth-client condition when the tenant UI does not expose one, " "and do not use an ISU/integration-system security-group rule for " diff --git a/solutions/ess-maker-skills/scripts/flightcheck/cli.py b/solutions/ess-maker-skills/scripts/flightcheck/cli.py index 85ca8cb1..3a2377f7 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/cli.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/cli.py @@ -790,6 +790,7 @@ def _resolve_environment_ring( "tokenHost", "vertical", "verticals", + "workdaySamlEntityId", }) @@ -809,6 +810,20 @@ def _merge_connect_config(config: dict, connect_config_path: str | None) -> dict if not isinstance(overlay, dict): raise ValueError(f"{connect_config_path} must contain a JSON object") + if overlay.get("schemaVersion") == 2: + scope = overlay.get("scope") or {} + identifiers = overlay.get("identifiers") or {} + endpoints = overlay.get("endpoints") or {} + overlay = { + **overlay, + **identifiers, + **endpoints, + "tenant": scope.get("workdayTenant"), + "tenantId": scope.get("entraTenantId"), + "sidecarDataverseEndpoint": scope.get("dataverseUrl"), + "appIdUri": identifiers.get("entraAppIdUri"), + } + for key in _PROVIDER_CONNECT_CONFIG_KEYS: if key in overlay: merged[key] = overlay[key] diff --git a/solutions/ess-maker-skills/scripts/workday_connect.py b/solutions/ess-maker-skills/scripts/workday_connect.py index e9063196..d0aa1a0e 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect.py +++ b/solutions/ess-maker-skills/scripts/workday_connect.py @@ -17,6 +17,11 @@ plan_hash, ) from workday_connect_auth import authentication_plan +from workday_connect_contracts import ( + WorkdayConnectContractError, + build_entra_plan, + build_workday_admin_packet, +) from workday_connect_preflight import ( WorkdayConnectPreflightError, run_preflight, @@ -72,6 +77,10 @@ def build_parser() -> argparse.ArgumentParser: subparsers.add_parser("status") subparsers.add_parser("auth-plan") + entra_plan = subparsers.add_parser("entra-plan") + entra_plan.add_argument("--discovery-json", required=True) + subparsers.add_parser("workday-admin-packet") + preflight = subparsers.add_parser("preflight") preflight.add_argument("--dataverse-url") preflight.add_argument("--maker-username") @@ -127,6 +136,15 @@ def main() -> None: "auth-plan", {"authenticationPlan": authentication_plan()}, ) + elif args.command == "entra-plan": + plan = build_entra_plan( + store.load(), + _json_object(args.discovery_json, "Entra discovery"), + ) + _emit("entra-plan", {"plan": plan}) + elif args.command == "workday-admin-packet": + packet = build_workday_admin_packet(store.load()) + _emit("workday-admin-packet", {"packet": packet}) elif args.command == "preflight": _emit( "preflight", @@ -203,6 +221,7 @@ def main() -> None: except ( OSError, WorkdayConnectModelError, + WorkdayConnectContractError, WorkdayConnectPlanChangedError, WorkdayConnectPreflightError, WorkdayConnectStoreError, diff --git a/solutions/ess-maker-skills/scripts/workday_connect_contracts.py b/solutions/ess-maker-skills/scripts/workday_connect_contracts.py new file mode 100644 index 00000000..5f18f5f4 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_connect_contracts.py @@ -0,0 +1,242 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Exact Entra and Workday administrator contracts for Workday connect.""" + +from __future__ import annotations + +from typing import Any, Mapping + +from workday_connect_model import ( + PhaseStatus, + WorkdayConnectModelError, + plan_hash, + workday_saml_entity_id, +) + + +WORKDAY_CONNECTOR_APP_ID = "4e4707ca-5f53-46a6-a819-f7765446e6ff" +GRAPH_DELEGATED_PERMISSIONS = ("openid", "profile", "User.Read") + + +class WorkdayConnectContractError(WorkdayConnectModelError): + """Raised when an exact Entra or Workday contract cannot be produced.""" + + +def _required_text( + document: Mapping[str, Any], + key: str, + label: str, +) -> str: + value = str(document.get(key) or "").strip() + if not value: + raise WorkdayConnectContractError(f"{label} is required.") + return value + + +def _normalized_uri(value: Any) -> str: + return str(value or "").strip().rstrip("/").casefold() + + +def _candidate(candidate: Any) -> dict[str, Any]: + if not isinstance(candidate, Mapping): + raise WorkdayConnectContractError( + "Every discovered Entra application must be an object." + ) + identifier_uris = candidate.get("identifierUris") or [] + if not isinstance(identifier_uris, list): + raise WorkdayConnectContractError( + "Discovered Entra identifierUris must be an array." + ) + return { + "displayName": _required_text( + candidate, "displayName", "Entra app display name" + ), + "appId": _required_text(candidate, "appId", "Entra app ID"), + "objectId": _required_text( + candidate, "objectId", "Entra app object ID" + ), + "servicePrincipalId": _required_text( + candidate, + "servicePrincipalId", + "Entra service principal ID", + ), + "identifierUris": [ + str(value).strip() + for value in identifier_uris + if str(value).strip() + ], + } + + +def _require_preflight(state: Mapping[str, Any]) -> None: + phases = state.get("phases") + if not isinstance(phases, Mapping): + raise WorkdayConnectContractError( + "Initialize Workday connect before building an Entra plan." + ) + preflight = phases.get("preflight") + if ( + not isinstance(preflight, Mapping) + or preflight.get("status") != PhaseStatus.COMPLETE.value + ): + raise WorkdayConnectContractError( + "Complete Workday preflight before planning Entra changes." + ) + + +def build_entra_plan( + state: Mapping[str, Any], + discovery: Mapping[str, Any], +) -> dict[str, Any]: + """Build one approval-ready Entra plan after exact app discovery.""" + _require_preflight(state) + if not isinstance(discovery, Mapping): + raise WorkdayConnectContractError( + "Entra discovery must contain a JSON object." + ) + scope = state.get("scope") or {} + tenant = _required_text(scope, "workdayTenant", "Workday tenant") + entity_id = workday_saml_entity_id(tenant) + entra_tenant_id = _required_text( + scope, "entraTenantId", "Microsoft Entra tenant ID" + ) + candidates = [ + _candidate(value) for value in (discovery.get("applications") or []) + ] + matches = [ + value + for value in candidates + if _normalized_uri(entity_id) + in {_normalized_uri(uri) for uri in value["identifierUris"]} + ] + if len(matches) > 1: + raise WorkdayConnectContractError( + "More than one Entra application has the exact Workday SAML " + "Service Provider ID. Resolve the duplicate before continuing." + ) + allow_create = discovery.get("allowCreate") is True + if not matches and not allow_create: + raise WorkdayConnectContractError( + "No exact Entra application was found and application creation " + "was not authorized for planning." + ) + + app = matches[0] if matches else None + target = ( + { + "mode": "reuse", + **app, + } + if app + else { + "mode": "create", + "displayName": str( + discovery.get("newDisplayName") or "Workday (ESS Copilot)" + ).strip(), + "galleryTemplate": "Workday", + } + ) + app_id_uri = f"api://{app['appId']}" if app else None + plan = { + "phase": "entra", + "scope": { + "entraTenantId": entra_tenant_id, + "workdayTenant": tenant, + "workdaySamlEntityId": entity_id, + }, + "target": target, + "identifiers": { + "workdaySamlEntityId": entity_id, + "entraAppIdUri": app_id_uri, + }, + "permissions": { + "connectorAppId": WORKDAY_CONNECTOR_APP_ID, + "graphDelegated": list(GRAPH_DELEGATED_PERMISSIONS), + "scope": "user_impersonation", + }, + "actions": [ + ( + "Reuse the exact Workday SAML application" + if app + else "Instantiate the Workday gallery application" + ), + "Configure SAML mode, the signing certificate, and the exact " + f"Service Provider ID {entity_id}", + "Expose the user_impersonation scope at the Entra application ID " + "URI and pre-authorize the Workday connector", + "Add openid, profile, and User.Read delegated permissions", + "Grant administrator consent and verify the final configuration", + ], + } + return {**plan, "planHash": plan_hash(plan)} + + +def build_workday_admin_packet( + state: Mapping[str, Any], +) -> dict[str, Any]: + """Build one compact handoff packet for the Workday administrator.""" + scope = state.get("scope") or {} + identifiers = state.get("identifiers") or {} + endpoints = state.get("endpoints") or {} + tenant = _required_text(scope, "workdayTenant", "Workday tenant") + expected_entity_id = workday_saml_entity_id(tenant) + entity_id = _required_text( + identifiers, + "workdaySamlEntityId", + "Workday SAML Service Provider ID", + ) + if _normalized_uri(entity_id) != _normalized_uri(expected_entity_id): + raise WorkdayConnectContractError( + "The Workday SAML Service Provider ID does not match the selected " + "Workday tenant." + ) + entra_app_id_uri = _required_text( + identifiers, + "entraAppIdUri", + "Entra application ID URI", + ) + if _normalized_uri(entity_id) == _normalized_uri(entra_app_id_uri): + raise WorkdayConnectContractError( + "The Workday SAML Service Provider ID and Entra application ID URI " + "must remain distinct." + ) + + packet = { + "phase": "workday-admin", + "scope": { + "workdayTenant": tenant, + "workdaySamlEntityId": entity_id, + }, + "referenceValues": { + "serviceProviderId": entity_id, + "entraApplicationIdUri": entra_app_id_uri, + "oauthTokenUrl": endpoints.get("oauthTokenUrl"), + }, + "actions": [ + "Confirm the existing enabled SAML identity-provider row belongs " + "to this tenant before changing it", + "Upload the active Entra SAML signing certificate", + f"Set the Workday Service Provider ID to {entity_id}", + "Enable OAuth 2.0 Clients and SAML in Tenant Setup - Security", + "Register the signed-in employee API client with the required " + "functional areas and Include Workday Owned Scope", + "Verify an active authentication policy allows SAML for the " + "intended employee population", + ], + "responseForm": { + "required": [ + "enabledServiceProviderId", + "certificateValidFrom", + "certificateValidTo", + "oauthClientId", + "oauthTokenUrl", + "authenticationPolicyOutcome", + ], + "note": ( + "Return configuration evidence only. Do not paste passwords, " + "client secrets, tokens, cookies, or certificate private keys." + ), + }, + } + return {**packet, "planHash": plan_hash(packet)} diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md index eabe63a8..9bedc8a8 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md @@ -7,7 +7,8 @@ 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 +`entraAppId`, `entraAppIdUri`, `workdaySamlEntityId`, 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 @@ -157,8 +158,8 @@ 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`): + (its Service Provider ID does not exactly match this tenant's + `workdaySamlEntityId` from `.local/connect/workday-da/config.json`): **Message:** @@ -269,7 +270,9 @@ as part of the `WD-TENANT-001` attestation (verified at the end of DA3.3). 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. +Workday SAML Identifier (Entity ID), +`http://www.workday.com/{tenant}` — not the `api://...` Application ID URI. +Type **done** when saved. **End message.** @@ -314,13 +317,15 @@ C.1–C.6), passing whatever is already known 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. +- `ENTRA_APP_ID_URI` — the Entra `entraAppIdUri` from DA-2. +- `WORKDAY_SAML_ENTITY_ID` — the `workdaySamlEntityId` 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). +persists `oauthClientId`, `tokenEndpoint`, `soapBaseUrl`, `restBaseUrl`, +`entraAppIdUri`, and `workdaySamlEntityId` back to +`.local/connect/workday-da/config.json` (round-trip merge — never drop fields +owned by other steps). **Message:** @@ -429,8 +434,8 @@ are in place. 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 +This echoes the captured `tenant` / `restBaseUrl` / `soapBaseUrl` / +`workdaySamlEntityId` 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 diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md index 7c4bfb75..ae35a2ba 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md @@ -371,13 +371,15 @@ activated. Pass the certificate thumbprint and activation confirmation as 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 +1. **Expose the `user_impersonation` scope without replacing the SAML + identifier.** The application must retain both distinct identifier URIs: + `http://www.workday.com/{tenant}` for Workday SAML and + `api://{WD_ENTRA_APP_ID}` for the exposed API scope. Do not call the generic + B.4a command because it replaces the entire `identifierUris` collection. + PATCH the application with both exact values, generate `SCOPE_GUID`, then + 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). + **§B.4b** to expose `user_impersonation`. 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 @@ -414,7 +416,9 @@ Workday connector can obtain an on-behalf-of token. 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`. +`SCOPE_GUID`, `entraAppIdUri` = `api://{WD_ENTRA_APP_ID}`, +`workdaySamlEntityId` = `http://www.workday.com/{tenant}`, and `entraSSO` = +`true`. These two URI fields are independent and must never be aliases. **Message:** diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md index 2df032d5..518ab24c 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md @@ -62,7 +62,8 @@ read by later steps. Unknown/absent fields are treated as `null`. | `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. | +| `entraAppIdUri` | string | DA-2 | Entra Application ID URI (`api://{entraAppId}`), used for the exposed API scope. | +| `workdaySamlEntityId` | string | DA-2 | Workday SAML Service Provider ID / resource URL (`http://www.workday.com/{tenant}`). Never alias this to `entraAppIdUri`. | | `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. | @@ -150,7 +151,8 @@ evidence from the checked-in authorization script. "entraSSO": true, "entraAppId": "11111111-1111-1111-1111-111111111111", "entraAppObjectId": "22222222-2222-2222-2222-222222222222", - "appIdUri": "api://11111111-1111-1111-1111-111111111111", + "entraAppIdUri": "api://11111111-1111-1111-1111-111111111111", + "workdaySamlEntityId": "http://www.workday.com/acme_dpt1", "scopeGuid": "33333333-3333-3333-3333-333333333333", "oauthClientId": "WORKDAY_CLIENT_ID", "status": "in-progress", 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 index 89b8abbc..5efc08eb 100644 --- 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 @@ -21,17 +21,20 @@ not rephrase or narrate tool calls. 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. +- `ENTRA_APP_ID_URI` — the Entra Application ID URI + (`api://{entraAppId}`) from DA-2. +- `WORKDAY_SAML_ENTITY_ID` — the Workday SAML Service Provider ID and + connection resource URL (`http://www.workday.com/{tenant}`) from DA-2. **Outputs (written back to `.local/connect/workday-da/config.json`, see `config-schema.md`):** -- `appIdUri`, `oauthTokenUrl` / `tokenEndpoint`, `oauthClientId`, +- `entraAppIdUri`, `workdaySamlEntityId`, `oauthTokenUrl` / `tokenEndpoint`, + `oauthClientId`, `soapBaseUrl`, `restBaseUrl` (trimmed). --- -## C.1 — Application ID URI +## C.1 — Keep the two audience identifiers distinct The Application ID URI identifies the Entra app registration itself (`api://{entraAppId}`). DA-3 exposes it for the SAML token audience and the @@ -39,7 +42,7 @@ 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`: +- If `ENTRA_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: @@ -53,7 +56,7 @@ Entra resource URL" — see the note below. ] ``` -Save as `appIdUri`. +Save as `entraAppIdUri`. > **Not the connection resource URL.** The Workday connection asks for a > **Microsoft Entra resource URL** — the Workday SAML identifier @@ -61,6 +64,11 @@ Save as `appIdUri`. > Entity ID and Workday's SAML Service Provider ID), **not** this `api://…` App > ID URI. +Derive `WORKDAY_SAML_ENTITY_ID` independently from the validated Workday +tenant as `http://www.workday.com/{tenant}` and save it as +`workdaySamlEntityId`. Reject the data if these two identifiers are equal or if +the SAML entity ID does not exactly match the selected tenant. + --- ## C.2 — OAuth token URL diff --git a/tests/flightcheck/checks/test_workday_tenant.py b/tests/flightcheck/checks/test_workday_tenant.py index 3a6246ee..8953b050 100644 --- a/tests/flightcheck/checks/test_workday_tenant.py +++ b/tests/flightcheck/checks/test_workday_tenant.py @@ -52,7 +52,7 @@ def get(self, *_a: Any, **_k: Any): "tenant": "acme_dpt1", "restBaseUrl": "https://wd2-impl-services1.workday.com/ccx/api", "soapBaseUrl": "https://wd2-impl-services1.workday.com/ccx/service", - "appIdUri": "api://11111111-1111-1111-1111-111111111111", + "workdaySamlEntityId": "http://www.workday.com/acme_dpt1", } @@ -109,10 +109,11 @@ def test_tenant_echoes_connection_fields(self): "acme_dpt1", _FULL_CONFIG["restBaseUrl"], _FULL_CONFIG["soapBaseUrl"], - _FULL_CONFIG["appIdUri"], + _FULL_CONFIG["workdaySamlEntityId"], ): assert value in tenant.result assert "Service Provider ID" in tenant.result + assert "api://" not in tenant.result assert "Tenant Setup - Security" in tenant.remediation assert "intended employees" in tenant.remediation assert "Do not invent an OAuth-client condition" in tenant.remediation diff --git a/tests/flightcheck/test_cli_single_checkpoint.py b/tests/flightcheck/test_cli_single_checkpoint.py index 4e9e784d..a41e920d 100644 --- a/tests/flightcheck/test_cli_single_checkpoint.py +++ b/tests/flightcheck/test_cli_single_checkpoint.py @@ -246,6 +246,49 @@ def test_connect_config_only_merges_provider_owned_fields( "url": "https://foundation.example" } + def test_connect_config_flattens_v2_workday_state( + self, tmp_path: Path + ) -> None: + overlay = tmp_path / "provider.json" + overlay.write_text( + json.dumps( + { + "schemaVersion": 2, + "scope": { + "workdayTenant": "acme_impl", + "entraTenantId": "tenant-id", + "dataverseUrl": "https://acme.crm.dynamics.com", + }, + "identifiers": { + "entraAppId": "app-id", + "entraAppIdUri": "api://app-id", + "workdaySamlEntityId": ( + "http://www.workday.com/acme_impl" + ), + }, + "endpoints": { + "restBaseUrl": ( + "https://wd2-impl-services1.workday.com/ccx/api" + ) + }, + } + ), + encoding="utf-8", + ) + + merged = cli._merge_connect_config({}, str(overlay)) + + assert merged["tenant"] == "acme_impl" + assert merged["tenantId"] == "tenant-id" + assert merged["dataverseEndpoint"] == ( + "https://acme.crm.dynamics.com" + ) + assert merged["entraAppId"] == "app-id" + assert merged["appIdUri"] == "api://app-id" + assert merged["workdaySamlEntityId"] == ( + "http://www.workday.com/acme_impl" + ) + @pytest.mark.parametrize( "agent_slug", ( diff --git a/tests/scripts/test_workday_connect_contracts.py b/tests/scripts/test_workday_connect_contracts.py new file mode 100644 index 00000000..345cc47b --- /dev/null +++ b/tests/scripts/test_workday_connect_contracts.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path +import sys + +import pytest + + +SCRIPTS = ( + Path(__file__).resolve().parents[2] + / "solutions" + / "ess-maker-skills" + / "scripts" +) +sys.path.insert(0, str(SCRIPTS)) + +from workday_connect_contracts import ( # noqa: E402 + WorkdayConnectContractError, + build_entra_plan, + build_workday_admin_packet, +) +from workday_connect_model import default_state # noqa: E402 + + +def _state(): + state = default_state() + state["scope"].update( + { + "entraTenantId": "00000000-0000-0000-0000-000000000000", + "workdayTenant": "contoso_impl", + } + ) + state["phases"]["preflight"]["status"] = "complete" + return state + + +def test_entra_plan_selects_only_exact_service_provider_id(): + plan = build_entra_plan( + _state(), + { + "applications": [ + { + "displayName": "Workday wrong", + "appId": "11111111-1111-1111-1111-111111111111", + "objectId": "22222222-2222-2222-2222-222222222222", + "servicePrincipalId": "33333333-3333-3333-3333-333333333333", + "identifierUris": [ + "http://www.workday.com/contoso_impl_old" + ], + }, + { + "displayName": "Workday exact", + "appId": "44444444-4444-4444-4444-444444444444", + "objectId": "55555555-5555-5555-5555-555555555555", + "servicePrincipalId": "66666666-6666-6666-6666-666666666666", + "identifierUris": [ + "http://www.workday.com/contoso_impl/" + ], + }, + ] + }, + ) + + assert plan["target"]["displayName"] == "Workday exact" + assert plan["identifiers"]["workdaySamlEntityId"] == ( + "http://www.workday.com/contoso_impl" + ) + assert plan["identifiers"]["entraAppIdUri"] == ( + "api://44444444-4444-4444-4444-444444444444" + ) + assert plan["identifiers"]["workdaySamlEntityId"] != ( + plan["identifiers"]["entraAppIdUri"] + ) + assert len(plan["planHash"]) == 64 + + +def test_entra_plan_rejects_approval_before_exact_discovery(): + with pytest.raises(WorkdayConnectContractError, match="No exact"): + build_entra_plan(_state(), {"applications": []}) + + +def test_entra_plan_can_plan_explicit_creation(): + plan = build_entra_plan( + _state(), + {"applications": [], "allowCreate": True}, + ) + + assert plan["target"]["mode"] == "create" + assert plan["identifiers"]["entraAppIdUri"] is None + + +def test_workday_packet_uses_service_provider_id_not_app_id_uri(): + state = _state() + state["identifiers"].update( + { + "workdaySamlEntityId": "http://www.workday.com/contoso_impl", + "entraAppIdUri": ( + "api://44444444-4444-4444-4444-444444444444" + ), + } + ) + packet = build_workday_admin_packet(state) + + assert packet["referenceValues"]["serviceProviderId"] == ( + "http://www.workday.com/contoso_impl" + ) + assert packet["referenceValues"]["entraApplicationIdUri"].startswith( + "api://" + ) + assert "client secrets" in packet["responseForm"]["note"] + + +def test_workday_packet_rejects_identifier_aliasing(): + state = _state() + state["identifiers"].update( + { + "workdaySamlEntityId": "http://www.workday.com/contoso_impl", + "entraAppIdUri": "http://www.workday.com/contoso_impl", + } + ) + + with pytest.raises(WorkdayConnectContractError, match="remain distinct"): + build_workday_admin_packet(state) + + +def test_workday_packet_rejects_tenant_drift(): + state = _state() + state["identifiers"].update( + { + "workdaySamlEntityId": "http://www.workday.com/other", + "entraAppIdUri": ( + "api://44444444-4444-4444-4444-444444444444" + ), + } + ) + + with pytest.raises(WorkdayConnectContractError, match="does not match"): + build_workday_admin_packet(deepcopy(state)) From 1bf043ea4b02b542343ec53f23743fa990a284b2 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Fri, 25 Sep 2026 22:45:25 -0700 Subject: [PATCH 04/20] Consolidate Workday runtime follow-up checks --- .../scripts/workday_connect.py | 53 ++ .../scripts/workday_connect_runtime.py | 795 ++++++++++++++++++ tests/scripts/test_workday_connect_runtime.py | 291 +++++++ 3 files changed, 1139 insertions(+) create mode 100644 solutions/ess-maker-skills/scripts/workday_connect_runtime.py create mode 100644 tests/scripts/test_workday_connect_runtime.py diff --git a/solutions/ess-maker-skills/scripts/workday_connect.py b/solutions/ess-maker-skills/scripts/workday_connect.py index d0aa1a0e..c731dc41 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect.py +++ b/solutions/ess-maker-skills/scripts/workday_connect.py @@ -26,6 +26,10 @@ WorkdayConnectPreflightError, run_preflight, ) +from workday_connect_runtime import ( + WorkdayConnectRuntimeError, + run_runtime_operation, +) from workday_connect_store import ( WorkdayConnectPlanChangedError, WorkdayConnectStore, @@ -81,6 +85,15 @@ def build_parser() -> argparse.ArgumentParser: entra_plan.add_argument("--discovery-json", required=True) subparsers.add_parser("workday-admin-packet") + runtime_plan = subparsers.add_parser("runtime-plan") + runtime_plan.add_argument("--workday-connection-id") + runtime_plan.add_argument("--dataverse-connection-id") + + runtime_apply = subparsers.add_parser("runtime-apply") + runtime_apply.add_argument("--plan-hash", required=True) + runtime_apply.add_argument("--workday-connection-id") + runtime_apply.add_argument("--dataverse-connection-id") + preflight = subparsers.add_parser("preflight") preflight.add_argument("--dataverse-url") preflight.add_argument("--maker-username") @@ -145,6 +158,45 @@ def main() -> None: elif args.command == "workday-admin-packet": packet = build_workday_admin_packet(store.load()) _emit("workday-admin-packet", {"packet": packet}) + elif args.command == "runtime-plan": + _emit( + "runtime-plan", + run_runtime_operation( + store.load(), + apply=False, + workday_connection_id=args.workday_connection_id, + dataverse_connection_id=args.dataverse_connection_id, + ), + ) + elif args.command == "runtime-apply": + result = run_runtime_operation( + store.load(), + apply=True, + approved_hash=args.plan_hash, + verifier=lambda plan, approved_hash: store.verify_plan( + "runtime", + plan, + approved_hash, + ), + workday_connection_id=args.workday_connection_id, + dataverse_connection_id=args.dataverse_connection_id, + ) + for action, evidence in ( + ("connection-references-bound", "Dataverse reread"), + ("runtime-flows-active", "Dataverse reread"), + ("delegated-authorization-configured", "authorization script"), + ("user-context-v2-configured", "Dataverse reread"), + ): + store.complete_action( + "runtime", + action, + evidence={ + "outcome": "verified", + "provenance": evidence, + }, + ) + state = store.set_phase_status("runtime", "complete") + _emit("runtime-apply", {**result, "state": state}) elif args.command == "preflight": _emit( "preflight", @@ -224,6 +276,7 @@ def main() -> None: WorkdayConnectContractError, WorkdayConnectPlanChangedError, WorkdayConnectPreflightError, + WorkdayConnectRuntimeError, WorkdayConnectStoreError, ) as exc: print( diff --git a/solutions/ess-maker-skills/scripts/workday_connect_runtime.py b/solutions/ess-maker-skills/scripts/workday_connect_runtime.py new file mode 100644 index 00000000..d5803b98 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_connect_runtime.py @@ -0,0 +1,795 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Consolidated preview, apply, and verification for Workday runtime wiring.""" + +from __future__ import annotations + +import json +from pathlib import Path +import shutil +import subprocess +from typing import Any, Callable, Mapping +import uuid + +import yaml + +from auth import authenticate, query_all, update_record +from install_workday_da_extension import ( + ensure_pac_auth, + resolve_pac_executable, +) +from workday_connect_model import load_catalog, plan_hash + + +ACTIVE_FLOW_STATE = 1 +ACTIVE_FLOW_STATUS = 2 +CLOUD_FLOW_CATEGORY = 5 +SETUP_TOPIC_NAME = "[Admin] - User Context - Setup" +SETUP_SCHEMA_SUFFIX = ".topic.setusercontext" +TARGET_TOPIC_NAME = "Workday [System] - 1: Set User Context V2" +TARGET_SCHEMA_SUFFIX = ".topic.workdaysystemgetusercontextv2" +AUTHORIZATION_SCRIPT = "alm/Enable-CosmosDAFlowAuthorization.ps1" + + +class WorkdayConnectRuntimeError(RuntimeError): + """Raised when runtime configuration cannot proceed safely.""" + + +def _required_text( + document: Mapping[str, Any], + key: str, + label: str, +) -> str: + value = str(document.get(key) or "").strip() + if not value: + raise WorkdayConnectRuntimeError(f"{label} is required.") + return value + + +def _odata_literal(value: str) -> str: + return value.replace("'", "''") + + +def _connector_name(connection: Mapping[str, Any]) -> str: + api_id = str((connection.get("properties") or {}).get("apiId") or "") + return api_id.rstrip("/").rsplit("/", 1)[-1].casefold() + + +def _connected(connection: Mapping[str, Any]) -> bool: + statuses = (connection.get("properties") or {}).get("statuses") or [] + return any( + isinstance(status, Mapping) + and str(status.get("status") or "").casefold() == "connected" + for status in statuses + ) + + +def _select_connection( + connections: list[dict[str, Any]], + connector_name: str, + *, + explicit_id: str | None, +) -> dict[str, Any]: + matches = [ + value + for value in connections + if _connector_name(value) == connector_name.casefold() + and _connected(value) + and ( + not explicit_id + or str(value.get("name") or "").casefold() + == explicit_id.casefold() + ) + ] + if len(matches) != 1: + safe = [ + { + "id": value.get("name"), + "displayName": (value.get("properties") or {}).get( + "displayName" + ), + } + for value in matches + ] + raise WorkdayConnectRuntimeError( + f"Expected exactly one connected {connector_name} connection; " + f"found {len(matches)}. Candidates: " + f"{json.dumps(safe, sort_keys=True)}" + ) + return matches[0] + + +def _list_connections( + pac_executable: Path, + environment_url: str, + *, + runner: Callable[..., subprocess.CompletedProcess], +) -> list[dict[str, Any]]: + result = runner( + [ + str(pac_executable), + "connectivity", + "list-connections", + "--environment", + environment_url, + "--json", + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + check=False, + ) + if result.returncode != 0: + raise WorkdayConnectRuntimeError( + "PAC could not list the target environment's connections." + ) + try: + payload = json.loads(result.stdout or "") + except json.JSONDecodeError as exc: + raise WorkdayConnectRuntimeError( + "PAC returned invalid connection inventory JSON." + ) from exc + values = payload.get("value") + if not isinstance(values, list): + raise WorkdayConnectRuntimeError( + "PAC connection inventory did not contain a value array." + ) + return [value for value in values if isinstance(value, dict)] + + +def _single_rows( + rows: list[dict[str, Any]], + names: list[str], + key: str, + label: str, +) -> dict[str, dict[str, Any]]: + grouped = {name: [] for name in names} + for row in rows: + observed = str(row.get(key) or "").casefold() + for name in names: + if observed == name.casefold(): + grouped[name].append(row) + invalid = { + name: len(matches) + for name, matches in grouped.items() + if len(matches) != 1 + } + if invalid: + raise WorkdayConnectRuntimeError( + f"Expected exactly one installed {label} for each reviewed name; " + f"observed {json.dumps(invalid, sort_keys=True)}." + ) + return {name: grouped[name][0] for name in names} + + +def _runtime_references( + environment_url: str, + token: str, + logical_names: list[str], + *, + query: Callable[..., list[dict[str, Any]]], +) -> dict[str, dict[str, Any]]: + filters = " or ".join( + "connectionreferencelogicalname eq " + f"'{_odata_literal(name)}'" + for name in logical_names + ) + rows = query( + environment_url, + token, + "connectionreferences", + "connectionreferenceid,connectionreferencelogicalname," + "connectionreferencedisplayname,connectionid", + filters, + ) + return _single_rows( + rows, + logical_names, + "connectionreferencelogicalname", + "connection reference", + ) + + +def _runtime_flows( + environment_url: str, + token: str, + flow_names: list[str], + *, + query: Callable[..., list[dict[str, Any]]], +) -> dict[str, dict[str, Any]]: + filters = " or ".join( + f"name eq '{_odata_literal(name)}'" for name in flow_names + ) + rows = query( + environment_url, + token, + "workflows", + "workflowid,name,statecode,statuscode,category", + filters, + ) + flows = _single_rows(rows, flow_names, "name", "Workday flow") + non_cloud = [ + name + for name, row in flows.items() + if row.get("category") != CLOUD_FLOW_CATEGORY + ] + if non_cloud: + raise WorkdayConnectRuntimeError( + "Refusing to configure non-cloud workflow records: " + + ", ".join(sorted(non_cloud)) + ) + return flows + + +def _topic_document(data: str) -> dict[str, Any] | None: + if not data.strip(): + return {} + try: + document = yaml.safe_load(data) + except yaml.YAMLError: + return None + return document if isinstance(document, dict) else None + + +def _begin_dialog(document: Mapping[str, Any]) -> Mapping[str, Any]: + direct = document.get("beginDialog") + if isinstance(direct, Mapping): + return direct + dialog = document.get("dialog") + if isinstance(dialog, Mapping): + nested = dialog.get("beginDialog") + if isinstance(nested, Mapping): + return nested + return {} + + +def _redirect_state(data: str, target_schema: str) -> str: + document = _topic_document(data) + if document is None: + return "custom" + begin = _begin_dialog(document) + actions = begin.get("actions") + if isinstance(actions, list) and len(actions) == 1: + action = actions[0] + if ( + isinstance(action, Mapping) + and str(action.get("kind") or "").casefold() == "begindialog" + and str(action.get("dialog") or "").casefold() + == target_schema.casefold() + ): + return "configured" + if not document or ( + str(begin.get("kind") or "").casefold() == "onredirect" + and actions in (None, []) + ): + return "empty" + return "custom" + + +def _redirect_yaml(target_schema: str) -> str: + return ( + "kind: AdaptiveDialog\n" + "beginDialog:\n" + " kind: OnRedirect\n" + " id: main\n" + " priority: 0\n" + " actions:\n" + " - kind: BeginDialog\n" + " id: QVk2yi\n" + f" dialog: {target_schema}\n" + ) + + +def _runtime_topics( + environment_url: str, + token: str, + bot_id: str, + *, + query: Callable[..., list[dict[str, Any]]], +) -> dict[str, dict[str, Any]]: + try: + normalized_bot_id = str(uuid.UUID(bot_id)) + except ValueError as exc: + raise WorkdayConnectRuntimeError( + "The selected Workday agent has an invalid bot ID." + ) from exc + rows = query( + environment_url, + token, + "botcomponents", + "botcomponentid,name,schemaname,data,statecode,statuscode", + f"_parentbotid_value eq '{normalized_bot_id}' and componenttype eq 9", + ) + setup_matches = [ + row + for row in rows + if str(row.get("name") or "").casefold() + == SETUP_TOPIC_NAME.casefold() + or str(row.get("schemaname") or "").casefold().endswith( + SETUP_SCHEMA_SUFFIX + ) + ] + target_matches = [ + row + for row in rows + if str(row.get("name") or "").casefold() + == TARGET_TOPIC_NAME.casefold() + or str(row.get("schemaname") or "").casefold().endswith( + TARGET_SCHEMA_SUFFIX + ) + ] + if len(setup_matches) != 1 or len(target_matches) != 1: + raise WorkdayConnectRuntimeError( + "Expected exactly one Workday setup topic and one User Context V2 " + "topic in the selected agent." + ) + target_schema = _required_text( + target_matches[0], "schemaname", "User Context V2 topic schema" + ) + state = _redirect_state( + str(setup_matches[0].get("data") or ""), + target_schema, + ) + if state == "custom": + raise WorkdayConnectRuntimeError( + f"'{SETUP_TOPIC_NAME}' contains custom content. Refusing to " + "overwrite it automatically." + ) + return { + "setup": setup_matches[0], + "target": target_matches[0], + "redirectState": state, + } + + +def _default_runner(command: list[str], **kwargs) -> subprocess.CompletedProcess: + return subprocess.run(command, **kwargs) + + +def discover_runtime_plan( + state: Mapping[str, Any], + *, + workday_connection_id: str | None = None, + dataverse_connection_id: str | None = None, + catalog: Mapping[str, Any] | None = None, + token: str | None = None, + token_provider: Callable[..., str] = authenticate, + query: Callable[..., list[dict[str, Any]]] = query_all, + pac_resolver: Callable[[], Path] = resolve_pac_executable, + pac_auth: Callable[..., Any] = ensure_pac_auth, + runner: Callable[..., subprocess.CompletedProcess] = _default_runner, +) -> dict[str, Any]: + """Discover exact runtime targets and return a stable approval plan.""" + scope = state.get("scope") or {} + operators = state.get("operators") or {} + agent = scope.get("agent") or {} + environment_url = _required_text( + scope, "dataverseUrl", "Dataverse environment URL" + ).rstrip("/") + package_flavor = _required_text( + scope, "packageFlavor", "Workday package flavor" + ) + bot_id = _required_text(agent, "botId", "Workday agent bot ID") + maker = _required_text( + operators.get("powerPlatformMaker") or {}, + "username", + "Power Platform maker account", + ) + ring = str(scope.get("ring") or "prod").casefold() + pac_ring = "preprod" if ring in {"test", "preprod"} else "prod" + active_catalog = catalog or load_catalog() + package = (active_catalog.get("packages") or {}).get(package_flavor) + if not isinstance(package, Mapping): + raise WorkdayConnectRuntimeError( + f"Unknown Workday package flavor: {package_flavor}." + ) + flow_names = package.get("flowNames") + if not isinstance(flow_names, list) or not flow_names: + raise WorkdayConnectRuntimeError( + "This Workday architecture requires a manual runtime handoff; " + "no reviewed flow catalog is available." + ) + + pac = pac_resolver() + pac_auth( + pac, + ring=pac_ring, + environment_url=environment_url, + preferred_username=maker, + runner=runner, + ) + connections = _list_connections( + pac, + environment_url, + runner=runner, + ) + references_catalog = active_catalog["connectionReferences"] + workday = _select_connection( + connections, + references_catalog["workday"]["connectorName"], + explicit_id=workday_connection_id, + ) + dataverse = _select_connection( + connections, + references_catalog["dataverse"]["connectorName"], + explicit_id=dataverse_connection_id, + ) + + active_token = token or token_provider( + environment_url, + preferred_username=maker, + ) + logical_names = [ + references_catalog["workday"]["logicalName"], + references_catalog["dataverse"]["logicalName"], + ] + references = _runtime_references( + environment_url, + active_token, + logical_names, + query=query, + ) + flows = _runtime_flows( + environment_url, + active_token, + [str(name) for name in flow_names], + query=query, + ) + topics = _runtime_topics( + environment_url, + active_token, + bot_id, + query=query, + ) + target_connections = { + logical_names[0]: str(workday.get("name") or ""), + logical_names[1]: str(dataverse.get("name") or ""), + } + flow_targets = [ + { + "name": name, + "workflowId": _required_text( + flows[name], "workflowid", f"Workflow ID for {name}" + ), + } + for name in flow_names + ] + plan = { + "phase": "runtime", + "scope": { + "dataverseUrl": environment_url, + "botId": bot_id, + "packageFlavor": package_flavor, + "makerUsername": maker, + }, + "connectionBindings": target_connections, + "flows": flow_targets, + "userContext": { + "setupTopicId": _required_text( + topics["setup"], + "botcomponentid", + "User-context setup topic ID", + ), + "targetTopicSchema": _required_text( + topics["target"], + "schemaname", + "User Context V2 schema", + ), + }, + "delegatedAuthorization": { + "botId": bot_id, + "workflowIds": [target["workflowId"] for target in flow_targets], + "script": AUTHORIZATION_SCRIPT, + }, + "actions": [ + "Bind the reviewed Workday and Dataverse connection references", + "Activate the reviewed Workday runtime cloud flows", + "Authorize the selected agent to invoke each reviewed flow", + "Redirect the empty admin user-context scaffold to Workday User " + "Context V2", + "Reread and verify every changed Dataverse record", + ], + } + observed = { + "connectionBindings": { + name: references[name].get("connectionid") + for name in logical_names + }, + "flowStates": { + name: { + "statecode": flows[name].get("statecode"), + "statuscode": flows[name].get("statuscode"), + } + for name in flow_names + }, + "userContext": topics["redirectState"], + } + return { + "plan": {**plan, "planHash": plan_hash(plan)}, + "observed": observed, + } + + +def run_runtime_operation( + state: Mapping[str, Any], + *, + apply: bool, + approved_hash: str | None = None, + verifier: Callable[[Mapping[str, Any], str], Any] | None = None, + workday_connection_id: str | None = None, + dataverse_connection_id: str | None = None, + token_provider: Callable[..., str] = authenticate, + query: Callable[..., list[dict[str, Any]]] = query_all, + updater: Callable[..., bool] = update_record, + authorization_runner: Callable[ + ..., subprocess.CompletedProcess + ] = _default_runner, + **discovery_dependencies: Any, +) -> dict[str, Any]: + """Run runtime preview or apply while reusing one Dataverse token.""" + phases = state.get("phases") or {} + if (phases.get("connections") or {}).get("status") != "complete": + raise WorkdayConnectRuntimeError( + "Complete the Workday connection sign-ins before runtime wiring." + ) + scope = state.get("scope") or {} + operators = state.get("operators") or {} + environment_url = _required_text( + scope, "dataverseUrl", "Dataverse environment URL" + ).rstrip("/") + maker = _required_text( + operators.get("powerPlatformMaker") or {}, + "username", + "Power Platform maker account", + ) + token = token_provider( + environment_url, + preferred_username=maker, + ) + discovery = discover_runtime_plan( + state, + workday_connection_id=workday_connection_id, + dataverse_connection_id=dataverse_connection_id, + token=token, + token_provider=token_provider, + query=query, + **discovery_dependencies, + ) + if not apply: + return discovery + if not approved_hash or verifier is None: + raise WorkdayConnectRuntimeError( + "Runtime apply requires an approved plan hash." + ) + verifier(discovery["plan"], approved_hash) + applied = apply_runtime_plan( + discovery["plan"], + token=token, + query=query, + updater=updater, + authorization_runner=authorization_runner, + ) + return { + "plan": discovery["plan"], + "observedBeforeApply": discovery["observed"], + "applied": applied, + } + + +def _run_authorization( + plan: Mapping[str, Any], + *, + runner: Callable[..., subprocess.CompletedProcess], +) -> None: + shell = shutil.which("pwsh") or shutil.which("powershell") + if not shell: + raise WorkdayConnectRuntimeError( + "PowerShell is required for delegated flow authorization." + ) + authorization = plan["delegatedAuthorization"] + for workflow_id in authorization["workflowIds"]: + result = runner( + [ + shell, + "-NoProfile", + "-File", + str(Path(__file__).parent / authorization["script"]), + "-OrgUrl", + plan["scope"]["dataverseUrl"], + "-BotId", + authorization["botId"], + "-WorkflowId", + workflow_id, + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=600, + check=False, + ) + output = (result.stdout or "") + "\n" + (result.stderr or "") + if ( + result.returncode != 0 + or "[FAIL]" in output + or "Dataverse authorization is in place." not in output + ): + normalized = output.casefold() + if "403" in normalized or "forbidden" in normalized: + evidence = ( + "The authorization script received an explicit forbidden " + "response from Dataverse." + ) + elif "multiple" in normalized: + evidence = ( + "The authorization script found ambiguous existing " + "authorization records." + ) + elif "[fail]" in normalized: + evidence = ( + "The authorization script emitted an explicit [FAIL] " + "result." + ) + else: + evidence = ( + f"The authorization script exited with code " + f"{result.returncode} without its success marker." + ) + raise WorkdayConnectRuntimeError( + "Delegated flow authorization failed for workflow " + f"{workflow_id}. {evidence}" + ) + + +def apply_runtime_plan( + plan: Mapping[str, Any], + *, + token: str, + query: Callable[..., list[dict[str, Any]]] = query_all, + updater: Callable[..., bool] = update_record, + authorization_runner: Callable[ + ..., subprocess.CompletedProcess + ] = _default_runner, +) -> dict[str, Any]: + """Apply one approved runtime plan with one shared Dataverse token.""" + environment_url = plan["scope"]["dataverseUrl"] + bindings = plan["connectionBindings"] + references = _runtime_references( + environment_url, + token, + list(bindings), + query=query, + ) + for logical_name, target_id in bindings.items(): + if ( + str(references[logical_name].get("connectionid") or "").casefold() + == str(target_id).casefold() + ): + continue + updater( + environment_url, + token, + "connectionreferences", + _required_text( + references[logical_name], + "connectionreferenceid", + f"Connection reference ID for {logical_name}", + ), + {"connectionid": target_id}, + ) + + flow_names = [value["name"] for value in plan["flows"]] + flows = _runtime_flows( + environment_url, + token, + flow_names, + query=query, + ) + for target in plan["flows"]: + flow = flows[target["name"]] + if ( + flow.get("statecode") == ACTIVE_FLOW_STATE + and flow.get("statuscode") == ACTIVE_FLOW_STATUS + ): + continue + updater( + environment_url, + token, + "workflows", + target["workflowId"], + { + "statecode": ACTIVE_FLOW_STATE, + "statuscode": ACTIVE_FLOW_STATUS, + }, + ) + + _run_authorization(plan, runner=authorization_runner) + + setup_topic_id = plan["userContext"]["setupTopicId"] + topic_rows = query( + environment_url, + token, + "botcomponents", + "botcomponentid,data", + f"botcomponentid eq '{_odata_literal(setup_topic_id)}'", + ) + if len(topic_rows) != 1: + raise WorkdayConnectRuntimeError( + "The approved user-context setup topic is no longer unique." + ) + redirect_state = _redirect_state( + str(topic_rows[0].get("data") or ""), + plan["userContext"]["targetTopicSchema"], + ) + if redirect_state == "custom": + raise WorkdayConnectRuntimeError( + "The user-context setup topic changed after approval." + ) + if redirect_state == "empty": + updater( + environment_url, + token, + "botcomponents", + setup_topic_id, + { + "data": _redirect_yaml( + plan["userContext"]["targetTopicSchema"] + ) + }, + ) + + verified_references = _runtime_references( + environment_url, + token, + list(bindings), + query=query, + ) + wrong_bindings = [ + name + for name, target_id in bindings.items() + if str(verified_references[name].get("connectionid") or "").casefold() + != str(target_id).casefold() + ] + verified_flows = _runtime_flows( + environment_url, + token, + flow_names, + query=query, + ) + inactive = [ + name + for name, row in verified_flows.items() + if row.get("statecode") != ACTIVE_FLOW_STATE + or row.get("statuscode") != ACTIVE_FLOW_STATUS + ] + verified_topic = query( + environment_url, + token, + "botcomponents", + "botcomponentid,data", + f"botcomponentid eq '{_odata_literal(setup_topic_id)}'", + ) + topic_ok = ( + len(verified_topic) == 1 + and _redirect_state( + str(verified_topic[0].get("data") or ""), + plan["userContext"]["targetTopicSchema"], + ) + == "configured" + ) + if wrong_bindings or inactive or not topic_ok: + raise WorkdayConnectRuntimeError( + "Runtime post-write verification failed: " + f"bindings={wrong_bindings}, flows={inactive}, " + f"userContext={topic_ok}." + ) + return { + "verified": True, + "connectionBindings": bindings, + "flows": flow_names, + "userContext": plan["userContext"]["targetTopicSchema"], + "delegatedAuthorization": "verified-by-script", + } diff --git a/tests/scripts/test_workday_connect_runtime.py b/tests/scripts/test_workday_connect_runtime.py new file mode 100644 index 00000000..78bba07d --- /dev/null +++ b/tests/scripts/test_workday_connect_runtime.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +import json +import sys + +import pytest + + +SCRIPTS = ( + Path(__file__).resolve().parents[2] + / "solutions" + / "ess-maker-skills" + / "scripts" +) +sys.path.insert(0, str(SCRIPTS)) + +import workday_connect_runtime as runtime # noqa: E402 +from workday_connect_model import default_state # noqa: E402 + + +BOT_ID = "11111111-1111-1111-1111-111111111111" +WORKDAY_CONNECTION = "22222222-2222-2222-2222-222222222222" +DATAVERSE_CONNECTION = "33333333-3333-3333-3333-333333333333" + + +def _state(): + state = default_state() + state["scope"].update( + { + "dataverseUrl": "https://org.crm.dynamics.com", + "packageFlavor": "runtime", + "ring": "prod", + "agent": {"botId": BOT_ID}, + } + ) + state["operators"]["powerPlatformMaker"] = { + "username": "maker@contoso.com" + } + state["phases"]["connections"]["status"] = "complete" + return state + + +def _connections(): + return { + "value": [ + { + "name": WORKDAY_CONNECTION, + "properties": { + "apiId": "/providers/Microsoft.PowerApps/apis/shared_workdaysoap", + "displayName": "Workday", + "statuses": [{"status": "Connected"}], + }, + }, + { + "name": DATAVERSE_CONNECTION, + "properties": { + "apiId": ( + "/providers/Microsoft.PowerApps/apis/" + "shared_commondataserviceforapps" + ), + "displayName": "Dataverse", + "statuses": [{"status": "Connected"}], + }, + }, + ] + } + + +def _pac_runner(command, **_kwargs): + if command[1:3] == ["auth", "list"]: + return SimpleNamespace( + returncode=0, + stdout="[1] * maker@contoso.com Public\n", + stderr="", + ) + if command[1:3] == ["connectivity", "list-connections"]: + return SimpleNamespace( + returncode=0, + stdout=json.dumps(_connections()), + stderr="", + ) + raise AssertionError(command) + + +def _records(): + catalog = runtime.load_catalog() + logical_names = [ + catalog["connectionReferences"]["workday"]["logicalName"], + catalog["connectionReferences"]["dataverse"]["logicalName"], + ] + flow_names = catalog["packages"]["runtime"]["flowNames"] + return { + "references": { + logical_names[0]: { + "connectionreferenceid": "ref-workday", + "connectionreferencelogicalname": logical_names[0], + "connectionid": None, + }, + logical_names[1]: { + "connectionreferenceid": "ref-dataverse", + "connectionreferencelogicalname": logical_names[1], + "connectionid": None, + }, + }, + "flows": { + name: { + "workflowid": f"44444444-4444-4444-4444-{index:012d}", + "name": name, + "statecode": 0, + "statuscode": 1, + "category": 5, + } + for index, name in enumerate(flow_names, start=1) + }, + "setup": { + "botcomponentid": "setup-topic", + "name": runtime.SETUP_TOPIC_NAME, + "schemaname": "contoso.topic.setusercontext", + "data": ( + "kind: AdaptiveDialog\n" + "beginDialog:\n" + " kind: OnRedirect\n" + " actions: []\n" + ), + "statecode": 0, + "statuscode": 1, + }, + "target": { + "botcomponentid": "target-topic", + "name": runtime.TARGET_TOPIC_NAME, + "schemaname": "contoso.topic.workdaysystemgetusercontextv2", + "data": "", + "statecode": 0, + "statuscode": 1, + }, + } + + +def _query_for(records): + def query(_url, _token, entity_set, _select, filter_expr=None): + if entity_set == "connectionreferences": + return list(records["references"].values()) + if entity_set == "workflows": + return list(records["flows"].values()) + if entity_set == "botcomponents": + if filter_expr and "componenttype eq 9" in filter_expr: + return [records["setup"], records["target"]] + return [records["setup"]] + raise AssertionError(entity_set) + + return query + + +def _discovery_dependencies(records): + return { + "query": _query_for(records), + "pac_resolver": lambda: Path("pac.exe"), + "runner": _pac_runner, + } + + +def test_runtime_plan_uses_one_dataverse_token_and_exact_targets(): + records = _records() + token_calls = [] + + result = runtime.run_runtime_operation( + _state(), + apply=False, + token_provider=lambda url, preferred_username: token_calls.append( + (url, preferred_username) + ) + or "token", + **_discovery_dependencies(records), + ) + + assert token_calls == [ + ("https://org.crm.dynamics.com", "maker@contoso.com") + ] + assert result["plan"]["connectionBindings"] == { + runtime.load_catalog()["connectionReferences"]["workday"][ + "logicalName" + ]: WORKDAY_CONNECTION, + runtime.load_catalog()["connectionReferences"]["dataverse"][ + "logicalName" + ]: DATAVERSE_CONNECTION, + } + assert len(result["plan"]["flows"]) == 3 + assert result["plan"]["userContext"]["targetTopicSchema"].endswith( + "workdaysystemgetusercontextv2" + ) + assert "token" not in json.dumps(result).casefold() + + +def test_runtime_plan_stops_on_custom_user_context(): + records = _records() + records["setup"]["data"] = ( + "kind: AdaptiveDialog\n" + "beginDialog:\n" + " kind: OnRedirect\n" + " actions:\n" + " - kind: SendActivity\n" + " activity: custom\n" + ) + + with pytest.raises( + runtime.WorkdayConnectRuntimeError, + match="custom content", + ): + runtime.run_runtime_operation( + _state(), + apply=False, + token_provider=lambda *_args, **_kwargs: "token", + **_discovery_dependencies(records), + ) + + +def test_runtime_apply_verifies_all_mutations(monkeypatch): + records = _records() + verified_hashes = [] + + def updater(_url, _token, entity_set, record_id, data): + if entity_set == "connectionreferences": + for value in records["references"].values(): + if value["connectionreferenceid"] == record_id: + value.update(data) + return True + if entity_set == "workflows": + for value in records["flows"].values(): + if value["workflowid"] == record_id: + value.update(data) + return True + if entity_set == "botcomponents" and record_id == "setup-topic": + records["setup"].update(data) + return True + raise AssertionError((entity_set, record_id, data)) + + monkeypatch.setattr(runtime.shutil, "which", lambda _name: "pwsh.exe") + + def authorization_runner(command, **_kwargs): + assert command[-2] == "-WorkflowId" + return SimpleNamespace( + returncode=0, + stdout="Dataverse authorization is in place.", + stderr="", + ) + + result = runtime.run_runtime_operation( + _state(), + apply=True, + approved_hash="approved", + verifier=lambda plan, approved_hash: verified_hashes.append( + (plan["planHash"], approved_hash) + ), + token_provider=lambda *_args, **_kwargs: "token", + updater=updater, + authorization_runner=authorization_runner, + **_discovery_dependencies(records), + ) + + assert verified_hashes == [(result["plan"]["planHash"], "approved")] + assert result["applied"]["verified"] is True + assert all( + value["connectionid"] + for value in records["references"].values() + ) + assert all( + value["statecode"] == 1 and value["statuscode"] == 2 + for value in records["flows"].values() + ) + assert runtime._redirect_state( + records["setup"]["data"], + records["target"]["schemaname"], + ) == "configured" + + +def test_runtime_requires_completed_connection_phase(): + state = _state() + state["phases"]["connections"]["status"] = "active" + + with pytest.raises( + runtime.WorkdayConnectRuntimeError, + match="connection sign-ins", + ): + runtime.run_runtime_operation( + state, + apply=False, + token_provider=lambda *_args, **_kwargs: "token", + **_discovery_dependencies(_records()), + ) From 995c35bdbb45e6092677a1f61909f97cacb02b65 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Fri, 25 Sep 2026 22:47:36 -0700 Subject: [PATCH 05/20] Streamline Workday setup follow-up flow --- .../scripts/workday_connect_runtime.py | 2 +- .../src/skills/connect/SKILL.md | 5 +- .../src/skills/setup/workday-da/SKILL.md | 317 ++------ .../workday-da/configure-power-platform.md | 311 ++------ .../setup/workday-da/configure-tenant.md | 512 ++---------- .../setup/workday-da/install-extension.md | 157 +--- .../setup/workday-da/provision-entra-app.md | 745 ++---------------- .../workday-da/shared/checklist-updater.md | 307 -------- .../setup/workday-da/shared/config-schema.md | 218 ++--- .../workday-da/shared/connection-fields.md | 151 ---- .../workday-da/shared/permission-gate.md | 165 ---- .../src/skills/setup/workday-da/tasks.md | 114 --- .../setup/workday-da/verify-connection.md | 111 +-- tests/scripts/test_workday_connect_runtime.py | 4 +- tests/setup/test_workday_da_foundation.py | 156 ++-- tests/setup/test_workday_da_orchestration.py | 76 +- 16 files changed, 418 insertions(+), 2933 deletions(-) delete mode 100644 solutions/ess-maker-skills/src/skills/setup/workday-da/shared/checklist-updater.md delete mode 100644 solutions/ess-maker-skills/src/skills/setup/workday-da/shared/connection-fields.md delete mode 100644 solutions/ess-maker-skills/src/skills/setup/workday-da/shared/permission-gate.md delete mode 100644 solutions/ess-maker-skills/src/skills/setup/workday-da/tasks.md diff --git a/solutions/ess-maker-skills/scripts/workday_connect_runtime.py b/solutions/ess-maker-skills/scripts/workday_connect_runtime.py index d5803b98..471b4951 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_runtime.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_runtime.py @@ -531,7 +531,7 @@ def run_runtime_operation( ) -> dict[str, Any]: """Run runtime preview or apply while reusing one Dataverse token.""" phases = state.get("phases") or {} - if (phases.get("connections") or {}).get("status") != "complete": + if apply and (phases.get("connections") or {}).get("status") != "complete": raise WorkdayConnectRuntimeError( "Complete the Workday connection sign-ins before runtime wiring." ) diff --git a/solutions/ess-maker-skills/src/skills/connect/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/SKILL.md index df8406c2..4efe7ce6 100644 --- a/solutions/ess-maker-skills/src/skills/connect/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/connect/SKILL.md @@ -52,14 +52,13 @@ Workday routes by architecture before package detection: - **CEA full/legacy package or no package** — stop at the current unsupported installation boundary without changing state. - **DA HR agent** — use `src/skills/setup/workday-da/SKILL.md` for the - resumable package, Entra, tenant, Power Platform, and runtime checklist. + resumable six-phase controller lifecycle. - **DA IT or another DA agent** — unsupported for Workday in this release; stop before creating state or entering a Workday lifecycle. CEA per-agent lifecycle state is stored at `.local/connect/workday/agents/{agent-slug}/lifecycle.json`. DA Workday state - is stored in `.local/connect/workday-da/config.json` and - `.local/setup/workday-da/tasks.md`. + is stored only in `.local/connect/workday-da/config.json`. Each integration's steps.md and config.json persist after completion. Running `/connect` again lets the user add a different integration diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md index e9f7c46f..88d7e558 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md @@ -1,253 +1,66 @@ -# Workday Connect (DA) — Orchestrator - -Every **Message** block is the exact text to show the user. Copy it verbatim. Do -not rephrase, add commentary, or tell the user what tools you are calling or what -files you are reading. - -This router sequences the five Workday connect steps for the **ESS HR agent**, -using the master checklist as a -**resume-aware spine**: it renders the working checklist on first run, resumes at -the first unverified step, and dispatches to the owning step's playbook. It -**never** advances past a `MANUAL` / attestation row on a flightcheck pass alone — -those require explicit user acknowledgement (enforced by -[`shared/checklist-updater.md`](./shared/checklist-updater.md)). - -This skill assumes the Employee Self-Service base agent itself is already -installed — that's owned by `/setup`, not by this skill. DA-1 checks for it and -sends you to `/setup` first if it isn't there yet. - -This release does not support Workday for the ESS DA IT Agent. Before creating -the working checklist or reading provider state, resolve `activeAgent` from -`.local/config.json` and require an ESS DA HR agent entry with a stable slug, -`botId`, and HR schema name. Then read the canonical -`.local/setup/config.json` `agents` record keyed by that `botId` and require -canonical workspace evidence plus `steps.SETUP-07.state: "done"`. Do not -require `connect_ready: true`; configuring Workday may resolve the remaining -runtime connection blocker. Do not use the retired `selected_products` field -or choose the first agent in a multi-agent workspace. If the target is IT, -Hub, CEA, ambiguous, incomplete, or unresolved, show: - -**Message:** - -This Workday setup supports the ESS HR agent only. Select the ESS HR agent, -or contact your administrator if it isn't available. - -**End message.** - -Stop immediately without creating or updating any Workday state. This guard is -required even though the `/connect workday` router performs the same check, -because this file must remain safe if invoked directly. - ---- - -## Handling Workday credentials — never put secrets in chat - -The Workday **password is a secret**. **Never** ask for it with a chat question -(`vscode_askQuestions`, or a plain "paste your Workday password" message) — the -chat question tool has no masked-input option, so anything typed is recorded -verbatim in the transcript. - -When a Workday secret is genuinely required (only the FlightCheck Workday SOAP -workflow tests need one), it is collected **exclusively** through a masked -input that keeps the value out of chat history: - -- the `.vscode/mcp.json` `workdayPass` input — a `promptString` with - `"password": true`, which VS Code masks and substitutes directly into the - check environment, or -- the FlightCheck CLI's own `getpass` prompt when you run - `python scripts/flightcheck/cli.py --scope workdayda` in the terminal. - -Non-secret connection identifiers (tenant, SOAP/REST/token URLs, OAuth client -ID, App ID URI) are safe to capture in chat — see -[`shared/connection-fields.md`](./shared/connection-fields.md). The Workday -**username** is likewise not masked (`"password": false`); only the password is. - ---- - -## Start - -1. **Show the readiness briefing.** After the DA HR agent guard and routing - FlightChecks have passed, show this before creating or resuming the - checklist. Show it on every invocation so a resumed setup makes its - remaining administrator dependencies clear. - - **Message:** - - Here's the plan for connecting Workday to your ESS HR agent. Some steps - require administrators outside the maker role, so involve them now if you - don't hold these permissions: - - | Phase | What we'll do | Who is needed | - | --- | --- | --- | - | Workday extension | Install or verify the Workday package for the ESS HR agent | Power Platform Environment Maker | - | Microsoft Entra | Configure Workday SSO, API permission, consent, user assignment, NameID, and SAML signing | Entra Application Administrator or Cloud Application Administrator; a consent-capable administrator if required | - | Workday tenant | Configure tenant security, the API client, functional areas, endpoints, authentication policy, and certificate trust | Workday Administrator | - | Power Platform connections | Configure Workday OAuthUser and Dataverse connections, shared parameters, bindings, and cloud flows | Power Platform Environment Maker | - | Agent authorization | Preview and run the Dataverse bot-to-flow authorization script | Power Platform Administrator with Dataverse System Administrator access | - | Network readiness | Allow the required Workday REST and SOAP hosts | InfoSec or network administrator | - | Topics and validation | Select Workday topics and validate a real signed-in employee scenario | Environment Maker, Workday test employee, and Workday Administrator if remediation is needed | - - I'll automate checks and supported changes where reliable APIs are - available. For Workday or portal-only settings, I'll give the responsible - administrator the exact steps and wait for confirmation. I won't mark the - environment ready until the signed-in Workday scenario succeeds. - - **End message.** - -2. **Working copy.** If `.local/setup/workday-da/tasks.md` does not exist, render - it by copying the template `src/skills/setup/workday-da/tasks.md`. Do not - hand-edit its status markers — the shared checklist-updater writes them. - -3. **Resume point.** Read `setupStatus` in `.local/connect/workday-da/config.json` - (the durable source of truth; the tasks file is only the view). If the file or - the `setupStatus` key is missing, treat every row as `pending`. A row counts as - complete only when `setupStatus["{Step}"].state` is `"done"`. - -4. **Show the checklist, then find where to resume.** Determine each item's state - from `setupStatus`: ✅ = `done`, 🔄 = `in-progress`, ⛔ = `blocked`, ⬜ = - `pending` or unset. Show the checklist **grouped exactly as in the template** — - the group headings and item titles below are verbatim from - `src/skills/setup/workday-da/tasks.md`; render every group and every item, - replacing each `{m}` with that item's marker. **Never show Step IDs or - checkpoint IDs.** - - **Message:** - - Here's the checklist for connecting Workday to your agent: - - **1. Workday extension package** - - {m} Install the Workday extension package - - **2. Connect Microsoft Entra sign-in to Workday** - - {m} Set up Workday sign-in - - {m} Allow Power Platform to call Workday - - {m} Approve the sign-in permissions - - {m} Choose who can use Workday - - {m} Match the signed-in employee - - {m} Sign the Workday sign-in response - - {m} Confirm the correct Microsoft Entra tenant - - **3. Workday tenant configuration** - - {m} Register the Workday API client - - {m} Capture your Workday connection details - - {m} Verify employee SAML sign-in policy - - {m} Match the signing certificate - - **4. Power Platform and agent integration** - - {m} Create the Workday connection - - {m} Create the Microsoft Dataverse connection - - {m} Bind the extension connections - - {m} Turn on the Workday cloud flows - - {m} Connect Workday to the agent - - {m} Authorize the agent to use the Workday flows - - {m} Configure employee context and topics - - {m} Allow Workday through the firewall - - **5. Validate Workday readiness** - - {m} Validate a signed-in Workday scenario - - Picking up at: {title of the first item whose state is not `done`}. - - **End message.** - - Then walk the items in Step order (DA1.1, DA2.1 … DA5.1 — these IDs are - internal only), pick the first whose state is not `done`, and dispatch by that - Step in **Dispatch** below. A step's playbook may re-run its own idempotent - foundation steps (role gate, resource lookup) ahead of the resume item to - rehydrate in-memory state — follow the playbook's stated build order rather - than jumping straight into it. - -5. If **every** item is `done`, also require provider `status` to be `"ready"` - before showing **All done**. If every row is done but status is not ready, - treat DA5.1 as `in-progress` and dispatch to DA-5 to reconcile readiness; - never claim success from checklist state alone. - ---- - -## Dispatch - -**Persist each row the moment its checkpoint passes.** Every step calls -[`shared/checklist-updater.md`](./shared/checklist-updater.md) per row, inline — -updating both the working checklist and the durable `setupStatus` mirror -immediately — and **must not** batch those writes to the end of its run. This -keeps progress crash-safe: if a step errors midway, the rows already verified -stay complete and this router resumes at the first row that isn't. - -### DA1.1 — Install the Workday extension package (DA-1) - -Read `src/skills/setup/workday-da/install-extension.md` and follow it. That -playbook checks the DA base agent is installed and sends the user to `/setup` -if it isn't, attempts an automated install of the Workday extension package, -falls back to a guided manual AppSource install if automation isn't available -in this tenant, verifies the package landed (`WD-DA-PKG-001`), and updates row -**DA1.1** through the shared checklist-updater. - -When it returns, go back to **Start** to resume at the next unverified row. - -### DA2.1 through DA2.7 — Provision the Workday Entra app (DA-2) - -Read `src/skills/setup/workday-da/provision-entra-app.md` and follow it. That -playbook role-gates (App / Cloud Application Administrator), instantiates and -configures the Workday SSO gallery app, exposes the API scope and -pre-authorizes the Workday connector, grants and consents the Graph -permissions, assigns the enterprise app, sets the NameID mapping and SAML -signing option, and confirms single-tenant federation. It verifies each -outcome (`WD-CONN-102`, `WD-ENTRA-SCOPE-001`, `WD-ENTRA-CONSENT-001`, -`WD-ASSIGN-001`, `WD-ENTRA-NAMEID-001`, `WD-ENTRA-SIGNOPT-001`, `WD-CONN-010`) -and updates rows **DA2.1**–**DA2.7** through the shared checklist-updater -(DA2.1/DA2.6 manual and DA2.7 attest rows need acknowledgement). On resume it -always re-runs its role gate and DA2.1 (create the SSO app) first — both -idempotent — before the first incomplete row, since DA2.2–DA2.4 depend on the -in-memory app object id that only DA2.1 populates. - -When it returns, go back to **Start** to resume at the next unverified row. - -### DA3.1 through DA3.4 — Configure the Workday tenant (DA-3) - -Read `src/skills/setup/workday-da/configure-tenant.md` and follow it. That -playbook role-gates (Workday Administrator, by attestation), records the -current single-tenant SAML federation before any change, uploads and verifies -the X.509 signing certificate (`WD-CONN-102`), edits Tenant Setup – Security, -registers the Workday API client and captures the connection fields -(`WD-API-CLIENT-001`), and verifies the signed-in employee SAML policy -(`WD-TENANT-001`) — updating rows **DA3.1**–**DA3.4** through the shared -checklist-updater. All four are manual Workday-admin tasks (attest / manual -gates) that need acknowledgement; `WD-API-CLIENT-001` and `WD-TENANT-001` -report `MANUAL`. On resume it always re-runs its role gate and the -single-tenant SAML pre-check first — both idempotent — before the first -incomplete row. - -When it returns, go back to **Start** to resume at the next unverified row. - -### DA4.1 through DA4.8 — Configure Power Platform and agent integration (DA-4) - -Read `src/skills/setup/workday-da/configure-power-platform.md` and follow it. -That playbook guides creation of the Workday and Dataverse connections, binds -the installed solution references, activates the runtime flows, connects the -flows to the agent with parameter sharing, applies checked-in script -authorization, configures DA V2 employee context and topic selection, and -records firewall allowlisting. It updates rows **DA4.1**–**DA4.8** through the -shared checklist-updater. Manual and attestation rows require explicit -evidence; DA4.3, supported DA4.4 activation, and DA4.6 are programmatic. - -When it returns, go back to **Start** to resume at DA5.1. - -### DA5.1 — Validate Workday readiness (DA-5) - -Read `src/skills/setup/workday-da/verify-connection.md` and follow it. That -playbook re-runs `WD-DA-PKG-001`, summarizes all setup areas, and requires a -successful signed-in employee Workday scenario. It updates **DA5.1** only after -runtime evidence is captured and sets provider `status` to `"ready"`. - -When it returns, go back to **Start** — every row should now be `done`. - -## All done - -**Message:** - -Your ESS HR agent is connected to Workday and the signed-in employee path -has been validated in this environment. The Workday connection is ready; you -do not need to run `/setup` again. - -**End message.** +# Connect Workday to the ESS HR agent + +This skill is a thin conversational client for +`scripts/workday_connect.py`. The controller and +`.local/connect/workday-da/config.json` own lifecycle state. Do not create, +copy, update, or infer status from a Markdown checklist. + +## Safety contract + +- Support only the active ESS HR agent recorded by `/setup`. The controller + verifies the exact agent, workspace materialization, architecture, and + Dataverse environment during preflight. +- Never ask for a Workday password, client secret, access token, refresh token, + cookie, certificate private key, or certificate body in chat. +- Explain an authentication prompt before launching it. Azure CLI/Graph, PAC, + Dataverse, connector OAuth, and Agent Builder are separate credential stores; + a prompt for a different store is expected, but a valid store must not be + prompted twice for the same account and session. +- Preview the exact target and actions before approval. After approval, verify + the plan hash immediately before every mutation. If discovery or scope + changes, discard the approval and show the new plan. +- After every mutation, reread the target and persist evidence only after the + verified result matches the approved plan. +- Never diagnose a permission problem from a guess. Show the API, CLI, or + checked-in script evidence that produced the diagnosis. + +## Start or resume + +Run: + +```powershell +python scripts/workday_connect.py initialize +python scripts/workday_connect.py status +``` + +Show only the returned `progressText`, current blocker when present, and the +next phase. Do not render internal action IDs, hashes, or the full JSON state. + +The six customer-facing phases are: + +1. Preflight +2. Microsoft Entra +3. Workday administrator +4. Connections +5. Runtime configuration +6. Employee validation + +Dispatch from `nextPhaseId`: + +- `preflight` -> read `install-extension.md` +- `entra` -> read `provision-entra-app.md` +- `workday-admin` -> read `configure-tenant.md` +- `connections` or `runtime` -> read `configure-power-platform.md` +- `employee-validation` -> read `verify-connection.md` + +When a phase returns, run `status` again and continue from the controller's +next phase. Never restart completed phases because the user asked a side +question; answer the side question, then resume the same blocker. + +## Completion + +Only show the following after controller status is `ready`: + +> Your ESS HR agent is connected to Workday, and the signed-in employee path +> has been validated in this environment. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md index 94e2e29a..c9f4f87b 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md @@ -1,278 +1,73 @@ -# DA-4 — Configure Power Platform and Agent Integration +# Phases 4 and 5 - Connections and runtime -Role: **Environment Maker**, with a **Power Platform Administrator** for -bot-to-flow authorization and **InfoSec/IT** for network allowlisting. This step -applies the Workday and Entra values captured earlier to the installed ESS DA HR -extension. It owns checklist rows **DA4.1 through DA4.8**. +## Connections -Every **Message** block is the exact text to show the user. Copy it verbatim. Do -not claim that a manual portal setting was verified automatically. +Ask the maker to create or confirm exactly two connected Power Platform +connections in the selected environment: -The two required runtime connection references are: +- Workday OAuthUser, using the Workday SAML resource URL, token URL, and + Workday OAuth client ID from controller state; +- Microsoft Dataverse, using the selected maker account. -| Connection | Logical name | -| --- | --- | -| Workday OAuthUser | `msdyn_sharedworkdaysoap_workdayruntime` | -| Microsoft Dataverse | `msdyn_sharedcommondataserviceforapps_workdayruntime` | +Explain that Workday connector OAuth is another credential store and may open +its own sign-in. Do not ask the maker to paste connection IDs. -Never reuse Dev connection IDs, bot IDs, or workflow IDs in another -environment. +Run runtime discovery: ---- +```powershell +python scripts/workday_connect.py runtime-plan +``` -## DA4.0 — Prepare the connections page +The command uses PAC to discover connected physical connections and one shared +Dataverse session to discover the installed connection references, reviewed +flows, selected agent, and User Context V2 topics. -This setup always uses the signed-in employee Workday runtime. Do not present -an installation-path or migration choice. +- If exactly one connection exists for each connector, discovery is + deterministic. +- If more than one exists, show safe display names and ask which connection to + use, then rerun with `--workday-connection-id` and/or + `--dataverse-connection-id`. +- If none exists or a connection is not connected, leave the phase waiting and + show the exact missing connector. -Build the environment's Connections URL from the recorded ring: +After successful discovery, record evidence and set `connections` to +`complete`. -- `preprod` → - `https://make.preprod.powerautomate.com/environments/{ENV_ID}/connections` -- `prod` → - `https://make.powerautomate.com/environments/{ENV_ID}/connections` +## Runtime approval and apply -Persist `installPath: "simplified"`. +Show one combined runtime plan: -## DA4.1 — Connect the Workday OAuthUser reference +- bind the two reviewed connection references; +- activate only the checked-in Workday flow catalog; +- authorize the exact agent to invoke those exact workflow IDs; +- redirect only an empty admin user-context scaffold to Workday User Context + V2; and +- reread every changed record. -**Message:** +If the setup topic contains custom content, stop and preserve it. Do not +overwrite or approximate the topic. -Now we'll create the two connections the Workday runtime needs. +Approve the exact plan: -Open this environment's **Connections** page: +```powershell +python scripts/workday_connect.py approve-plan --phase runtime --plan-json '{...}' +``` -{CONNECTIONS_URL} +Apply using the returned hash and the same disambiguating connection IDs, if +any: -1. Select **New connection**, search for **Workday**, and create a connection - using **Microsoft Entra ID Integrated** authentication. -3. Enter the values below. Complete the sign-in/consent window if one opens. -4. Wait until the Workday connection shows **Connected**. +```powershell +python scripts/workday_connect.py runtime-apply --plan-hash "{hash}" +``` -Use the values captured earlier: +The controller rediscovers the current target, rejects stale approval, reuses +one Dataverse token for Python mutations, invokes the checked-in delegated +authorization script, and verifies bindings, flow state, authorization, and +User Context V2 after the write. Report permission issues only from an +explicit forbidden response, `[FAIL]` marker, ambiguity result, or nonzero +script exit. -- Microsoft Entra resource URL: the Workday SAML identifier configured for - this tenant, not the `api://` application ID URI. -- OAuth token URL: `{oauthTokenUrl}`. -- Workday API client ID: `{oauthClientId}`. -- SOAP base URL: `{soapBaseUrl}`. -- REST base URL: `{restBaseUrl}`. It must end exactly at `/api`. - -**End message.** - -If no Workday connection exists yet, do not ask the maker to confirm the -reference binding—guide the connection creation first. Re-read the ring-native -connection inventory and confirm the new `shared_workdaysoap` connection is -`Connected` and carries the expected resource, token, client, SOAP, REST, and -tenant values. -Record DA4.1 with `GATE="manual"`, `ACK=true`, and evidence describing the -connection name and environment. If it is not connected, leave the row -`in-progress`. - -## DA4.2 — Connect the Dataverse reference - -**Message:** - -On the same **Connections** page, select **New connection** and create a -**Microsoft Dataverse** connection with your maker account. Wait until both the -Workday and Dataverse connections show **Connected**, then tell me they are -ready. - -**End message.** - -Re-read the ring-native connection inventory and confirm the Dataverse -connection is `Connected` and belongs to this environment. Record DA4.2 with -the connection name and environment in the evidence. - -## DA4.3 — Bind the extension connections - -Bind the installed solution references programmatically: - -1. Resolve the physical Workday and Dataverse connection IDs created in - DA4.1–DA4.2. -2. PATCH `msdyn_sharedworkdaysoap_workdayruntime.connectionid` to the Workday - connection ID. -3. PATCH - `msdyn_sharedcommondataserviceforapps_workdayruntime.connectionid` to the - Dataverse connection ID. -4. Re-read both rows and confirm the IDs persisted. -5. Confirm neither reference points to a connection from a different - environment or user. - -Preview the two target logical names and connection display names before -PATCHing. The authorization script does not perform this step. Record DA4.3 -only after the post-write verification passes. - -## DA4.4 — Turn on the Workday cloud flows - -Do not activate flows until DA4.1–DA4.3 are complete and the two installed -runtime `connectionreference` rows have non-empty connection bindings. A flow -whose references are unbound may activate but will fail at runtime. - -Discover the Workday flows installed with the ESS DA HR extension when a -reliable DA-scoped listing is available. If they can be enabled through the -supported Power Platform API, preview the affected flows and ask for approval -before enabling them. - -Otherwise show: - -**Message:** - -Open **Power Apps → Solutions → Workday → Cloud flows**. Turn on every flow used -by the ESS HR agent, then confirm they all show **On**. Do not enable unrelated -flows from other solutions. - -**End message.** - -Record DA4.4 only after every target Workday flow is verified as active. - -## DA4.5 — Connect the agent and share parameters - -**Message:** - -The Workday and Dataverse connections are ready and the runtime flows are on. -Now connect those flows to this agent: - -1. Open the active agent's **Copilot Studio → Settings → Connection settings** - page: - - `https://{CPS_HOST}/environments/{ENV_ID}/copilots/{BOT_ID}/da-settings/connectionSettings` -2. Open each Workday flow entry and select **Connect**. -3. Select the Workday connection created earlier and submit. -4. Under **Manage**, select **See details**. -5. Open **Connection parameters**. -6. Turn on **Allow permission to share parameters** and save. - -If the parameter values appear empty, turn the setting off and save, turn it -back on and save again, then confirm the REST, SOAP, token, client, and resource -values remain populated. - -**End message.** - -This is agent/runtime wiring; solution-level binding does not replace it. Do -not infer it from the physical connection inventory's `allowSharing` property. -Require explicit confirmation that every Workday flow entry is connected, -parameter sharing is enabled, the fields remain populated, and the connection -is connected. If a connection is **Stale** or **Needs attention**, reconnect it -before continuing. Record DA4.5 as manual. - -## DA4.6 — Authorize the DA to use the Workday flows - -Use the checked-in authorization script: - -`scripts/alm/Enable-CosmosDAFlowAuthorization.ps1` - -Execute this PowerShell file directly. Do not translate, regenerate, or replace -it with Python. PowerShell 7 is preferred; Windows PowerShell 5.1 is also -supported by the script syntax. The script validates the Azure CLI Dataverse -token before use. If that token is rejected (including PPE environments), it -automatically reuses the kit's Dataverse authentication cache and opens the -standard kit sign-in only when a refresh is required. - -Resolve parameters instead of asking the maker to paste GUIDs: - -- `OrgUrl`: `.local/config.json` `dataverseEndpoint` when present; otherwise - `.local/connect/workday-da/config.json` `sidecarDataverseEndpoint`. This must - be the same effective Dataverse environment used by DA-1. -- `BotId`: active ESS DA HR agent → `agent.botId`. -- `WorkflowId[]`: the target Workday workflow IDs referenced by the active - agent's Workday topics. Resolve topic `flowId` values to Dataverse - `workflowid` values and exclude unrelated flows. -- `TeamName`: a deterministic name containing the agent and environment. - -If the bot or workflow set cannot be resolved unambiguously, stop and explain -which value is missing. Never guess or run the script with a partial flow set. - -Before invoking the checked-in script, perform the same read-only -delegated-authorization and team lookups documented by the script: - -- exactly one MCSBot delegated authorization and one linked Access team already - exist for the bot → the script reuses them and adds missing workflow shares; -- no authorization or team exists → the script may create them. Its Dataverse - writes request `Prefer: return=representation`, so the new record IDs are - captured and bound in the same run; -- more than one delegated authorization or linked team exists → stop and - require administrator remediation. The script also fails closed on these - ambiguous records. - -First run the script with `-WhatIf`, show the target organization, agent, and -flow display names, and obtain explicit approval. Then run the same command -without `-WhatIf`. - -The script's `-WhatIf` run may exit `1` after showing a correct `would create` -or `would share` plan. This happens because its final verification checks for -records and shares that `-WhatIf` intentionally did not write. Treat that -preview as acceptable only when the target values are correct and every -`[FAIL]` corresponds exactly to a listed preview operation. Authentication, -permission, lookup, missing-flow, wrong-target, conflicting-existing-record, or -multiple-team errors remain blocking. Do not apply based on an ambiguous -preview. - -DA4.6 passes only when the applied result has exactly one linked Access team, -the script exits with code `0`, ends with -`Dataverse authorization is in place.`, returns one access team for the target -bot, contains no `[FAIL]` line, and confirms `WriteAccess` for every supplied -workflow. On any failure, -leave the row blocked and show the script error. Do not replace this with an -attestation. - -After successful apply verification, update DA4.6 through -[`shared/checklist-updater.md`](shared/checklist-updater.md) with -`STEP_ID="DA4.6"`, `GATE="prog"`, and -`CHECKPOINT_RESULT="PASSED"`, `RESULT_SOURCE="external"`, and -`EXTERNAL_EVIDENCE` containing the target environment, bot, workflow display -names, script exit code, and verification summary. Render that summary instead -of reading `workspace/flightcheck/results.json`. On an apply or verification -failure, use `CHECKPOINT_RESULT="FAILED"`, `RESULT_SOURCE="external"`, and the -safe failure summary so the row becomes blocked. - -## DA4.7 — Configure employee context and Workday topics - -Inspect the installed DA package before changing the agent. Do not assume the -CEA topic name or file shape. Identify the package's V2 signed-in-user context -component that uses the Workday `/workers/me` path. - -Present these choices: - -1. **Enable all Workday topics** — recommended for makers who want the complete - Workday experience. -2. **Choose specific Workday topics** — show a multi-select list of available - business scenarios. -3. **Keep the current topic selection** — make no topic-status changes. - -Whichever option is selected, include the V2 signed-in-user context and every -system dependency required by the selected business topics. Preview the exact -topic list and obtain approval before changing anything. Confirm: - -- the DA-equivalent V2 user-context component is enabled and wired; -- selected topics are enabled; -- unselected topics remain disabled; -- choosing **Enable all** enables every installed Workday business topic plus - the required Workday system topics. - -Topic activation is server-only state and is not stored in the topic YAML. -The current AgentBuilder client can fetch components, update the bot entity, -import, and publish, but it has no proven per-component status mutation API. -Until a supported API is added, do not guess a MinimalBot payload. Provide the -equivalent Copilot Studio enablement steps, including the **Enable all** -selection, and record DA4.7 as manual after confirmation. - -## DA4.8 — Record firewall allowlisting - -**Message:** - -Your InfoSec/IT team must allow outbound access from the Power Platform Workday -managed connectors to these Workday hosts: - -- REST: `{restBaseUrl host}` -- SOAP: `{soapBaseUrl host}` - -Has that allowlisting been put in place for this environment? - -**End message.** - -This is an attestation, not a local connectivity test. Record DA4.8 only after -explicit acknowledgement and captured evidence. - -Return to the orchestrator. +Topic/business-scenario selection that is not represented by a reviewed +deterministic helper remains a concise manual handoff; do not expand it into a +per-topic internal checklist. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md index 9bedc8a8..c7c154e3 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md @@ -1,468 +1,46 @@ -# 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`, `entraAppIdUri`, `workdaySamlEntityId`, 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** - (its Service Provider ID does not exactly match this tenant's - `workdaySamlEntityId` 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 -Workday SAML Identifier (Entity ID), -`http://www.workday.com/{tenant}` — not the `api://...` Application ID URI. -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`). -- `ENTRA_APP_ID_URI` — the Entra `entraAppIdUri` from DA-2. -- `WORKDAY_SAML_ENTITY_ID` — the `workdaySamlEntityId` 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`, -`entraAppIdUri`, and `workdaySamlEntityId` 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` / -`workdaySamlEntityId` 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.** +# Phase 3 - Workday administrator + +Generate one administrator handoff: + +```powershell +python scripts/workday_connect.py workday-admin-packet +``` + +Show the packet once as a single ordered task list. Do not split it into +repeated confirmations or rerun manual-only FlightChecks that merely repeat +the same instructions. + +The Workday administrator must: + +1. Identify the currently enabled SAML identity-provider row and confirm its + Service Provider ID. Stop if it belongs to another federation. +2. Upload the active Entra SAML signing certificate and compare its validity + dates with Workday. +3. Set the exact Service Provider ID to + `http://www.workday.com/{workdayTenant}`. This is not the + `api://{entraAppId}` Application ID URI. +4. Enable OAuth 2.0 Clients and SAML in Tenant Setup - Security. +5. Register the signed-in employee API client with Core Payroll, + Organizations and Roles, Staffing, Time Off and Leave, and Include Workday + Owned Scope. +6. Verify an active authentication policy allows SAML for the intended + employees without replacing existing administrator or network safeguards. + +Collect one response form containing only: + +- enabled Service Provider ID; +- certificate Valid From and Valid To dates; +- Workday OAuth client ID; +- OAuth token URL; +- authentication-policy outcome. + +Never collect a secret, password, token, cookie, certificate body, or private +key. Validate the Service Provider ID against the selected tenant and derive +the SOAP and REST endpoints deterministically. The REST base must end exactly +at `/ccx/api`. + +Merge validated non-secret values into `identifiers` and `endpoints`, record +the administrator evidence with `complete-action`, and set +`workday-admin` to `complete`. If the administrator is not available, record +the packet with `record-handoff` and return without losing prior progress. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md index 8722bcbf..e7919145 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md @@ -1,143 +1,40 @@ -# DA-1 — Install the Workday Extension Package +# Phase 1 - Preflight -Every **Message** block is the exact text to show the user. Copy it verbatim. Do -not rephrase, add commentary, or tell the user what tools you are calling or what -files you are reading. +Run the controller authentication briefing once: -This step completes **DA1.1** on the Workday connect checklist. It confirms the -ESS HR agent is available, then installs its Workday package. The router must -stop DA IT agents before this file is read. - ---- - -## P1.0 — Check for the DA base agent, and install the extension if it's missing - -Resolve the target environment automatically: - -1. Use `.local/config.json` `dataverseEndpoint` when present (legacy - Dataverse-backed workspace). -2. Otherwise read `.local/connect/workday-da/config.json` - `sidecarDataverseEndpoint`. -3. If neither exists, show the environments available to the - signed-in account and ask the maker to choose one. Do not ask them to type or - copy a URL when a selectable environment is available. Persist the selected - Dataverse URL as `sidecarDataverseEndpoint`. - -Call the resolved value `WORKDAY_DATAVERSE_URL`. Never copy it into -`.local/config.json`; that file's native `powerPlatformApiEndpoint` remains the -agent identity boundary. If `.local/connect/workday-da/config.json` does not -yet exist, create it as an empty JSON object before the first checkpoint; if it -exists, preserve all current fields. - -Resolve the package flavor from the active agent schema: - -- `gptagent_copilotforemployeeselfservicehr` → `runtime` -- `msdyn_copilotforemployeeselfservicedahr` → `legacy-da` - -Call this value `PACKAGE_FLAVOR`. Stop if the active agent does not match one -of these supported HR schemas. - -Run the checkpoint that reports both facts at once — whether a DA base agent -exists, and whether Workday is already installed against it: - -``` -python scripts/flightcheck/cli.py --checkpoint WD-DA-PKG-001 --connect-config ".local/connect/workday-da/config.json" +```powershell +python scripts/workday_connect.py auth-plan ``` -Read the checkpoint result from `workspace/flightcheck/results.json`. The only -supported target is `hr`. An IT base agent or IT Workday package in the same -environment is outside this lifecycle and must not affect DA1.1. - -- **`PASSED`** → the required HR Workday extension package is already installed. - Show the result, record `verticals: ["hr"]` into - `.local/connect/workday-da/config.json`, and go to **record DA1.1** below. -- **`FAILED`** with "No ESS DA HR agent was found in this environment" or - "An ESS DA IT agent is installed, but no ESS DA HR agent was found" → the - supported HR base agent isn't installed. Stop here — this skill doesn't - install the base agent. - - **Message:** - - I don't see an Employee Self-Service HR agent installed in this environment - yet. Run `/setup` first to install it, then come back and run - `/connect workday` again. +Explain only credential stores that may prompt during this phase: - **End message.** +- Dataverse browser sign-in verifies the exact environment and maker account. +- PAC may use device-code sign-in to inspect or install the Workday package. + PAC is a separate Microsoft credential store, so this can be one additional + sign-in. The controller pins and verifies the resulting PAC account. - Halt this skill entirely — do not proceed to DA-2 or DA-3. +Then run: -- **`FAILED`** with "The Workday package required by the ESS HR agent is not - installed" → the HR base agent is present but Workday isn't installed yet. - Continue to **P1.1**. -- Any other **`FAILED`** result → show the result and stop. Do not guess - whether installation is safe from an unrecognized failure reason. -- **`WARNING` / `SKIPPED`** (Dataverse verification could not run, e.g. - authentication, permissions, endpoint initialization, or a transient error) - → show the result verbatim, keep DA1.1 `in-progress`, and stop; ask the user - to resolve the underlying issue and re-run this step. Never attempt package - installation from an inconclusive result. - ---- - -## P1.1 — Attempt an automated install - -``` -python scripts/install_workday_da_extension.py --url "{WORKDAY_DATAVERSE_URL}" --vertical "hr" --package-flavor "{PACKAGE_FLAVOR}" --ring "{RING}" +```powershell +python scripts/workday_connect.py preflight ``` -Read `RING` from canonical setup state `environment.ring`; use `prod` only for -legacy state with no recorded ring. Run the command once. The installer selects -or creates a PAC profile for that ring and PAC polls AppSource installation -internally. - -Parse the script's JSON marker line: - -- **`INSTALLED_WORKDAY_DA_EXTENSION_JSON:`** → the HR package installed (or was - already installed and the script confirmed it). Re-run -`--checkpoint WD-DA-PKG-001` with the same `--connect-config`; proceed only -when it reports `PASSED`. -- **`WORKDAY_PACKAGE_INSTALL_FAILED_JSON:`** → show its concise `error` value - and stop with DA1.1 `in-progress`. Do not claim the package needs a manual - AppSource installation. PAC's output is the source of truth: - - If PAC CLI is missing, explain that it is the local tool used to install - the Workday package, then ask once whether the maker wants the kit to - install a current-user managed copy. Do not present .NET and PAC as - unexplained product setup steps. If approved, run: - - ```powershell - dotnet tool install --tool-path "$env:LOCALAPPDATA\InternalTools\pac" --interactive --verbosity n --configfile "scripts\managed-pac.nuget.config" Microsoft.PowerApps.CLI.Tool - ``` - - If the tool is already present but needs repair or update, run the same - command with `update` instead of `install`. Then rerun P1.1. - If the managed install reports that a .NET SDK is missing, explain that it - is required only to install the local PAC tool. Obtain approval before - installing it, and resume at DA1.1 afterward; do not restart the Workday - checklist or repeat completed rows. - - If PAC starts device-code authentication, wait for it to finish. - - If multiple profiles exist for the required ring, ask the maker to select - the intended profile with `pac auth select`, then retry. - - For permission or package-availability errors, show PAC's output and ask - the maker to correct that exact issue before retrying. - ---- - -## Record DA1.1 - -When `WD-DA-PKG-001` is `PASSED`: - -1. Merge `verticals: ["hr"]` and `vertical: "hr"` into - `.local/connect/workday-da/config.json` (round-trip merge — never drop other - keys). Remove any stale `it` entry written by a pre-release version. -2. Call [`shared/checklist-updater.md`](./shared/checklist-updater.md) with - `STEP_ID = "DA1.1"`, `CHECKPOINT_RESULT = "PASSED"`, `GATE = "prog"`. - -**Message:** +Use `--dataverse-url` only when canonical setup state has no exact Dataverse +URL. Use `--maker-username` only to pin an intended maker account or resolve +account ambiguity; never ask for it when the authenticated account is already +unambiguous. -The Workday extension package is installed for the **ESS HR Agent**. +The command performs the complete phase: -**End message.** +- verifies the selected setup-complete ESS HR agent; +- chooses the architecture-specific Workday package; +- verifies the exact Dataverse URL directly rather than relying on inventory + visibility; +- verifies the authenticated account and Entra tenant; +- detects or installs the package through PAC; and +- rereads Dataverse to prove the package is installed. -Return to the DA orchestrator (`src/skills/setup/workday-da/SKILL.md`, -**Start**) to resume at the next unverified row. +On failure, show the controller's concise error and preserve its blocker. Do +not replace a precise PAC, authentication, or package error with a generic +manual-install instruction. On success, return to `SKILL.md`. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md index ae35a2ba..4caeb05b 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md @@ -1,700 +1,75 @@ -# 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 +# Phase 2 - Microsoft Entra + +This phase configures one exact Workday SAML application. It requires an +Application Administrator or Cloud Application Administrator; administrator +consent may require a consent-capable role. + +## Discover before approval + +1. Read the canonical Entra tenant ID and Workday tenant from controller state. + If the Workday tenant is missing, ask for the signed-in Workday URL and + validate its first path segment against + `^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`, then merge it into `scope` as + `workdayTenant`. +2. Align Azure CLI to the canonical Entra tenant. Explain that this is the + Microsoft Graph/Azure CLI credential store before any sign-in. +3. Query the signed-in user's directory roles by stable role-template ID. + Never authorize from a localized display name and never downgrade a failed + privileged-role query to self-attestation. +4. List application and service-principal identity together. Match only exact, + normalized equality with: + + ```text + http://www.workday.com/{workdayTenant} ``` -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: + Never select by display name alone and never use substring matching. -- `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: +Build discovery JSON with `displayName`, application `appId`, application +`objectId`, service-principal `servicePrincipalId`, and `identifierUris`, then +run: - ``` - 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 - } -] +```powershell +python scripts/workday_connect.py entra-plan --discovery-json '{...}' ``` -**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. +If no exact app exists, include `"allowCreate": true` only after the user +chooses to create the Workday gallery app. If multiple apps have the exact +Service Provider ID, stop for administrator remediation. -Before persisting or interpolating the tenant, require: +Show the returned target, Service Provider ID, Entra Application ID URI, +permissions, and actions. Obtain one approval for this exact plan, then store +it: -- 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 +```powershell +python scripts/workday_connect.py approve-plan --phase entra --plan-json '{...}' ``` -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 without replacing the SAML - identifier.** The application must retain both distinct identifier URIs: - `http://www.workday.com/{tenant}` for Workday SAML and - `api://{WD_ENTRA_APP_ID}` for the exposed API scope. Do not call the generic - B.4a command because it replaces the entire `identifierUris` collection. - PATCH the application with both exact values, generate `SCOPE_GUID`, then - apply - [`connect/azure/app-registration.md`](../../connect/azure/app-registration.md) - **§B.4b** to expose `user_impersonation`. - -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`, `entraAppIdUri` = `api://{WD_ENTRA_APP_ID}`, -`workdaySamlEntityId` = `http://www.workday.com/{tenant}`, and `entraSSO` = -`true`. These two URI fields are independent and must never be aliases. - -**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 +## Apply and verify -**Message:** +Immediately before each Graph mutation, run `verify-plan` with the current +plan and approved hash. Apply only the approved actions: -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). +- reuse the exact app or instantiate the Workday gallery app; +- configure SAML mode and the signing certificate; +- retain both distinct identifier URIs: + - Workday SAML Service Provider ID: + `http://www.workday.com/{workdayTenant}` + - Entra Application ID URI: `api://{entraAppId}` +- expose `user_impersonation`; +- pre-authorize Workday connector app + `4e4707ca-5f53-46a6-a819-f7765446e6ff`; +- add `openid`, `profile`, and `User.Read`; +- grant administrator consent; +- configure user assignment, NameID, and SAML signing as required. -**End message.** +Reread the application and service principal. Persist `entraAppId`, +`entraAppObjectId`, `entraAppIdUri`, `workdaySamlEntityId`, `scopeGuid`, and +safe certificate metadata under `identifiers`. Never persist certificate +contents. -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. +Record verified actions with `complete-action`, then set the `entra` phase to +`complete`. If a portal-only setting remains, record one explicit handoff and +set the phase to `waiting`; resume by rereading the setting, not by repeating +all instructions. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/checklist-updater.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/checklist-updater.md deleted file mode 100644 index 0b81fedf..00000000 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/checklist-updater.md +++ /dev/null @@ -1,307 +0,0 @@ -# Master-Checklist Updater (DA) - -The single routine every DA Workday setup skill calls to update **its own rows** -in the DA master checklist. Centralizing it means each skill records status the -same way, and the **MANUAL/attestation rule** below is enforced in exactly one -place. - -Forked from the CEA `setup/shared/checklist-updater.md` with DA-scoped state -paths (`.local/setup/workday-da/tasks.md`, `.local/connect/workday-da/config.json`). -The logic is identical — only the persisted files differ — so the two skills can -evolve independently. - -Every **Message** block is the exact text to show the user. Copy it verbatim. Do -not narrate tool calls. - -**Inputs from the calling file:** -- `STEP_ID` — the DA master checklist Step ID to update (e.g. `"DA3.1"`, - `"DA2.4"`). See the canonical rows in the checklist template - `src/skills/setup/workday-da/tasks.md`. A skill updates **only** the Step IDs - it owns. -- `NEW_STATE` — `"in-progress"` \| `"done"` \| `"blocked"`. -- `CHECKPOINT_RESULT` — the flightcheck result for the row's checkpoint, one of - `PASSED` \| `FAILED` \| `ERROR` \| `WARNING` \| `MANUAL` \| - `NOT_CONFIGURED` \| `SKIPPED` \| `null` (null = not run yet). -- `GATE` — the row's gate type: `"prog"` \| `"manual"` \| `"attest"` \| - `"advisory"` (from the DA master checklist row; also recorded in config per - `config-schema.md`). -- `ACK` — *(manual/attest rows only)* `true` once the user has explicitly - acknowledged the step and any evidence has been captured; otherwise `false`. -- `RESULT_SOURCE` — `"flightcheck"` (default) when `CHECKPOINT_RESULT` came - from the current FlightCheck results file, or `"external"` when a - programmatic operation produced its own structured evidence. -- `EXTERNAL_EVIDENCE` — required when `RESULT_SOURCE="external"`; a safe - summary proving the operation's target, outcome, and verification. Never - include credentials, tokens, or raw sensitive output. -- `GATE_EVIDENCE` — optional structured evidence returned by - `permission-gate.md`. Preserve it under the row's `gateEvidence` field; do - not store the object in scalar `verifiedBy`. -- `ROW_EVIDENCE` — required before a `manual`/`attest` row can complete. It is - a safe object with `outcome`, `provenance`, `note`, and `capturedAt`; - `provenance` identifies the source such as `flightcheck`, - `user-acknowledgement`, or `external-operation`. - -**Outputs:** -- The matching checklist item in `.local/setup/workday-da/tasks.md` is updated - in place (checkbox + hidden `status:` field). -- The mirror record `setupStatus["{STEP_ID}"]` in - `.local/connect/workday-da/config.json` is updated (see `config-schema.md`). - ---- - -## Files - -- **Working copy (read/write):** `.local/setup/workday-da/tasks.md` — the - rendered, human-readable checklist. Rendered on first run from the template - `src/skills/setup/workday-da/tasks.md` (the canonical row source). If the - working copy doesn't exist yet, render it from the template before updating. -- **Durable mirror:** `setupStatus` in `.local/connect/workday-da/config.json`. - The tasks file is the view; `setupStatus` is the source of truth a later - step reads to know what's already done. - -Row shape in `tasks.md` (each item in the checklist template -`src/skills/setup/workday-da/tasks.md`): a checkbox line the user sees, followed -by an HTML comment the tooling reads. - -``` -- [ ] **** — - -``` - -- `- [ ]` / `- [x]` is the at-a-glance done marker. -- The hidden `id:` field is the `STEP_ID`; the hidden `status:` field carries the - full four-state value a single checkbox can't express. -- **Never surface a Step ID, checkpoint ID, or the hidden comment to the user** — - they see the checkbox and its description only. - ---- - -## U.0 — Show the checkpoint result to the user (in chat) - -**Timing — render this the instant a checkpoint run returns, and for a -manual/attest row *before* you ask the attestation question.** When a -`python scripts/flightcheck/cli.py --checkpoint ` run just produced -`CHECKPOINT_RESULT`, surface that result to the user — the U.0 table **and** the -U.0a manual steps — before touching any state and before any attestation, so every -checkpoint run has a visible outcome. Single-checkpoint runs never open the HTML -report, so this in-chat render is the only place the user sees the outcome; never -ask a user to attest to manual steps they have not been shown. If you already -rendered this checkpoint's result this pass (per a skill's post-checkpoint display -convention), do not repeat it — proceed to U.1. The U.1–U.3 status update below -runs afterwards (once any attestation is answered) and does **not** re-display the -result. - -- Skip this step when `RESULT_SOURCE="external"`; show `EXTERNAL_EVIDENCE` - using the calling playbook's operation-specific result instead. Also skip - when `CHECKPOINT_RESULT` is `null` (the checkpoint was not run this pass), - or when `workspace/flightcheck/results.json` does not exist. - -Read `workspace/flightcheck/results.json` — the run that led here wrote it. It has: - -```json -{ "results": [ { "checkpoint_id": "...", "description": "...", "status": "..." }, ... ] } -``` - -Render a GitHub-flavoured markdown table in chat, **one row per entry** in -`results`, using `description` verbatim for **Check** and `status` verbatim for -**Status**: - -``` -| Check | Status | -| --- | --- | -| | | -``` - -Rules: -- **Never** include `checkpoint_id`, the Step ID, or any other internal - identifier — there is no ID column; `description` is the only label shown. -- If a `description` or `status` contains a `|`, escape it as `\|`; collapse any - newline to a single space. -- If `results` is empty, render **no** table. -- This table is **in addition to** the row's own **Message** blocks and the - manual verification steps below (see U.0a) — it does not replace or alter them. -- Draw the table yourself in chat. Do not mention `results.json`, file paths, or - the tools used to produce it. - ---- - -## U.0a — Show the manual verification steps to the user (in chat) - -Do this right after the U.0 table, before touching any state. A -`python scripts/flightcheck/cli.py --checkpoint ` run **never opens the HTML -report** — for `MANUAL` checks the verification steps must appear **in chat**, not -in a browser popup. This routine is what puts them there. - -- Skip this step when `RESULT_SOURCE="external"`; the calling playbook has - already shown `EXTERNAL_EVIDENCE`. Also skip when `CHECKPOINT_RESULT` is - `null`, or when `workspace/flightcheck/results.json` does not exist. - -Each entry in `results.json` carries the full text of what the operator must do — -not just `description`/`status` but also the finding and the how-to: - -```json -{ "checkpoint_id": "...", "description": "...", "status": "Manual", - "result": "", - "remediation": "" } -``` - -For **every** entry in `results` whose `status` is `Manual` (also `Warning` or -`NotConfigured`, when present), render a block in chat — one per entry, in the -order they appear — using `description` as the heading, then `result`, then -`remediation`: - -``` -**** - - - - -``` - -Rules: -- Copy `result` and `remediation` **verbatim** — keep the numbered/bulleted steps - and every line break. Do **not** summarise, shorten, re-order, or paraphrase the - steps; the operator follows them exactly. -- Still **never** surface `checkpoint_id`, the Step ID, or the hidden comment. -- Do **not** open, mention, or link `report.html` — the steps live in chat now. -- If no entry has a `Manual`/`Warning`/`NotConfigured` status, render no block. -- Do not mention `results.json`, file paths, or the tools used to produce it. - ---- - -## U.1 — Locate the item - -Read `.local/setup/workday-da/tasks.md` (render from the template first if -absent). Find the checklist item whose hidden comment has `id:` equal to -`STEP_ID`. - -- If no such item exists, **stop and report** — a skill must not invent items. - The canonical item set lives in the checklist template - `src/skills/setup/workday-da/tasks.md`; a missing item means the template is - out of date, not that the updater should add one. -- If `STEP_ID` is **not** owned by the calling skill, **stop** — skills update - only their own items. - ---- - -## U.2 — Determine the new Status (the MANUAL/attestation rule) - -This is the load-bearing rule. **A `MANUAL` or attestation-gated row is never -auto-completed by a flightcheck pass.** - -First apply failure precedence: for every non-advisory row, -`CHECKPOINT_RESULT = FAILED` or `ERROR` always produces `blocked`, regardless -of `ACK`, `NEW_STATE`, or gate evidence. An acknowledgement records that a -person saw or performed a step; it never overrides an objective failure. - -Otherwise decide `Status` as follows: - -| `GATE` | Condition | Resulting `Status` | -|--------|-----------|--------------------| -| `prog` | `CHECKPOINT_RESULT` = `PASSED` | `done` | -| `prog` | `CHECKPOINT_RESULT` = `WARNING` / `NOT_CONFIGURED` / `SKIPPED` / `MANUAL` / `null` | `in-progress` | -| `manual` / `attest` | `ACK` = `true`, `ROW_EVIDENCE` is complete, and result is not `FAILED`/`ERROR` | `done` | -| `manual` / `attest` | `ACK` = `false` or `ROW_EVIDENCE` is missing | `in-progress` | -| `advisory` | the advisory step has been run and its report shown (or attempted and skipped) | `done` | - -Notes: -- An `advisory` row is not backed by a flightcheck checkpoint (`CHECKPOINT_RESULT` - is `null`). It **never blocks** — it completes to `done` once its advisory - output has been presented to the user, regardless of what the output found. If - the advisory step can't run, note it and still complete the row (advisory rows - never hold up the setup). -- A `CHECKPOINT_RESULT` of `MANUAL` means "the checkpoint reported what it could, - but completion needs a human." It **never** maps to `done` on its own — it - requires `ACK = true`. -- For `prog` rows, `NEW_STATE` from the caller must be consistent with - `CHECKPOINT_RESULT`; if they conflict, the checkpoint result wins (it's the - objective signal). -- For a `prog` row with `RESULT_SOURCE="external"`, `PASSED` is valid only when - non-empty `EXTERNAL_EVIDENCE` is supplied. Otherwise treat the result as - `null` and leave the row `in-progress`. - -If the row is `manual`/`attest` and `ACK` is `false`, before leaving the row -`in-progress` confirm the user actually saw the manual step. (Precondition: the -manual verification steps — U.0a — for this row's checkpoint must already have been -rendered in chat. If they were not, show them now, then ask.) - -```json -[ - { - "header": "Confirm step", - "question": "Have you completed this step and is the evidence captured?", - "options": [ - { "label": "Yes, it's done", "recommended": true }, - { "label": "Not yet" } - ], - "allowFreeformInput": false - } -] -``` - -Only treat the row as acknowledged (`ACK = true`) on an explicit "Yes, it's -done". Never infer acknowledgement from a flightcheck pass. - ---- - -## U.3 — Write the item + mirror - -**Persist immediately — never batch.** Write **both** files below **now**, as part -of this call, before returning control to the caller and before the caller proceeds -to its next row. A completed row must be durable the instant its checkpoint passes, -so that if a later row in the same skill errors, the progress already made is not -lost — the orchestrator resumes from the first non-`done` row in `setupStatus`. - -1. Update the located item in `.local/setup/workday-da/tasks.md` to the state - from U.2: - - Set the checkbox marker: `- [x]` when the resulting status is `done`, - otherwise `- [ ]`. - - Set the hidden `status:` field in that item's comment to the full value - (`pending` / `in-progress` / `done` / `blocked`). - - Leave the visible title/description and every other item untouched. Do not add - any Step ID, checkpoint ID, or status text to the visible line — the checkbox is - the only at-a-glance marker the user sees. -2. Update the mirror in `.local/connect/workday-da/config.json`: - ```json - { - "setupStatus": { - "{STEP_ID}": { - "state": "", - "checkpoint": "", - "gate": "", - "verifiedBy": "", - "evidence": { - "outcome": "", - "provenance": "", - "note": "", - "capturedAt": "" - }, - "gateEvidence": { - "method": "", - "outcome": "", - "provenance": "", - "note": "", - "capturedAt": "" - } - } - } - } - ``` - Set scalar `verifiedBy` from the resulting completed state: - - `programmatic` for a completed `prog` row, - - `attested` for a completed `manual`/`attest` row, - - `reviewed` for a completed `advisory` row, - - `null` for any row that is not `done`. - - Persist `ROW_EVIDENCE` as `evidence` and `GATE_EVIDENCE` as - `gateEvidence` when supplied. Merge these fields with the existing row; - never replace `verifiedBy` with an object. When a row regresses to - `in-progress` or `blocked`, clear stale completion `verifiedBy` and - `evidence`, while retaining current failure evidence and any still-valid - `gateEvidence`. - Merge — do not drop other `setupStatus` keys (round-trip contract in - `config-schema.md`). - -Return control to the calling file. Do not announce file paths or internal -mechanics to the user. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md index 518ab24c..f753a361 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md @@ -1,189 +1,63 @@ -# Workday DA Setup — Config Persistence Schema + +# Workday connect state contract -This file documents the **canonical shape** of the Workday connection config -that the `connect/workday-da` skill's steps read and write. It is a *reference -doc*, not an executable fragment — there are no Message blocks here. The steps -cite this file so they agree on field names, owners, and types. +The only writable lifecycle state is: -**Canonical data file:** `.local/connect/workday-da/config.json` - -Forked from the CEA `setup/shared/config-schema.md`. The field shapes are the -same; only the file path and the owning steps differ — DA has five steps -(DA-1 install, DA-2 Entra, DA-3 tenant, DA-4 Power Platform integration, -DA-5 runtime validation). - ---- - -## Do NOT confuse the two config files - -There are **two distinct** files. Keep them separate. - -| File | Owner | Purpose | -|------|-------|---------| -| `.local/connect/workday-da/config.json` | the `connect/workday-da` skill | Workday connection state — sidecar Dataverse URL, Workday URLs, tenant, Entra app, OAuth client, per-step status. **This schema.** | -| `.local/config.json` | foundation setup + FlightCheck | AgentBuilder-native identity (`powerPlatformApiEndpoint`, `activeAgent`, `agent`/`agents`) and, for legacy workspaces only, a foundation `dataverseEndpoint`. **Not this schema.** | - -Never write Workday connection fields into `.local/config.json`, and never -write agent identity into `.local/connect/workday-da/config.json`. A native MOS -agent may use `sidecarDataverseEndpoint` in this schema for the Dataverse -environment hosting the Workday solution and flows; FlightCheck consumes it -only when foundation config has no `dataverseEndpoint`. - ---- - -## Canonical fields - -All fields live at the top level of `.local/connect/workday-da/config.json` -unless noted. A field is written **once** by its owner step and thereafter -read by later steps. Unknown/absent fields are treated as `null`. - -### Connection + tenant (tenant URL captured early by DA-2; API-client fields by DA-3) - -| Field | Type | Owner | Notes | -|-------|------|-------|-------| -| `sidecarDataverseEndpoint` | string | DA-1 | HTTPS Dataverse organization URL hosting the Workday solution, connections, and flows for a native MOS/AgentBuilder agent. Do not copy it into foundation config. | -| `baseUrl` | string | DA-2/DA-3 | Workday web host base URL (e.g. `https://wd2-impl.workday.com`). Captured early by DA-2 when the operator has the URL, else by DA-3. | -| `tenant` | string | DA-2/DA-3 | Workday tenant short name. Captured early by DA-2 to pin the Entra app deterministically, else by DA-3. | -| `tokenHost` | string | DA-2/DA-3 | Services host used to build token / REST URLs. Derived by DA-2 when the URL matches a known pattern, else by DA-3. | -| `oauthTokenUrl` | string | DA-3 | `https://{tokenHost}/ccx/oauth2/{tenant}/token`. | -| `restBaseUrl` | string | DA-3 | REST base, **trimmed to `/api`** — see `shared/connection-fields.md`. | -| `soapBaseUrl` | string | DA-3 | SOAP base (`https://{services-host}/ccx/service`). | -| `domainName` | string | DA-3 | Workday domain name, when discovered. | -| `tenantId` | string | DA-2 | **Entra** tenant ID (GUID) — set during Entra setup. | -| `installPath` | string | DA-3/DA-4 | `"simplified"`. | -| `status` | string | all | `"in-progress"` \| `"configured"` \| `"ready"`. `"configured"` means setup values are recorded but runtime is not proven. Only DA-5 sets `"ready"` after a signed-in Workday scenario succeeds. | -| `verticals` | array[string] | DA-1 | Always `["hr"]` for this release. ESS DA IT is not supported by `/connect workday`. | -| `vertical` | string | DA-1 | Always `"hr"` for this release. | - -### Entra app + OAuth client (owned by DA-2 / DA-3) - -| Field | Type | Owner | Notes | -|-------|------|-------|-------| -| `entraSSO` | boolean | DA-2 | True once the SSO gallery app + connector authorization exist. | -| `entraAppId` | string | DA-2 | Entra app (client) ID. | -| `entraAppObjectId` | string | DA-2 | Entra app object ID (for Graph calls). | -| `entraAppIdUri` | string | DA-2 | Entra Application ID URI (`api://{entraAppId}`), used for the exposed API scope. | -| `workdaySamlEntityId` | string | DA-2 | Workday SAML Service Provider ID / resource URL (`http://www.workday.com/{tenant}`). Never alias this to `entraAppIdUri`. | -| `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. | +```text +.local/connect/workday-da/config.json +``` -### Per-step status fields (owned by each step via the checklist-updater) +`scripts/workday_connect_store.py` owns locking, migration, validation, atomic +writes, and phase transitions. Skills must use `scripts/workday_connect.py`; +they must not edit this file directly or create a Markdown state mirror. -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. +## Schema version 2 ```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 } - } + "schemaVersion": 2, + "provider": "workday", + "status": "in-progress", + "scope": {}, + "identifiers": {}, + "endpoints": {}, + "operators": {}, + "phases": {}, + "migration": null, + "updatedAt": "UTC timestamp" } ``` -- `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. +- `scope` contains exact agent, Dataverse environment, architecture, package, + Entra tenant, and Workday tenant targeting. +- `identifiers` contains non-secret Entra and Workday identifiers. +- `endpoints` contains validated non-secret Workday endpoints. +- `operators` contains safe account and tenant provenance. +- `phases` contains exactly the six controller phases. ---- +Each phase stores status, scope hash, completed action keys, one approved plan +and hash, manual handoff, evidence, current blocker, and updated time. -## 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", - "entraAppIdUri": "api://11111111-1111-1111-1111-111111111111", - "workdaySamlEntityId": "http://www.workday.com/acme_dpt1", - "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" - } - } - } -} -``` +## Identifier invariant ---- +These values are independent and must never be aliases: -## Round-trip contract +| Field | Meaning | Format | +| --- | --- | --- | +| `identifiers.workdaySamlEntityId` | Workday SAML Service Provider ID and connector resource URL | `http://www.workday.com/{tenant}` | +| `identifiers.entraAppIdUri` | Entra exposed API Application ID URI | `api://{entraAppId}` | -Any step that writes a field listed above must: +## Persistence rules -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. +- Never persist passwords, client secrets, access or refresh tokens, cookies, + certificate bodies, private keys, or employee data. +- Persist account usernames and tenant IDs only as authentication provenance. +- Approved mutations must carry an exact plan hash. A scope or target change + invalidates the approval. +- A phase is complete only after the target has been reread and matching + evidence is persisted. +- The provider status becomes `ready` only when all six phases are complete. -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`. +Legacy row-based state is backed up to `config.pre-v2.json` before one-time +migration. Legacy Markdown task files, when present, are historical snapshots +and are never rewritten. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/connection-fields.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/connection-fields.md deleted file mode 100644 index 5efc08eb..00000000 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/connection-fields.md +++ /dev/null @@ -1,151 +0,0 @@ -# Connection Fields — Capture & Validate (DA) - -Centralizes capture and validation of the Workday connection identifiers the -DA Workday setup skills exchange. **DA-3 captures** these (from the Workday -API client view and tenant URL); a later DA extension-pack configuration step -consumes them when it binds the connection. Keeping the rules here means both -steps agree on format — especially the documented **REST-base `/api` trim** -gotcha that silently breaks the connection if it's wrong. - -Forked from the CEA `setup/shared/connection-fields.md` with DA-scoped state -paths. The tenant math (URL derivation, trim rules) is agent-architecture -agnostic and identical to the CEA version — only the persisted file changes. - -Every **Message** block is the exact text to show the user. Copy it verbatim. Do -not rephrase or narrate tool calls. - -**Inputs from the calling file (any that are already known):** -- `WD_TENANT`, `WD_BASE_URL`, `WD_TOKEN_HOST` — captured by DA-3 from the - Workday tenant / API-client screens (or read from - `.local/connect/workday-da/config.json` when an earlier step already stored - them). -- `OAUTH_CLIENT_ID`, `TOKEN_ENDPOINT` — from the Workday "View API Client" - screen (DA-3). -- `ENTRA_APP_ID_URI` — the Entra Application ID URI - (`api://{entraAppId}`) from DA-2. -- `WORKDAY_SAML_ENTITY_ID` — the Workday SAML Service Provider ID and - connection resource URL (`http://www.workday.com/{tenant}`) from DA-2. - -**Outputs (written back to `.local/connect/workday-da/config.json`, see -`config-schema.md`):** -- `entraAppIdUri`, `workdaySamlEntityId`, `oauthTokenUrl` / `tokenEndpoint`, - `oauthClientId`, - `soapBaseUrl`, `restBaseUrl` (trimmed). - ---- - -## C.1 — Keep the two audience identifiers distinct - -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 `ENTRA_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 `entraAppIdUri`. - -> **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. - -Derive `WORKDAY_SAML_ENTITY_ID` independently from the validated Workday -tenant as `http://www.workday.com/{tenant}` and save it as -`workdaySamlEntityId`. Reject the data if these two identifiers are equal or if -the SAML entity ID does not exactly match the selected tenant. - ---- - -## C.2 — OAuth token URL - -- Expected form: `https://{WD_TOKEN_HOST}/ccx/oauth2/{WD_TENANT}/token`. -- If `TOKEN_ENDPOINT` was captured from the API client screen, prefer it but - confirm it matches the derived form's host + tenant; if it diverges, keep the - captured value and note it. -- **Validate:** must be `https://`, contain `/ccx/oauth2/`, and end with `/token`. - -Save as `oauthTokenUrl` (and `tokenEndpoint` when captured from the API client). - ---- - -## C.3 — Client ID - -- `OAUTH_CLIENT_ID` is the **Workday API client ID** shown on the "View API - Client" screen. It is **not** the Entra `entraAppId` — do not conflate them. -- **Validate:** non-empty. If the user pastes something that is obviously the - Entra app GUID already stored as `entraAppId`, warn and re-ask — they are - distinct identities. - -Save as `oauthClientId`. - ---- - -## C.4 — SOAP base URL - -The SOAP base is derived from the Workday **services** host (the same host as -`WD_TOKEN_HOST`, so `https://{WD_TOKEN_HOST}/ccx/service` is equivalent): - -- `impl.workday.com` → `https://wd2-impl-services1.workday.com/ccx/service` -- `wd5.myworkday.com` → `https://wd5-services1.myworkday.com/ccx/service` -- `{dcN}.myworkday.com` → `https://{dcN}-services1.myworkday.com/ccx/service` - -- Expected form: `https://{services-host}/ccx/service` (no tenant suffix, no - trailing slash). -- **Validate:** must be `https://`, contain `/ccx/service`, and **not** end in a - trailing `/`. If `WD_BASE_URL` didn't match a known pattern, fall back to - asking the user for the SOAP base URL. - -Save as `soapBaseUrl`. - ---- - -## C.5 — REST base URL — trimmed to `/api` *(silent-failure gotcha)* - -This is the field that most often breaks the simplified-path connection. The -Workday screens and copy/paste sources frequently include extra trailing -segments. **Copy as displayed, then trim** so the value ends at `/api`. - -- Canonical form: `https://{WD_TOKEN_HOST}/ccx/api`. -- **Trim procedure** — starting from whatever was captured: - 1. Strip any trailing slash. - 2. If it ends with a version segment (`/v1`, `/v2`, …), remove it. - 3. If it ends with the tenant name or any path **after** `/ccx/api`, remove - everything after `/ccx/api`. - 4. The result must end exactly with `/ccx/api` (or `/api` for hosts that omit - `/ccx`). -- **Validate:** must be `https://`, contain `/api`, and have **nothing** after - the `/api` segment. If anything follows `/api`, trim it and show the user the - corrected value: - -**Message:** - -I trimmed the Workday REST base URL to **{restBaseUrl}** — the connection -fails silently if anything is appended after `/api`, so it has to end there. - -**End message.** - -Save the trimmed value as `restBaseUrl`. - ---- - -## C.6 — Persist - -Read `.local/connect/workday-da/config.json`, merge the validated fields above -(never dropping fields owned by other steps), and write it back — per the -round-trip contract in `config-schema.md`. Return the saved values to the -calling file. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/permission-gate.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/permission-gate.md deleted file mode 100644 index ad02a0a4..00000000 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/permission-gate.md +++ /dev/null @@ -1,165 +0,0 @@ -# Permission Gate (Shared) - -A reusable **role check → specific named error → stop** routine. Every Workday -DA connect skill step applies this fragment before it performs role-restricted -work, so no step duplicates inline role logic. - -Forked from the CEA `setup/shared/permission-gate.md` — the role-gating logic -is identical; only the persisted-state path differs. - -Every **Message** block is the exact text to show the user. Copy it verbatim. -Do not rephrase, add commentary, or tell the user what tools you are calling. - -**Inputs from the calling file:** -- `REQUIRED_ROLE` — the human-readable role name to require (e.g. - `"Workday Administrator"`, `"Power Platform Administrator"`, - `"Application Administrator"`). -- `GATE_MODE` — `"programmatic"` or `"attested"` (see "Choosing a mode" below). -- `STEP_ID` — the master-checklist Step ID this gate protects (e.g. `"DA3.1"`), - used only to record evidence. -- `ROLE_QUERY` — *(programmatic mode only)* the command/check that proves the - caller holds the role (the calling file supplies it; examples below). - -**Outputs to the calling file:** -- `GATE_RESULT` — `"pass"` or `"stop"`. On `"stop"`, the calling file must halt. -- `GATE_EVIDENCE` — an object recording how the gate was satisfied; the caller - passes it to `checklist-updater.md`, which merges it under - `setupStatus["{STEP_ID}"].gateEvidence` in - `.local/connect/workday-da/config.json` (see `config-schema.md`): - - `method` ∈ `"programmatic"` \| `"attested"`. - - `outcome` ∈ `"pass"` \| `"stop"`. - - `provenance` ∈ `"role-query"` \| `"user-attestation"`. - - `note` — short free text (e.g. the role-query result, or the user's - attestation timestamp/identity). - - `capturedAt` — current UTC timestamp. - ---- - -## Choosing a mode - -The gating mechanism differs by role because not every role has a queryable -directory: - -| Role family | Mode | How verified | -|-------------|------|--------------| -| Entra roles (App Admin, Cloud App Admin, Global Admin, Priv Role Admin) | `programmatic` | Microsoft Graph role / privilege query | -| Power Platform Admin | `programmatic` | Power Platform admin API | -| Dataverse maker / system roles | `programmatic` | Dataverse security-role query | -| **Workday Administrator** | `attested` | No directory here → explicit named-role attestation + captured evidence | -| **InfoSec / IT** (firewall allowlisting) | `attested` | No directory here → explicit named-role attestation + captured evidence | - -The calling file picks `GATE_MODE` from this table. **Never** silently pass an -attested role — always require the explicit confirmation in section G.2. - ---- - -## G.1 — Programmatic gate - -Use when `GATE_MODE` is `"programmatic"`. - -Run the `ROLE_QUERY` the calling file supplied. Examples of what a caller passes: - -- **Entra role (Graph):** - ``` - az rest --method GET --url "https://graph.microsoft.com/v1.0/me/memberOf/microsoft.graph.directoryRole?%24select=displayName,roleTemplateId" --query "value[].{displayName:displayName,roleTemplateId:roleTemplateId}" -o json - ``` - (OData options are percent-encoded — `%24select` not `$select` — so the URL - survives PowerShell/bash `$`-expansion and runs first-try on every shell.) - Pass only when the returned `roleTemplateId` equals one of the stable, - caller-approved built-in role template IDs. The - `microsoft.graph.directoryRole` cast excludes ordinary groups; display names - are diagnostic only and must never determine authorization. -- **Power Platform Admin / Dataverse role:** the caller supplies the specific - admin-API or Dataverse query and the expected value. - -**If the query proves the role is held:** -- Set `GATE_RESULT = "pass"`. -- Set `GATE_EVIDENCE = { "method": "programmatic", "outcome": "pass", "provenance": "role-query", "note": "", "capturedAt": "" }`. -- Return to the calling file. - -**If the query proves the role is NOT held** (or returns an -`Insufficient privileges` / `Authorization_RequestDenied` error — mirror the -existing pattern in `connect/azure/app-registration.md` section B.2): - -**Message:** - -This step requires the **{REQUIRED_ROLE}** role, and your account doesn't -have it. Ask your administrator to grant this role, then come back and run -this step again. - -**End message.** - -- Set `GATE_RESULT = "stop"`. -- Return to the calling file. **The caller must halt — do not proceed.** - -**If the query itself fails** for an unrelated reason (network, not logged in): -retry once. If it still fails, **fail closed**: - -**Message:** - -I couldn't verify the **{REQUIRED_ROLE}** role, so I can't safely continue this -step. Sign in again or ask a verified administrator to run it, then retry. - -**End message.** - -- Set `GATE_RESULT = "stop"`. -- Set `GATE_EVIDENCE` with `method: "programmatic"`, `outcome: "stop"`, - `provenance: "role-query"`, the query error in `note`, and the current UTC - timestamp in `capturedAt`. -- Return to the calling file. Never downgrade a programmatic privileged-role - gate to self-attestation. - ---- - -## G.2 — Attestation gate - -Use only when `GATE_MODE` is `"attested"` (Workday Administrator, InfoSec/IT). -Programmatic privileged-role checks never fall back to this section. - -**Message:** - -This step requires the **{REQUIRED_ROLE}** role. I can't verify that -automatically for this system, so I need you to confirm you (or the person -doing this step) hold that role before we continue. - -**End message.** - -Use the `vscode_askQuestions` tool: - -```json -[ - { - "header": "Confirm role", - "question": "Do you have the {REQUIRED_ROLE} role to perform this step?", - "options": [ - { "label": "Yes, I have this role", "recommended": true }, - { "label": "No / not sure" } - ], - "allowFreeformInput": false - } -] -``` - -**If the user chose "Yes, I have this role":** -- Set `GATE_RESULT = "pass"`. -- Set `GATE_EVIDENCE = { "method": "attested", "outcome": "pass", "provenance": "user-attestation", "note": "user attested {REQUIRED_ROLE} for {STEP_ID}", "capturedAt": "" }`. -- Return to the calling file. - -**If the user chose "No / not sure":** - -**Message:** - -No problem — this step needs the **{REQUIRED_ROLE}** role. Ask whoever holds -that role to run it, then come back and continue. - -**End message.** - -- Set `GATE_RESULT = "stop"`. -- Set `GATE_EVIDENCE` with `method: "attested"`, `outcome: "stop"`, - `provenance: "user-attestation"`, a safe note, and the current UTC timestamp. -- Return to the calling file. **The caller must halt — do not proceed.** - -> An attested `"pass"` records that the role was **claimed**, not directory-proven. -> It satisfies the *gate*, but it does **not** by itself complete the checklist row -> — the row still needs its own captured evidence/acknowledgement per -> `checklist-updater.md`. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/tasks.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/tasks.md deleted file mode 100644 index 57c5894f..00000000 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/tasks.md +++ /dev/null @@ -1,114 +0,0 @@ - -# Workday Connect — Checklist (template) - -The single, trackable checklist spanning the five Workday connect steps for the -**ESS HR agent**. This file is the -**canonical row source**: on first run the skill renders it to the working copy -`.local/setup/workday-da/tasks.md` and then updates **only its own items** -through the shared -[`shared/checklist-updater.md`](shared/checklist-updater.md). The durable -mirror of each item's status is `setupStatus` in -`.local/connect/workday-da/config.json` (see -[`shared/config-schema.md`](shared/config-schema.md)). - -> Do not hand-edit the working copy's checkboxes — let the checklist-updater -> write them so the **MANUAL / attestation rule** is enforced in one place. - -This checklist assumes your Employee Self-Service HR agent is already -installed (via `/setup`). If it isn't, DA-1 below detects that and points you -there first. - -## How to read this checklist - -Each item is a plain checkbox with a short description of what it achieves — -that is what the user sees: - -- `- [ ]` — not done yet. -- `- [x]` — done. - -The technical details the tooling needs (the stable **Step ID**, the -flightcheck **checkpoint(s)** that verify the item, and the completion -**gate**) live in the HTML comment directly under each item. Those comments are -invisible in the rendered checklist; only the checklist-updater reads them. -**Never surface a Step ID or checkpoint ID to the user** — show the checkbox -and its description only. - -**Gate** — how an item reaches done: - -| Gate | Meaning | -|------|---------| -| `prog` | A programmatic flightcheck pass completes the item. | -| `manual` | Explicit user action + re-verify; a flightcheck pass alone never completes it. | -| `attest` | Attestation + captured evidence (no queryable directory); never auto-completed. | -| `advisory` | Informational; completes once its output has been shown, regardless of findings. | - -The hidden `status:` field carries the full four-state value -(`pending` \| `in-progress` \| `done` \| `blocked`) that a single checkbox can't -express; all items start `pending`. - -## Checklist - -### 1. Workday extension package - -- [ ] **Install the Workday extension package** — Add the Workday extension package to your ESS HR agent so it can talk to Workday. If the HR base agent isn't installed yet, this step sends you to `/setup` first. - - -### 2. Connect Microsoft Entra sign-in to Workday - -- [ ] **Set up Workday sign-in** — Create the Microsoft Entra application Workday uses to recognize signed-in employees. - -- [ ] **Allow Power Platform to call Workday** — Add the permission used by the Workday connector and the Microsoft Graph permissions needed for sign-in. - -- [ ] **Approve the sign-in permissions** — Grant organization-wide consent for the permissions the Workday connection needs. - -- [ ] **Choose who can use Workday** — Assign the employees or groups allowed to use the Workday application, or confirm assignment is not required. - -- [ ] **Match the signed-in employee** — Configure the sign-in identifier Workday uses to find the current employee. - -- [ ] **Sign the Workday sign-in response** — Turn on "Sign SAML response and assertion" so Workday trusts the sign-in response. - -- [ ] **Confirm the correct Microsoft Entra tenant** — Verify Workday is connected to this environment's Microsoft Entra tenant. - - -### 3. Workday tenant configuration - -- [ ] **Register the Workday API client** — In Workday, register the API client for the agent, including the functional areas and Workday-owned scope. - -- [ ] **Capture your Workday connection details** — Record the client ID, token endpoint, REST and SOAP base URLs, and tenant name needed to connect. - -- [ ] **Verify employee SAML sign-in policy** — Confirm an active Workday authentication rule allows SAML for the intended employees, or have the Workday administrator review and activate the required change. - -- [ ] **Match the signing certificate** — Confirm the Workday-side signing certificate matches the one in Entra (validity dates, or an externally-computed SHA-1 — Workday shows no thumbprint). - - -### 4. Power Platform and agent integration - -- [ ] **Create the Workday connection** — Create the signed-in employee Workday connection with the captured Workday endpoints. - -- [ ] **Create the Microsoft Dataverse connection** — Create or select an active Dataverse connection owned by the maker in this environment. - -- [ ] **Bind the extension connections** — Attach the Workday and Dataverse connections to the installed Workday runtime references. - -- [ ] **Turn on the Workday cloud flows** — Enable every Workday runtime flow after its connections are bound. - -- [ ] **Connect Workday to the agent** — Connect each Workday flow in Copilot Studio and allow it to share the connection parameters used for signed-in employee access. - -- [ ] **Authorize the agent to use the Workday flows** — Preview and apply the delegated authorization and workflow sharing required by the ESS HR agent. - -- [ ] **Configure employee context and topics** — Use the Workday package's V2 signed-in-user context and enable the Workday topics selected for this agent. - -- [ ] **Allow Workday through the firewall** — Allow the Workday REST and SOAP hosts used by the Power Platform managed connectors. - - -### 5. Validate Workday readiness - -- [ ] **Validate a signed-in Workday scenario** — Run a Workday topic as a signed-in employee and confirm the agent returns real data before marking the environment ready. - - -> An item backed by an **attest** or **manual** gate is **never** auto-completed -> by its checkpoint — it requires an explicit user acknowledgement plus -> captured evidence (see [`shared/checklist-updater.md`](shared/checklist-updater.md)). - -DA-scoped APIs are not available for every Power Platform surface. Those rows -remain manual or attested rather than being falsely completed by CEA-specific -checks. The final row requires runtime evidence from a signed-in user. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md index 659dbd76..67ec579d 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md @@ -1,93 +1,40 @@ -# DA-5 — Validate Workday Readiness +# Phase 6 - Employee validation -Role: **Environment Maker** with a signed-in Workday test user. This step -re-confirms the extension package, reviews every setup area, and requires a -real Workday scenario before the environment is marked ready. It owns -master-checklist row **DA5.1**. +This phase requires a real signed-in employee scenario. Configuration checks +alone cannot complete it. -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. +Ask the maker to: ---- +1. publish the ESS HR agent; +2. start a new conversation; +3. sign in as a test employee assigned to the Workday Entra application and + authorized in Workday; +4. run one enabled read-only scenario, such as checking a vacation balance; +5. confirm the agent identifies the signed-in employee and returns real + Workday data without an unexpected repeated sign-in. -## DA5.1 — Validate a signed-in Workday scenario +On success, record only the scenario name, test-user category, timestamp, and +outcome: -**Re-confirm the extension package.** - -``` -python scripts/flightcheck/cli.py --checkpoint WD-DA-PKG-001 --connect-config ".local/connect/workday-da/config.json" +```powershell +python scripts/workday_connect.py complete-action --phase employee-validation --action signed-in-scenario --evidence-json '{...}' +python scripts/workday_connect.py set-phase-status --phase employee-validation --status complete ``` -Show the result per [`shared/checklist-updater.md`](shared/checklist-updater.md) -§U.0. - -If the current result is not `PASSED`, do not continue from the persisted -DA1.1 state: - -- `FAILED` → update DA1.1 with `GATE="prog"`, - `CHECKPOINT_RESULT="FAILED"` so it becomes `blocked`. -- `WARNING` / `SKIPPED` → update DA1.1 with `GATE="prog"` and that result so - it becomes `in-progress`. - -Tell the user the package must be restored or reverified, then return to the -orchestrator. Do not complete DA5.1. - -**Summarize the Entra and tenant configuration recorded so far.** Read -`.local/connect/workday-da/config.json` and render what's known: - -**Message:** - -Here's where your Workday connection stands: - -| Area | Status | -| --- | --- | -| Workday extension package | {✅/❌ from WD-DA-PKG-001} | -| Workday single sign-on (Entra) | {✅ if DA2.1–DA2.7 are all `done`, else "in progress"} | -| Workday tenant configuration | {✅ if DA3.1–DA3.4 are all `done`, else "in progress"} | -| Power Platform and agent integration | {✅ if DA4.1–DA4.8 are all `done`, else "in progress"} | - -**End message.** - -If any of DA1.1, DA2.1–DA2.7, DA3.1–DA3.4, or DA4.1–DA4.8 is not `done`, -tell the user which step to finish and stop here — do not present the -connection as ready. - -**Message:** - -The configuration checklist is complete. Now validate the actual employee -path: - -1. Publish the ESS HR agent. -2. Use a test employee who is assigned to the Workday Entra application and - has valid Workday access. -3. Start a new conversation so stale user-flow state is not reused. -4. Run one enabled Workday scenario, such as checking a vacation balance. -5. Confirm the agent identifies the signed-in employee and returns real - Workday data without asking for another unexpected sign-in. - -Did the scenario complete successfully? - -**End message.** - -On success, record the scenario, test user category (never credentials), time, -and result as evidence. First merge provider `status: "ready"` into the -provider config, then update **DA5.1** with `GATE="manual"`, `ACK=true`. This -write order ensures an interruption cannot leave a completed row while the -public readiness signal is missing. Return to the orchestrator. - -On failure, leave DA5.1 `in-progress`. Run -`python scripts/flightcheck/cli.py --scope workdayda --connect-config ".local/connect/workday-da/config.json"` -to recheck the environment and DA package. That scope does not prove the live -connection, flow authorization, employee-context wiring, or topic execution, -so also revisit the DA4 connection, flow, authorization, topic, and firewall -evidence. If connection parameters recently changed, reconnect the Workday -connection and retry with a fresh conversation or test user. +Never record employee data or credentials. ---- +On failure, keep the phase active and persist one current blocker. Use the +failing surface to choose the next check: -## Done +- sign-in loop -> identify which credential store prompted and whether the + account or tenant differs; +- connector error -> inspect the exact Workday connection status and resource + URL; +- flow error -> inspect the exact flow run and delegated-authorization + evidence; +- employee mismatch -> inspect NameID and User Context V2 evidence; +- network error -> inspect the exact Workday REST or SOAP host. -Return control to the orchestrator (`SKILL.md`) — every configuration row -should now be `done`. +After remediation, retry with a new conversation. Do not reset completed +phases. diff --git a/tests/scripts/test_workday_connect_runtime.py b/tests/scripts/test_workday_connect_runtime.py index 78bba07d..9760f7d2 100644 --- a/tests/scripts/test_workday_connect_runtime.py +++ b/tests/scripts/test_workday_connect_runtime.py @@ -285,7 +285,9 @@ def test_runtime_requires_completed_connection_phase(): ): runtime.run_runtime_operation( state, - apply=False, + apply=True, + approved_hash="approved", + verifier=lambda *_args: None, token_provider=lambda *_args, **_kwargs: "token", **_discovery_dependencies(_records()), ) diff --git a/tests/setup/test_workday_da_foundation.py b/tests/setup/test_workday_da_foundation.py index b147e076..078dd645 100644 --- a/tests/setup/test_workday_da_foundation.py +++ b/tests/setup/test_workday_da_foundation.py @@ -1,10 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""Contracts for the resumable Workday DA setup foundation.""" +"""Structural guards for the simplified Workday DA lifecycle.""" from pathlib import Path -import re _REPO_ROOT = Path(__file__).resolve().parents[2] @@ -17,115 +16,70 @@ / "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 all("ESS DA" not in line for line in visible_rows) - assert any("Connect Microsoft Entra sign-in to Workday" in line for line in tasks.splitlines()) - assert any("Match the signed-in employee" in line for line in visible_rows) + assert "scripts/workday_connect.py" in skill + assert ".local/connect/workday-da/config.json" in skill + assert "must not edit this file directly" in schema + assert '"schemaVersion": 2' in schema + assert "Markdown state mirror" in schema + assert not (_WORKDAY_DA / "tasks.md").exists() + assert not (_WORKDAY_DA / "shared" / "checklist-updater.md").exists() + assert not (_WORKDAY_DA / "shared" / "permission-gate.md").exists() -def test_orchestrator_renders_canonical_titles_in_canonical_order() -> None: - tasks = (_WORKDAY_DA / "tasks.md").read_text(encoding="utf-8") +def test_orchestrator_exposes_exactly_six_customer_phases() -> None: skill = (_WORKDAY_DA / "SKILL.md").read_text(encoding="utf-8") - canonical_titles = re.findall(r"^- \[ \] \*\*(.+?)\*\*", tasks, re.MULTILINE) - rendered_titles = re.findall(r"^\s+- \{m\} (.+)$", skill, re.MULTILINE) - - assert rendered_titles == canonical_titles - - -def test_state_contract_requires_immediate_durable_updates() -> None: - updater = (_SHARED / "checklist-updater.md").read_text(encoding="utf-8") - schema = (_SHARED / "config-schema.md").read_text(encoding="utf-8") - - assert "A `MANUAL` or attestation-gated row is never" in updater - assert "**Persist immediately — never batch.**" in updater - assert ".local/setup/workday-da/tasks.md" in updater - assert ".local/connect/workday-da/config.json" in updater - assert "Read" in schema and "Merge" in schema and "Write" in schema - assert "sidecarDataverseEndpoint" in schema - assert '"gateEvidence"' in schema - assert '"provenance"' in schema - assert '`reviewed`' in updater - assert "FAILED` or `ERROR` always produces `blocked`" in updater - - -def test_entra_setup_pins_tenant_and_exact_app_identity() -> None: - entra = (_WORKDAY_DA / "provision-entra-app.md").read_text(encoding="utf-8") - gate = (_SHARED / "permission-gate.md").read_text(encoding="utf-8") - - assert "az account show --query tenantId -o tsv" in entra - assert '--tenant "{SETUP_TENANT_ID}"' in entra - assert "normalized **exact equality**" in entra - assert "contains(@, 'workday.com/{tenant}')" not in entra - assert "microsoft.graph.directoryRole" in entra - assert "roleTemplateId" in entra - assert "9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3" in entra - assert "never auto-select by display name" in entra - assert "Never downgrade a programmatic privileged-role" in gate - assert "user_impersonation" in entra - assert "claimsMappingPolicy" in entra - - -def test_workday_tenant_setup_preserves_manual_gates_and_safe_order() -> None: - tasks = (_WORKDAY_DA / "tasks.md").read_text(encoding="utf-8") - tenant = (_WORKDAY_DA / "configure-tenant.md").read_text(encoding="utf-8") - - register = tenant.index("## DA3.1 + DA3.2 — Register the API client") - policy = tenant.index("## DA3.3 — Verify the signed-in employee authentication policy") - - assert register < policy - assert "Single-tenant SAML pre-gate" in tenant - assert "CHECKPOINT_RESULT=\"MANUAL\"" in tenant - assert "ACK=true" in tenant - assert "Workday cert field is not API-reachable" in tenant - assert "checkpoints: WD-CONN-102 | gate: manual" in tasks - assert "checkpoints: WD-API-CLIENT-001 | gate: attest" in tasks - assert ( - "| DA3.2 | `WD-API-CLIENT-001` — Workday connection fields captured" - in tenant + for phase in ( + "Preflight", + "Microsoft Entra", + "Workday administrator", + "Connections", + "Runtime configuration", + "Employee validation", + ): + assert phase in skill + assert "21" not in skill + assert "DA1.1" not in skill + assert "setupStatus" not in skill + + +def test_entra_and_workday_identifiers_remain_distinct() -> None: + entra = (_WORKDAY_DA / "provision-entra-app.md").read_text( + encoding="utf-8" + ) + tenant = (_WORKDAY_DA / "configure-tenant.md").read_text( + encoding="utf-8" + ) + schema = (_WORKDAY_DA / "shared" / "config-schema.md").read_text( + encoding="utf-8" ) - assert "There is no separate domain-to-integration-security-group" in tenant - assert "Do not look for an OAuth-client restriction" in tenant - assert "Existing active policy already allows employee SAML" in tenant + for text in (entra, tenant, schema): + assert "http://www.workday.com/{tenant}" in text or ( + "http://www.workday.com/{workdayTenant}" in text + ) + assert "api://" in text + assert "Never select by display name alone" in entra + assert "Never alias" in schema or "must never be aliases" in schema + assert "entra-plan" in entra + assert "workday-admin-packet" 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" +def test_manual_handoff_is_one_packet_not_row_attestations() -> None: + tenant = (_WORKDAY_DA / "configure-tenant.md").read_text( + encoding="utf-8" ) + + assert "one administrator handoff" in tenant + assert "one response form" in tenant + assert "repeated confirmations" in tenant + assert "CHECKPOINT_RESULT" not in tenant + assert "ACK=true" not in tenant diff --git a/tests/setup/test_workday_da_orchestration.py b/tests/setup/test_workday_da_orchestration.py index 533360dc..40ebe29e 100644 --- a/tests/setup/test_workday_da_orchestration.py +++ b/tests/setup/test_workday_da_orchestration.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""Contracts for the runnable Workday DA setup orchestration.""" +"""Contracts for the simplified Workday DA orchestration.""" from pathlib import Path @@ -18,65 +18,53 @@ ) -def test_orchestrator_resumes_durable_state_without_restarting_setup() -> None: +def test_orchestrator_resumes_from_controller_status() -> None: text = (_WORKDAY_DA / "SKILL.md").read_text(encoding="utf-8") normalized = " ".join(text.split()) - assert "pick the first whose state is not `done`" in text - assert "must not** batch those writes" in text - assert 'provider `status` to be `"ready"`' in text - assert "you do not need to run `/setup` again" in normalized - assert "Here's the plan for connecting Workday to your ESS HR agent" in text - assert "- {m} Verify employee SAML sign-in policy" in text - assert "Your ESS HR agent is connected to Workday" in text + assert "python scripts/workday_connect.py initialize" in text + assert "python scripts/workday_connect.py status" in text + assert "nextPhaseId" in text + assert "Do not create, copy, update, or infer status from a Markdown" in ( + normalized + ) + assert "resume the same blocker" in text + assert "controller status is `ready`" in text -def test_extension_install_uses_the_ring_aware_runtime_installer() -> None: +def test_preflight_is_one_identity_aware_operation() -> None: text = (_WORKDAY_DA / "install-extension.md").read_text(encoding="utf-8") - normalized = " ".join(text.split()) - assert "install_workday_da_extension.py" in text - assert '--package-flavor "{PACKAGE_FLAVOR}"' in text - assert '--ring "{RING}"' in text - assert "managed-pac.nuget.config" in text - assert "Do not present .NET and PAC as unexplained product setup steps" in normalized - assert "do not restart the Workday checklist" in normalized + assert "workday_connect.py preflight" in text + assert "pins and verifies the resulting PAC account" in text + assert "verifies the exact Dataverse URL directly" in text + assert "detects or installs the package" in text + assert "manual-install instruction" in text -def test_connections_are_created_before_binding_and_flow_activation() -> None: +def test_connections_are_proven_before_runtime_apply() -> None: text = (_WORKDAY_DA / "configure-power-platform.md").read_text( encoding="utf-8" ) + normalized = " ".join(text.split()) - prepare = text.index("## DA4.0 — Prepare the connections page") - bind = text.index("## DA4.3 — Bind the extension connections") - - assert prepare < bind - assert "make.preprod.powerautomate.com" in text - assert "make.powerautomate.com" in text - assert "Workday and Dataverse connections show **Connected**" in text - assert "msdyn_sharedworkdaysoap_workdayruntime" in text - assert "msdyn_sharedcommondataserviceforapps_workdayruntime" in text - - -def test_topic_and_authorization_guidance_matches_the_supported_runtime() -> None: - text = (_WORKDAY_DA / "configure-power-platform.md").read_text( - encoding="utf-8" + assert text.index("## Connections") < text.index( + "## Runtime approval and apply" ) - - assert "Enable all Workday topics" in text - assert "Choose specific Workday topics" in text - assert "legacy ISU/RaaS" not in text - assert "Prefer: return=representation" in text - assert "fails closed on these" in text - assert "Do not rely on the script's exit code" not in text + assert "runtime-plan" in text + assert "set `connections` to" in text + assert "runtime-apply" in text + assert "one shared Dataverse session" in normalized + assert "delegated" in text + assert "User Context V2" in text -def test_readiness_requires_a_signed_in_runtime_scenario() -> None: +def test_readiness_requires_real_employee_runtime_evidence() -> None: text = (_WORKDAY_DA / "verify-connection.md").read_text(encoding="utf-8") normalized = " ".join(text.split()) - assert "Run one enabled Workday scenario" in text - assert "returns real Workday data" in normalized - assert 'status: "ready"' in text - assert "does not prove the live" in text + assert "real signed-in employee scenario" in text + assert "returns real" in text + assert "without an unexpected repeated sign-in" in text + assert "Never record employee data or credentials" in text + assert "Do not reset completed phases" in normalized From d12dd9bccef53d4638b598afba8905df0fdf3104 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Fri, 25 Sep 2026 23:03:12 -0700 Subject: [PATCH 06/20] Address Workday lifecycle review follow-ups --- .../scripts/workday_connect_preflight.py | 34 ++- .../scripts/workday_connect_runtime.py | 249 +++++++++++++---- .../scripts/workday_connect_store.py | 1 + .../workday-da/configure-power-platform.md | 16 +- .../setup/workday-da/install-extension.md | 22 +- .../scripts/test_workday_connect_preflight.py | 71 +++++ tests/scripts/test_workday_connect_runtime.py | 259 ++++++++++++++++-- tests/scripts/test_workday_connect_store.py | 12 +- 8 files changed, 581 insertions(+), 83 deletions(-) diff --git a/solutions/ess-maker-skills/scripts/workday_connect_preflight.py b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py index 63437eb1..74f2a7c3 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_preflight.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py @@ -12,7 +12,11 @@ from auth import authenticate, query_all from install_workday_da_extension import install_workday_package -from workday_connect_auth import authentication_plan, require_identity +from workday_connect_auth import ( + WorkdayConnectIdentityError, + authentication_plan, + require_identity, +) from workday_connect_model import load_catalog from workday_connect_store import WorkdayConnectStore @@ -24,6 +28,7 @@ class WorkdayConnectPreflightError(RuntimeError): @dataclass(frozen=True) class PreflightTarget: agent: dict[str, Any] + environment_id: str | None architecture: str package_flavor: str dataverse_url: str @@ -166,12 +171,18 @@ def resolve_target( "The Workday Dataverse environment URL must use HTTPS." ) foundation_ring = str(foundation.get("ring") or "prod").casefold() + environment_id = str( + foundation.get("environmentId") + or (setup_state.get("environment") or {}).get("id") + or "" + ).strip() return PreflightTarget( agent={ key: agent[key] for key in ("slug", "botId", "schemaName", "name") if agent.get(key) }, + environment_id=environment_id or None, architecture=supported["architecture"], package_flavor=supported["packageFlavor"], dataverse_url=exact_url, @@ -223,6 +234,13 @@ def run_preflight( active_catalog = catalog or load_catalog() state_store = store or WorkdayConnectStore(workspace_root) state = state_store.initialize() + stored_maker = str( + ( + state.get("operators", {}).get("powerPlatformMaker") or {} + ).get("username") + or "" + ).strip() + intended_maker = str(maker_username or stored_maker or "").strip() or None target = resolve_target( workspace_root, dataverse_url=dataverse_url, @@ -231,12 +249,15 @@ def run_preflight( ) token = token_provider( target.dataverse_url, - preferred_username=maker_username, - ) - identity = identity_provider( - token, - preferred_username=maker_username, + preferred_username=intended_maker, ) + try: + identity = identity_provider( + token, + preferred_username=intended_maker, + ) + except WorkdayConnectIdentityError as exc: + raise WorkdayConnectPreflightError(str(exc)) from exc installed = _installed_solutions( target.dataverse_url, token, @@ -280,6 +301,7 @@ def run_preflight( { "agent": target.agent, "dataverseUrl": target.dataverse_url, + "environmentId": target.environment_id, "architecture": target.architecture, "packageFlavor": target.package_flavor, "ring": target.foundation_ring, diff --git a/solutions/ess-maker-skills/scripts/workday_connect_runtime.py b/solutions/ess-maker-skills/scripts/workday_connect_runtime.py index 471b4951..25781cb9 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_runtime.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_runtime.py @@ -20,6 +20,10 @@ resolve_pac_executable, ) from workday_connect_model import load_catalog, plan_hash +from workday_connect_auth import ( + WorkdayConnectIdentityError, + require_identity, +) ACTIVE_FLOW_STATE = 1 @@ -83,18 +87,18 @@ def _select_connection( ) ] if len(matches) != 1: - safe = [ + safe = sorted( { - "id": value.get("name"), - "displayName": (value.get("properties") or {}).get( - "displayName" - ), + str( + (value.get("properties") or {}).get("displayName") + or connector_name + ) + for value in matches } - for value in matches - ] + ) raise WorkdayConnectRuntimeError( f"Expected exactly one connected {connector_name} connection; " - f"found {len(matches)}. Candidates: " + f"found {len(matches)}. Connected display names: " f"{json.dumps(safe, sort_keys=True)}" ) return matches[0] @@ -106,22 +110,28 @@ def _list_connections( *, runner: Callable[..., subprocess.CompletedProcess], ) -> list[dict[str, Any]]: - result = runner( - [ - str(pac_executable), - "connectivity", - "list-connections", - "--environment", - environment_url, - "--json", - ], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=120, - check=False, - ) + try: + result = runner( + [ + str(pac_executable), + "connectivity", + "list-connections", + "--environment", + environment_url, + "--json", + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise WorkdayConnectRuntimeError( + "PAC did not finish listing environment connections within " + "2 minutes." + ) from exc if result.returncode != 0: raise WorkdayConnectRuntimeError( "PAC could not list the target environment's connections." @@ -197,6 +207,7 @@ def _runtime_flows( environment_url: str, token: str, flow_names: list[str], + allowed_workflow_ids: set[str], *, query: Callable[..., list[dict[str, Any]]], ) -> dict[str, dict[str, Any]]: @@ -211,6 +222,17 @@ def _runtime_flows( filters, ) flows = _single_rows(rows, flow_names, "name", "Workday flow") + outside_package = [ + name + for name, row in flows.items() + if str(row.get("workflowid") or "").casefold() + not in allowed_workflow_ids + ] + if outside_package: + raise WorkdayConnectRuntimeError( + "A reviewed Workday flow name resolved outside the selected " + "installed package: " + ", ".join(sorted(outside_package)) + ) non_cloud = [ name for name, row in flows.items() @@ -224,6 +246,50 @@ def _runtime_flows( return flows +def _solution_component_ids( + environment_url: str, + token: str, + solution_schema: str, + *, + query: Callable[..., list[dict[str, Any]]], +) -> set[str]: + solutions = query( + environment_url, + token, + "solutions", + "solutionid,uniquename", + f"uniquename eq '{_odata_literal(solution_schema)}'", + ) + if len(solutions) != 1: + raise WorkdayConnectRuntimeError( + "Expected exactly one installed Workday package solution " + f"'{solution_schema}'; found {len(solutions)}." + ) + solution_id = _required_text( + solutions[0], + "solutionid", + f"Solution ID for {solution_schema}", + ) + components = query( + environment_url, + token, + "solutioncomponents", + "objectid,componenttype", + f"_solutionid_value eq {solution_id} and componenttype eq 29", + ) + component_ids = { + str(value.get("objectid") or "").casefold() + for value in components + if str(value.get("objectid") or "").strip() + } + if not component_ids: + raise WorkdayConnectRuntimeError( + "The installed Workday package did not expose solution-component " + "membership for its reviewed flows." + ) + return component_ids + + def _topic_document(data: str) -> dict[str, Any] | None: if not data.strip(): return {} @@ -432,10 +498,21 @@ def discover_runtime_plan( logical_names, query=query, ) + solution_component_ids = _solution_component_ids( + environment_url, + active_token, + _required_text( + package, + "solutionSchemaName", + "Workday package solution schema", + ), + query=query, + ) flows = _runtime_flows( environment_url, active_token, [str(name) for name in flow_names], + solution_component_ids, query=query, ) topics = _runtime_topics( @@ -445,8 +522,22 @@ def discover_runtime_plan( query=query, ) target_connections = { - logical_names[0]: str(workday.get("name") or ""), - logical_names[1]: str(dataverse.get("name") or ""), + logical_names[0]: { + "connectionId": str(workday.get("name") or ""), + "displayName": str( + (workday.get("properties") or {}).get("displayName") + or "Workday" + ), + "connector": references_catalog["workday"]["connectorName"], + }, + logical_names[1]: { + "connectionId": str(dataverse.get("name") or ""), + "displayName": str( + (dataverse.get("properties") or {}).get("displayName") + or "Microsoft Dataverse" + ), + "connector": references_catalog["dataverse"]["connectorName"], + }, } flow_targets = [ { @@ -495,7 +586,13 @@ def discover_runtime_plan( } observed = { "connectionBindings": { - name: references[name].get("connectionid") + name: { + "selectedDisplayName": target_connections[name]["displayName"], + "alreadyBoundToSelection": str( + references[name].get("connectionid") or "" + ).casefold() + == target_connections[name]["connectionId"].casefold(), + } for name in logical_names }, "flowStates": { @@ -509,6 +606,15 @@ def discover_runtime_plan( } return { "plan": {**plan, "planHash": plan_hash(plan)}, + "approvalSummary": { + "environmentUrl": environment_url, + "agentName": str(agent.get("name") or "ESS HR agent"), + "connections": [ + value["displayName"] for value in target_connections.values() + ], + "flows": [target["name"] for target in flow_targets], + "userContextTarget": TARGET_TOPIC_NAME, + }, "observed": observed, } @@ -522,6 +628,7 @@ def run_runtime_operation( workday_connection_id: str | None = None, dataverse_connection_id: str | None = None, token_provider: Callable[..., str] = authenticate, + identity_provider: Callable[..., dict[str, str]] = require_identity, query: Callable[..., list[dict[str, Any]]] = query_all, updater: Callable[..., bool] = update_record, authorization_runner: Callable[ @@ -549,6 +656,13 @@ def run_runtime_operation( environment_url, preferred_username=maker, ) + try: + identity = identity_provider( + token, + preferred_username=maker, + ) + except WorkdayConnectIdentityError as exc: + raise WorkdayConnectRuntimeError(str(exc)) from exc discovery = discover_runtime_plan( state, workday_connection_id=workday_connection_id, @@ -559,7 +673,7 @@ def run_runtime_operation( **discovery_dependencies, ) if not apply: - return discovery + return {**discovery, "authenticatedAccount": identity["username"]} if not approved_hash or verifier is None: raise WorkdayConnectRuntimeError( "Runtime apply requires an approved plan hash." @@ -576,6 +690,7 @@ def run_runtime_operation( "plan": discovery["plan"], "observedBeforeApply": discovery["observed"], "applied": applied, + "authenticatedAccount": identity["username"], } @@ -591,26 +706,32 @@ def _run_authorization( ) authorization = plan["delegatedAuthorization"] for workflow_id in authorization["workflowIds"]: - result = runner( - [ - shell, - "-NoProfile", - "-File", - str(Path(__file__).parent / authorization["script"]), - "-OrgUrl", - plan["scope"]["dataverseUrl"], - "-BotId", - authorization["botId"], - "-WorkflowId", - workflow_id, - ], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=600, - check=False, - ) + try: + result = runner( + [ + shell, + "-NoProfile", + "-File", + str(Path(__file__).parent / authorization["script"]), + "-OrgUrl", + plan["scope"]["dataverseUrl"], + "-BotId", + authorization["botId"], + "-WorkflowId", + workflow_id, + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=600, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise WorkdayConnectRuntimeError( + "Delegated flow authorization did not finish within " + f"10 minutes for workflow {workflow_id}." + ) from exc output = (result.stdout or "") + "\n" + (result.stderr or "") if ( result.returncode != 0 @@ -644,6 +765,23 @@ def _run_authorization( ) +def _require_approved_flow_targets( + flows: Mapping[str, Mapping[str, Any]], + targets: list[Mapping[str, Any]], +) -> None: + changed = [ + str(target["name"]) + for target in targets + if str(flows[str(target["name"])].get("workflowid") or "").casefold() + != str(target["workflowId"]).casefold() + ] + if changed: + raise WorkdayConnectRuntimeError( + "Reviewed Workday flow identity changed after approval: " + + ", ".join(sorted(changed)) + ) + + def apply_runtime_plan( plan: Mapping[str, Any], *, @@ -656,7 +794,11 @@ def apply_runtime_plan( ) -> dict[str, Any]: """Apply one approved runtime plan with one shared Dataverse token.""" environment_url = plan["scope"]["dataverseUrl"] - bindings = plan["connectionBindings"] + binding_targets = plan["connectionBindings"] + bindings = { + logical_name: target["connectionId"] + for logical_name, target in binding_targets.items() + } references = _runtime_references( environment_url, token, @@ -686,8 +828,10 @@ def apply_runtime_plan( environment_url, token, flow_names, + {value["workflowId"].casefold() for value in plan["flows"]}, query=query, ) + _require_approved_flow_targets(flows, plan["flows"]) for target in plan["flows"]: flow = flows[target["name"]] if ( @@ -757,8 +901,10 @@ def apply_runtime_plan( environment_url, token, flow_names, + {value["workflowId"].casefold() for value in plan["flows"]}, query=query, ) + _require_approved_flow_targets(verified_flows, plan["flows"]) inactive = [ name for name, row in verified_flows.items() @@ -788,7 +934,10 @@ def apply_runtime_plan( ) return { "verified": True, - "connectionBindings": bindings, + "connectionBindings": { + logical_name: target["displayName"] + for logical_name, target in binding_targets.items() + }, "flows": flow_names, "userContext": plan["userContext"]["targetTopicSchema"], "delegatedAuthorization": "verified-by-script", diff --git a/solutions/ess-maker-skills/scripts/workday_connect_store.py b/solutions/ess-maker-skills/scripts/workday_connect_store.py index 58b1c0ed..451d5998 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_store.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_store.py @@ -230,6 +230,7 @@ def migrate_legacy_state(document: Mapping[str, Any]) -> dict[str, Any]: operator_map = { "entraAdminUsername": ("entraAdmin", "username"), + "entraAdminAccount": ("entraAdmin", "username"), "makerUsername": ("powerPlatformMaker", "username"), } for legacy, (operator, field) in operator_map.items(): diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md index c9f4f87b..86ef4a4b 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md @@ -13,6 +13,12 @@ connections in the selected environment: Explain that Workday connector OAuth is another credential store and may open its own sign-in. Do not ask the maker to paste connection IDs. +In Copilot Studio, connect the reviewed Workday flows to the selected ESS HR +agent. For every agent connection used by those flows, enable **Allow +permission to share parameters**. This is what prevents each employee from +receiving an unexpected first-use connection prompt. It is distinct from +binding the package's solution connection references. + Run runtime discovery: ```powershell @@ -30,13 +36,17 @@ flows, selected agent, and User Context V2 topics. `--dataverse-connection-id`. - If none exists or a connection is not connected, leave the phase waiting and show the exact missing connector. +- Run the existing FlightCheck and require `WD-CONN-013` to pass. If it does + not, show only its safe display-name remediation, have the maker enable + parameter sharing in Copilot Studio, and rerun that check. -After successful discovery, record evidence and set `connections` to -`complete`. +After successful discovery and a passing `WD-CONN-013`, record both as evidence +and set `connections` to `complete`. ## Runtime approval and apply -Show one combined runtime plan: +Show only the returned `approvalSummary`, not raw connection, application, +workflow, or bot identifiers. The combined runtime plan will: - bind the two reviewed connection references; - activate only the checked-in Workday flow catalog; diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md index e7919145..95c33b61 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md @@ -20,10 +20,24 @@ Then run: python scripts/workday_connect.py preflight ``` -Use `--dataverse-url` only when canonical setup state has no exact Dataverse -URL. Use `--maker-username` only to pin an intended maker account or resolve -account ambiguity; never ask for it when the authenticated account is already -unambiguous. +Fresh native-agent setup state normally identifies the Agent Builder +environment but may not contain a Dataverse organization URL. When no exact +Dataverse URL is available, explain that Power Platform environment inventory +uses its own Microsoft sign-in, then run: + +```powershell +python scripts/list_environments.py +``` + +Show only Dataverse-linked environment display names, types, regions, and +URLs. Ask the maker to choose from that list and pass the selected exact URL +with `--dataverse-url`. Inventory is a discovery fallback only; once an exact +URL is known, direct Dataverse verification is authoritative even if a later +inventory call omits it. + +Use `--maker-username` only to pin an intended maker account or resolve account +ambiguity. On resume, the controller reuses the previously verified maker +identity automatically. The command performs the complete phase: diff --git a/tests/scripts/test_workday_connect_preflight.py b/tests/scripts/test_workday_connect_preflight.py index 4d76d53c..49bc0aab 100644 --- a/tests/scripts/test_workday_connect_preflight.py +++ b/tests/scripts/test_workday_connect_preflight.py @@ -78,6 +78,7 @@ def test_resolve_target_prefers_canonical_dataverse_url(tmp_path: Path) -> None: ) assert target.dataverse_url == ENV_URL + assert target.environment_id == "agent-environment" assert target.package_flavor == "runtime" @@ -159,6 +160,76 @@ def test_preflight_installs_and_reverifies_with_same_account( assert result["operator"]["credentialStores"]["pac"] == "verified" +def test_preflight_reuses_persisted_maker_identity(tmp_path: Path) -> None: + import workday_connect_preflight as preflight + import workday_connect_store as store_module + + _write_foundation(tmp_path) + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + store.merge_section( + "operators", + { + "powerPlatformMaker": { + "username": "maker@example.com", + "tenantId": "tenant-id", + } + }, + ) + observed = {} + + def token_provider(_url, *, preferred_username): + observed["preferred"] = preferred_username + return "token" + + preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username=None, + store=store, + token_provider=token_provider, + identity_provider=lambda _token, *, preferred_username: { + "username": preferred_username, + "tenantId": "tenant-id", + }, + query=lambda *_args, **_kwargs: [ + {"uniquename": "msdyn_EssWorkdayRuntime"} + ], + ) + + assert observed["preferred"] == "maker@example.com" + + +def test_preflight_identity_mismatch_is_structured(tmp_path: Path) -> None: + import workday_connect_auth as auth + import workday_connect_preflight as preflight + import workday_connect_store as store_module + + _write_foundation(tmp_path) + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + + def mismatch(_token, *, preferred_username): + raise auth.WorkdayConnectIdentityError( + "Dataverse authentication used a different account from the " + "selected Environment Maker." + ) + + with pytest.raises( + preflight.WorkdayConnectPreflightError, + match="different account", + ): + preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username="maker@example.com", + store=store, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=mismatch, + query=lambda *_args, **_kwargs: [], + ) + + def test_preflight_rejects_unproven_pac_account(tmp_path: Path) -> None: import workday_connect_preflight as preflight diff --git a/tests/scripts/test_workday_connect_runtime.py b/tests/scripts/test_workday_connect_runtime.py index 9760f7d2..aa7fb25b 100644 --- a/tests/scripts/test_workday_connect_runtime.py +++ b/tests/scripts/test_workday_connect_runtime.py @@ -91,6 +91,16 @@ def _records(): catalog["connectionReferences"]["dataverse"]["logicalName"], ] flow_names = catalog["packages"]["runtime"]["flowNames"] + flows = { + name: { + "workflowid": f"44444444-4444-4444-4444-{index:012d}", + "name": name, + "statecode": 0, + "statuscode": 1, + "category": 5, + } + for index, name in enumerate(flow_names, start=1) + } return { "references": { logical_names[0]: { @@ -103,17 +113,32 @@ def _records(): "connectionreferencelogicalname": logical_names[1], "connectionid": None, }, + "agent-workday": { + "connectionreferencelogicalname": ( + "contoso." + "55555555-5555-5555-5555-555555555555." + "shared_workdaysoap" + ), + "connectionreferencedisplayname": "ESS HR Workday", + "connectionid": "agent-workday-connection", + "connectionparametersetconfig": '{"values":{}}', + }, + "agent-dataverse": { + "connectionreferencelogicalname": ( + "contoso." + "66666666-6666-6666-6666-666666666666." + "shared_commondataserviceforapps" + ), + "connectionreferencedisplayname": "Microsoft Dataverse", + "connectionid": "agent-dataverse-connection", + "connectionparametersconfig": '{"values":{}}', + }, }, - "flows": { - name: { - "workflowid": f"44444444-4444-4444-4444-{index:012d}", - "name": name, - "statecode": 0, - "statuscode": 1, - "category": 5, - } - for index, name in enumerate(flow_names, start=1) + "solution": { + "solutionid": "77777777-7777-7777-7777-777777777777", + "uniquename": "msdyn_EssWorkdayRuntime", }, + "flows": flows, "setup": { "botcomponentid": "setup-topic", "name": runtime.SETUP_TOPIC_NAME, @@ -142,6 +167,13 @@ def _query_for(records): def query(_url, _token, entity_set, _select, filter_expr=None): if entity_set == "connectionreferences": return list(records["references"].values()) + if entity_set == "solutions": + return [records["solution"]] + if entity_set == "solutioncomponents": + return [ + {"objectid": flow["workflowid"], "componenttype": 29} + for flow in records["flows"].values() + ] if entity_set == "workflows": return list(records["flows"].values()) if entity_set == "botcomponents": @@ -161,6 +193,13 @@ def _discovery_dependencies(records): } +def _identity(_token, *, preferred_username): + return { + "username": preferred_username, + "tenantId": "tenant-id", + } + + def test_runtime_plan_uses_one_dataverse_token_and_exact_targets(): records = _records() token_calls = [] @@ -172,20 +211,30 @@ def test_runtime_plan_uses_one_dataverse_token_and_exact_targets(): (url, preferred_username) ) or "token", + identity_provider=_identity, **_discovery_dependencies(records), ) assert token_calls == [ ("https://org.crm.dynamics.com", "maker@contoso.com") ] - assert result["plan"]["connectionBindings"] == { - runtime.load_catalog()["connectionReferences"]["workday"][ - "logicalName" - ]: WORKDAY_CONNECTION, - runtime.load_catalog()["connectionReferences"]["dataverse"][ - "logicalName" - ]: DATAVERSE_CONNECTION, - } + bindings = result["plan"]["connectionBindings"] + assert { + value["connectionId"] for value in bindings.values() + } == {WORKDAY_CONNECTION, DATAVERSE_CONNECTION} + assert result["approvalSummary"]["connections"] == [ + "Workday", + "Dataverse", + ] + assert result["approvalSummary"]["userContextTarget"] == ( + runtime.TARGET_TOPIC_NAME + ) + serialized_summary = __import__("json").dumps( + result["approvalSummary"], + sort_keys=True, + ) + assert WORKDAY_CONNECTION not in serialized_summary + assert DATAVERSE_CONNECTION not in serialized_summary assert len(result["plan"]["flows"]) == 3 assert result["plan"]["userContext"]["targetTopicSchema"].endswith( "workdaysystemgetusercontextv2" @@ -212,6 +261,7 @@ def test_runtime_plan_stops_on_custom_user_context(): _state(), apply=False, token_provider=lambda *_args, **_kwargs: "token", + identity_provider=_identity, **_discovery_dependencies(records), ) @@ -254,6 +304,7 @@ def authorization_runner(command, **_kwargs): (plan["planHash"], approved_hash) ), token_provider=lambda *_args, **_kwargs: "token", + identity_provider=_identity, updater=updater, authorization_runner=authorization_runner, **_discovery_dependencies(records), @@ -289,5 +340,179 @@ def test_runtime_requires_completed_connection_phase(): approved_hash="approved", verifier=lambda *_args: None, token_provider=lambda *_args, **_kwargs: "token", + identity_provider=_identity, **_discovery_dependencies(_records()), ) + + +def test_runtime_plan_rejects_same_named_flow_outside_package(): + records = _records() + first_flow = next(iter(records["flows"].values())) + original_id = first_flow["workflowid"] + + def query(_url, _token, entity_set, _select, filter_expr=None): + if entity_set == "solutioncomponents": + return [ + {"objectid": flow["workflowid"], "componenttype": 29} + for flow in records["flows"].values() + if flow["workflowid"] != original_id + ] + return _query_for(records)( + _url, + _token, + entity_set, + _select, + filter_expr, + ) + + with pytest.raises( + runtime.WorkdayConnectRuntimeError, + match="outside the selected installed package", + ): + runtime.run_runtime_operation( + _state(), + apply=False, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=_identity, + query=query, + pac_resolver=lambda: Path("pac.exe"), + runner=_pac_runner, + ) + + +def test_runtime_connection_inventory_timeout_is_structured(): + def timed_out(command, **_kwargs): + if command[1:3] == ["auth", "list"]: + return SimpleNamespace( + returncode=0, + stdout="[1] * maker@contoso.com Public\n", + stderr="", + ) + raise __import__("subprocess").TimeoutExpired(command, 120) + + with pytest.raises( + runtime.WorkdayConnectRuntimeError, + match="within 2 minutes", + ): + runtime.run_runtime_operation( + _state(), + apply=False, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=_identity, + query=_query_for(_records()), + pac_resolver=lambda: Path("pac.exe"), + runner=timed_out, + ) + + +def test_runtime_ambiguity_reports_only_safe_display_names(): + records = _records() + connections = [ + { + "name": WORKDAY_CONNECTION, + "properties": { + "apiId": ( + "/providers/Microsoft.PowerApps/apis/shared_workdaysoap" + ), + "displayName": "Workday Primary", + "statuses": [{"status": "Connected"}], + }, + }, + { + "name": "raw-connection-id-that-must-not-appear", + "properties": { + "apiId": ( + "/providers/Microsoft.PowerApps/apis/shared_workdaysoap" + ), + "displayName": "Workday Secondary", + "statuses": [{"status": "Connected"}], + }, + }, + { + "name": DATAVERSE_CONNECTION, + "properties": { + "apiId": ( + "/providers/Microsoft.PowerApps/apis/" + "shared_commondataserviceforapps" + ), + "displayName": "Dataverse", + "statuses": [{"status": "Connected"}], + }, + }, + ] + + def runner(command, **_kwargs): + if command[1:3] == ["auth", "list"]: + return SimpleNamespace( + returncode=0, + stdout="[1] * maker@contoso.com Public\n", + stderr="", + ) + return SimpleNamespace( + returncode=0, + stdout=__import__("json").dumps({"value": connections}), + stderr="", + ) + + with pytest.raises(runtime.WorkdayConnectRuntimeError) as exc_info: + runtime.run_runtime_operation( + _state(), + apply=False, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=_identity, + query=_query_for(records), + pac_resolver=lambda: Path("pac.exe"), + runner=runner, + ) + + message = str(exc_info.value) + assert "Workday Primary" in message + assert "Workday Secondary" in message + assert WORKDAY_CONNECTION not in message + assert "raw-connection-id-that-must-not-appear" not in message + + +def test_runtime_rejects_a_different_dataverse_identity(): + import workday_connect_auth as auth + + def mismatch(_token, *, preferred_username): + raise auth.WorkdayConnectIdentityError( + "Dataverse authentication used a different account from the " + "selected Environment Maker." + ) + + with pytest.raises( + runtime.WorkdayConnectRuntimeError, + match="different account", + ): + runtime.run_runtime_operation( + _state(), + apply=False, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=mismatch, + **_discovery_dependencies(_records()), + ) + + +def test_runtime_authorization_timeout_is_structured(monkeypatch): + plan = { + "scope": {"dataverseUrl": "https://contoso.crm.dynamics.com"}, + "delegatedAuthorization": { + "script": "alm/Enable-CosmosDAFlowAuthorization.ps1", + "botId": BOT_ID, + "workflowIds": ["workflow-id"], + }, + } + + def timed_out(command, **_kwargs): + raise __import__("subprocess").TimeoutExpired(command, 600) + + monkeypatch.setattr(runtime.shutil, "which", lambda _name: "pwsh") + with pytest.raises( + runtime.WorkdayConnectRuntimeError, + match="within 10 minutes", + ): + runtime._run_authorization( + plan, + runner=timed_out, + ) diff --git a/tests/scripts/test_workday_connect_store.py b/tests/scripts/test_workday_connect_store.py index 1357cd4d..ced3c9ed 100644 --- a/tests/scripts/test_workday_connect_store.py +++ b/tests/scripts/test_workday_connect_store.py @@ -39,6 +39,7 @@ def test_migrates_legacy_rows_without_using_app_uri_as_saml_id( { "tenant": "contoso_prod", "tenantId": "entra-tenant", + "entraAdminAccount": "admin@example.com", "appIdUri": "api://application-id", "setupStatus": { "DA1.1": {"state": "done", "verifiedBy": "programmatic"}, @@ -59,6 +60,7 @@ def test_migrates_legacy_rows_without_using_app_uri_as_saml_id( == "http://www.workday.com/contoso_prod" ) assert state["migration"]["source"] == "legacy-workday-da-config" + assert state["operators"]["entraAdmin"]["username"] == "admin@example.com" assert path.with_name("config.pre-v2.json").exists() @@ -69,13 +71,17 @@ def test_migration_preserves_existing_tasks_as_snapshot(tmp_path: Path) -> None: path.parent.mkdir(parents=True) path.write_text("{}", encoding="utf-8") tasks = tmp_path / ".local/setup/workday-da/tasks.md" + connect_tasks = tmp_path / ".local/connect/workday-da/tasks.md" tasks.parent.mkdir(parents=True) - tasks.write_text("legacy checklist", encoding="utf-8") + tasks.write_text("legacy setup checklist", encoding="utf-8") + connect_tasks.write_text("legacy connect checklist", encoding="utf-8") store_module.WorkdayConnectStore(tmp_path).initialize() - assert tasks.read_text(encoding="utf-8") == "legacy checklist" - assert not (tmp_path / ".local/connect/workday-da/tasks.md").exists() + assert tasks.read_text(encoding="utf-8") == "legacy setup checklist" + assert connect_tasks.read_text(encoding="utf-8") == ( + "legacy connect checklist" + ) def test_phase_completion_requires_prerequisite(tmp_path: Path) -> None: From 0ba9bae404cac76d1c2f0a1f6b98f6daa517e898 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Fri, 25 Sep 2026 23:16:22 -0700 Subject: [PATCH 07/20] Clarify Workday capability boundaries --- .../src/skills/setup/workday-da/SKILL.md | 18 ++++++++ .../workday-da/configure-power-platform.md | 27 ++++++++++-- .../setup/workday-da/configure-tenant.md | 5 +++ .../setup/workday-da/install-extension.md | 5 +++ .../setup/workday-da/provision-entra-app.md | 34 ++++++++++----- .../setup/workday-da/verify-connection.md | 4 ++ tests/setup/test_workday_da_orchestration.py | 42 +++++++++++++++++++ 7 files changed, 120 insertions(+), 15 deletions(-) diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md index 88d7e558..54370fb0 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md @@ -25,6 +25,24 @@ copy, update, or infer status from a Markdown checklist. - Never diagnose a permission problem from a guess. Show the API, CLI, or checked-in script evidence that produced the diagnosis. +## Capability contract + +Describe each action according to who actually performs it: + +| Phase | What the skill can do | What remains a user or administrator action | +| --- | --- | --- | +| Preflight | Verify the selected agent, environment, account, and package; install the reviewed package through PAC when needed | Complete Microsoft sign-in and choose an environment when no exact URL is known | +| Microsoft Entra | Discover exact applications, validate roles, build and hash the plan, reread Graph, and record verified evidence | Create or change the Entra application in the portal; the controller has no `entra-apply` command | +| Workday administrator | Generate the handoff, validate returned non-secret values, derive endpoints, and record evidence | Change SAML, OAuth, API-client, certificate, or authentication-policy settings in Workday | +| Connections | Discover connected physical connections, verify agent parameter sharing, and record the maker's flow-attachment confirmation | Create connector connections, complete connector OAuth, connect flows to the agent, and enable parameter sharing in Copilot Studio | +| Runtime | After approval, bind reviewed solution connection references, activate reviewed package flows, configure delegated authorization, redirect an empty User Context scaffold, and reread every write | Resolve custom topic content or a package without a reviewed runtime catalog | +| Employee validation | Record safe validation evidence and retain the current blocker | Publish the agent, sign in as an employee, and run the real employee scenario | + +Never say "I changed," "I configured," "I enabled," or "I updated" for a +manual action. Say what the administrator or maker must do, then say what the +skill can verify or record afterward. Claim an automated change only after its +command succeeded and the target was reread. + ## Start or resume Run: diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md index 86ef4a4b..c007a1ba 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md @@ -3,6 +3,14 @@ ## Connections +The skill does not create physical connector connections, complete connector +OAuth, connect flows to the agent, or enable parameter sharing. The maker +performs those actions; the skill discovers and verifies the result. + +The current helpers do not independently read the Copilot Studio +flow-to-agent attachment. Record the maker's confirmation of that attachment +as manual handoff evidence; do not describe it as automatically verified. + Ask the maker to create or confirm exactly two connected Power Platform connections in the selected environment: @@ -32,19 +40,26 @@ flows, selected agent, and User Context V2 topics. - If exactly one connection exists for each connector, discovery is deterministic. - If more than one exists, show safe display names and ask which connection to - use, then rerun with `--workday-connection-id` and/or - `--dataverse-connection-id`. + use. Resolve the selected display name to its ID internally, then rerun with + `--workday-connection-id` and/or `--dataverse-connection-id`; never ask the + maker to paste or repeat an ID. - If none exists or a connection is not connected, leave the phase waiting and show the exact missing connector. - Run the existing FlightCheck and require `WD-CONN-013` to pass. If it does not, show only its safe display-name remediation, have the maker enable parameter sharing in Copilot Studio, and rerun that check. -After successful discovery and a passing `WD-CONN-013`, record both as evidence -and set `connections` to `complete`. +After successful discovery, a passing `WD-CONN-013`, and the maker's recorded +confirmation that the reviewed flows are connected to the selected agent, +record the distinct automated and manual evidence and set `connections` to +`complete`. ## Runtime approval and apply +For a package with a reviewed runtime flow catalog, the controller performs the +following writes after exact-plan approval. These are real automated changes, +not instructions for the maker: + Show only the returned `approvalSummary`, not raw connection, application, workflow, or bot identifiers. The combined runtime plan will: @@ -78,6 +93,10 @@ User Context V2 after the write. Report permission issues only from an explicit forbidden response, `[FAIL]` marker, ambiguity result, or nonzero script exit. +If runtime discovery reports that the selected package has no reviewed flow +catalog, record a manual handoff. Do not claim that connection references, +flows, authorization, or topics were changed. + Topic/business-scenario selection that is not represented by a reviewed deterministic helper remains a concise manual handoff; do not expand it into a per-topic internal checklist. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md index c7c154e3..7cd8233f 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md @@ -1,6 +1,11 @@ # Phase 3 - Workday administrator +This phase never modifies Workday. The skill generates one handoff, validates +the administrator's non-secret response, derives deterministic endpoints, and +records evidence. All Workday tenant changes are performed by the Workday +administrator. + Generate one administrator handoff: ```powershell diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md index 95c33b61..707f913e 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md @@ -49,6 +49,11 @@ The command performs the complete phase: - detects or installs the package through PAC; and - rereads Dataverse to prove the package is installed. +This is a controller-owned automated change. It is accurate to say the package +was installed only when PAC succeeded and the Dataverse reread found the +expected solution. The maker still performs any browser or device-code sign-in +and chooses the environment when discovery cannot resolve one exact target. + On failure, show the controller's concise error and preserve its blocker. Do not replace a precise PAC, authentication, or package error with a generic manual-install instruction. On success, return to `SKILL.md`. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md index 4caeb05b..f8ffc690 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md @@ -1,9 +1,15 @@ # Phase 2 - Microsoft Entra -This phase configures one exact Workday SAML application. It requires an -Application Administrator or Cloud Application Administrator; administrator -consent may require a consent-capable role. +This phase discovers and plans one exact Workday SAML application, then guides +an administrator through the approved Entra changes and verifies the result. +It requires an Application Administrator or Cloud Application Administrator; +administrator consent may require a consent-capable role. + +`workday_connect.py` has no `entra-apply` command. It validates discovery, +builds and hashes the exact plan, protects approval, and records state; it does +not create or modify the Entra application. Do not say that the skill will +create, configure, update, grant, or enable an Entra setting. ## Discover before approval @@ -46,10 +52,10 @@ it: python scripts/workday_connect.py approve-plan --phase entra --plan-json '{...}' ``` -## Apply and verify +## Administrator apply, then skill verify -Immediately before each Graph mutation, run `verify-plan` with the current -plan and approved hash. Apply only the approved actions: +Immediately before presenting the handoff, run `verify-plan` with the current +plan and approved hash. Present only these approved administrator actions: - reuse the exact app or instantiate the Workday gallery app; - configure SAML mode and the signing certificate; @@ -64,12 +70,18 @@ plan and approved hash. Apply only the approved actions: - grant administrator consent; - configure user assignment, NameID, and SAML signing as required. -Reread the application and service principal. Persist `entraAppId`, +The administrator performs those changes in the Microsoft Entra admin center. +After the administrator confirms completion, reread the application and +service principal through Microsoft Graph. Do not mark a planned action as +complete from confirmation alone; require the reread to prove it where Graph +exposes the setting. Persist `entraAppId`, `entraAppObjectId`, `entraAppIdUri`, `workdaySamlEntityId`, `scopeGuid`, and safe certificate metadata under `identifiers`. Never persist certificate contents. -Record verified actions with `complete-action`, then set the `entra` phase to -`complete`. If a portal-only setting remains, record one explicit handoff and -set the phase to `waiting`; resume by rereading the setting, not by repeating -all instructions. +Record Graph-verified actions with `complete-action`. For a portal-only setting +that Graph cannot prove, record one explicit administrator handoff and its +non-secret confirmation rather than claiming the skill changed it. Set the +phase to `complete` only after all required evidence is present; otherwise set +it to `waiting`. Resume by rereading available settings, not by repeating all +instructions. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md index 67ec579d..dd4aec79 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md @@ -4,6 +4,10 @@ This phase requires a real signed-in employee scenario. Configuration checks alone cannot complete it. +The skill cannot publish the agent, impersonate an employee, or perform this +scenario on the employee's behalf. It guides the maker through the test and +records only the safe outcome. + Ask the maker to: 1. publish the ESS HR agent; diff --git a/tests/setup/test_workday_da_orchestration.py b/tests/setup/test_workday_da_orchestration.py index 40ebe29e..62ea1e0d 100644 --- a/tests/setup/test_workday_da_orchestration.py +++ b/tests/setup/test_workday_da_orchestration.py @@ -68,3 +68,45 @@ def test_readiness_requires_real_employee_runtime_evidence() -> None: assert "without an unexpected repeated sign-in" in text assert "Never record employee data or credentials" in text assert "Do not reset completed phases" in normalized + + +def test_capability_claims_match_controller_surface() -> None: + skill = (_WORKDAY_DA / "SKILL.md").read_text(encoding="utf-8") + entra = (_WORKDAY_DA / "provision-entra-app.md").read_text( + encoding="utf-8" + ) + tenant = (_WORKDAY_DA / "configure-tenant.md").read_text( + encoding="utf-8" + ) + power_platform = ( + _WORKDAY_DA / "configure-power-platform.md" + ).read_text(encoding="utf-8") + employee = (_WORKDAY_DA / "verify-connection.md").read_text( + encoding="utf-8" + ) + + normalized = { + "skill": " ".join(skill.split()), + "entra": " ".join(entra.split()), + "tenant": " ".join(tenant.split()), + "power_platform": " ".join(power_platform.split()), + "employee": " ".join(employee.split()), + } + + assert "## Capability contract" in skill + assert "Claim an automated change only after" in normalized["skill"] + assert "has no `entra-apply` command" in normalized["entra"] + assert "does not create or modify the Entra application" in ( + normalized["entra"] + ) + assert "This phase never modifies Workday" in normalized["tenant"] + assert "does not create physical connector connections" in ( + normalized["power_platform"] + ) + assert "do not describe it as automatically verified" in ( + normalized["power_platform"] + ) + assert "These are real automated changes" in ( + normalized["power_platform"] + ) + assert "The skill cannot publish the agent" in normalized["employee"] From 682a24cd6ec394f736b77406983a6215ee6c8309 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Fri, 25 Sep 2026 23:37:51 -0700 Subject: [PATCH 08/20] Simplify and harden Workday connect lifecycle --- .../scripts/workday_connect.py | 411 +++++++++------- .../scripts/workday_connect_catalog.json | 5 +- .../scripts/workday_connect_contracts.py | 218 ++++++++- .../scripts/workday_connect_model.py | 95 +++- .../scripts/workday_connect_preflight.py | 5 +- .../scripts/workday_connect_runtime.py | 443 ++++++++++++------ .../scripts/workday_connect_store.py | 182 +++++-- .../src/skills/connect/SKILL.md | 6 +- .../src/skills/connect/step1.md | 7 +- .../src/skills/setup/workday-da/SKILL.md | 10 +- .../workday-da/configure-power-platform.md | 21 +- .../setup/workday-da/configure-tenant.md | 21 +- .../setup/workday-da/install-extension.md | 8 +- .../setup/workday-da/provision-entra-app.md | 56 ++- .../setup/workday-da/shared/config-schema.md | 21 +- .../setup/workday-da/verify-connection.md | 7 +- .../scripts/test_workday_connect_contracts.py | 114 ++++- tests/scripts/test_workday_connect_model.py | 1 + .../scripts/test_workday_connect_preflight.py | 31 +- tests/scripts/test_workday_connect_runtime.py | 78 ++- tests/scripts/test_workday_connect_store.py | 76 ++- tests/setup/test_workday_da_foundation.py | 4 +- tests/setup/test_workday_da_orchestration.py | 59 ++- 23 files changed, 1396 insertions(+), 483 deletions(-) diff --git a/solutions/ess-maker-skills/scripts/workday_connect.py b/solutions/ess-maker-skills/scripts/workday_connect.py index c731dc41..d68a41be 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect.py +++ b/solutions/ess-maker-skills/scripts/workday_connect.py @@ -9,18 +9,21 @@ import json from pathlib import Path import sys -from typing import Any +from typing import Any, Callable from workday_connect_model import ( CONTROLLER_CONTRACT_VERSION, WorkdayConnectModelError, - plan_hash, + workday_saml_entity_id, ) -from workday_connect_auth import authentication_plan from workday_connect_contracts import ( WorkdayConnectContractError, - build_entra_plan, + build_entra_handoff, build_workday_admin_packet, + validate_connections_evidence, + validate_employee_evidence, + validate_entra_verification, + validate_workday_admin_response, ) from workday_connect_preflight import ( WorkdayConnectPreflightError, @@ -77,13 +80,18 @@ def build_parser() -> argparse.ArgumentParser: help="Workspace root containing .local state.", ) subparsers = parser.add_subparsers(dest="command", required=True) - subparsers.add_parser("initialize") subparsers.add_parser("status") - subparsers.add_parser("auth-plan") - entra_plan = subparsers.add_parser("entra-plan") - entra_plan.add_argument("--discovery-json", required=True) + tenant = subparsers.add_parser("set-workday-tenant") + tenant.add_argument("--tenant", required=True) + + entra_handoff = subparsers.add_parser("entra-handoff") + entra_handoff.add_argument("--discovery-json", required=True) + record_entra = subparsers.add_parser("record-entra") + record_entra.add_argument("--verification-json", required=True) subparsers.add_parser("workday-admin-packet") + record_admin = subparsers.add_parser("record-workday-admin") + record_admin.add_argument("--response-json", required=True) runtime_plan = subparsers.add_parser("runtime-plan") runtime_plan.add_argument("--workday-connection-id") @@ -93,45 +101,245 @@ def build_parser() -> argparse.ArgumentParser: runtime_apply.add_argument("--plan-hash", required=True) runtime_apply.add_argument("--workday-connection-id") runtime_apply.add_argument("--dataverse-connection-id") + runtime_approve = subparsers.add_parser("runtime-approve") + runtime_approve.add_argument("--plan-json", required=True) + + record_connections = subparsers.add_parser("record-connections") + record_connections.add_argument("--evidence-json", required=True) + record_validation = subparsers.add_parser("record-validation") + record_validation.add_argument("--evidence-json", required=True) preflight = subparsers.add_parser("preflight") preflight.add_argument("--dataverse-url") preflight.add_argument("--maker-username") - merge = subparsers.add_parser("merge-section") - merge.add_argument( - "--section", - required=True, - choices=["scope", "identifiers", "endpoints", "operators"], + return parser + + +def _status( + _args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + return store.status() + + +def _set_workday_tenant( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + tenant = args.tenant.strip() + workday_saml_entity_id(tenant) + state = store.merge_section("scope", {"workdayTenant": tenant}) + return { + "workdayTenant": state["scope"]["workdayTenant"], + "status": store.status(), + } + + +def _entra_handoff( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + return { + "packet": build_entra_handoff( + store.load(), + _json_object(args.discovery_json, "Entra discovery"), + ) + } + + +def _record_entra( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + result = validate_entra_verification( + store.load(), + _json_object(args.verification_json, "Entra verification"), + ) + store.merge_section("identifiers", result["identifiers"]) + store.complete_action( + "entra", + "exact-application-discovered", + evidence={ + "outcome": "verified", + "applicationDisplayName": result["evidence"][ + "applicationDisplayName" + ], + }, + ) + store.complete_action( + "entra", + "administrator-configuration-verified", + evidence={ + "outcome": "verified", + "checks": result["evidence"]["checks"], + }, ) - merge.add_argument("--json", required=True) + store.set_phase_status("entra", "complete") + return {"verified": True, "status": store.status()} - phase_status = subparsers.add_parser("set-phase-status") - phase_status.add_argument("--phase", required=True) - phase_status.add_argument("--status", required=True) - phase_status.add_argument("--blocker-json") - complete = subparsers.add_parser("complete-action") - complete.add_argument("--phase", required=True) - complete.add_argument("--action", required=True) - complete.add_argument("--evidence-json") +def _workday_admin_packet( + _args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + return {"packet": build_workday_admin_packet(store.load())} - handoff = subparsers.add_parser("record-handoff") - handoff.add_argument("--phase", required=True) - handoff.add_argument("--json", required=True) - approve = subparsers.add_parser("approve-plan") - approve.add_argument("--phase", required=True) - approve.add_argument("--plan-json", required=True) +def _record_workday_admin( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + result = validate_workday_admin_response( + store.load(), + _json_object( + args.response_json, + "Workday administrator response", + ), + ) + store.merge_section("identifiers", result["identifiers"]) + store.merge_section("endpoints", result["endpoints"]) + store.complete_action( + "workday-admin", + "administrator-response-validated", + evidence={"outcome": "verified", **result["evidence"]}, + ) + store.set_phase_status("workday-admin", "complete") + return {"verified": True, "status": store.status()} - verify = subparsers.add_parser("verify-plan") - verify.add_argument("--phase", required=True) - verify.add_argument("--plan-json", required=True) - verify.add_argument("--plan-hash", required=True) - calculate = subparsers.add_parser("plan-hash") - calculate.add_argument("--plan-json", required=True) - return parser +def _runtime_plan( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + return run_runtime_operation( + store.load(), + apply=False, + workday_connection_id=args.workday_connection_id, + dataverse_connection_id=args.dataverse_connection_id, + ) + + +def _runtime_apply( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + result = run_runtime_operation( + store.load(), + apply=True, + approved_hash=args.plan_hash, + verifier=lambda plan, approved_hash: store.verify_plan( + "runtime", + plan, + approved_hash, + ), + workday_connection_id=args.workday_connection_id, + dataverse_connection_id=args.dataverse_connection_id, + stage_recorder=lambda action, evidence: store.complete_action( + "runtime", + action, + evidence=evidence, + ), + ) + store.set_phase_status("runtime", "complete") + return {**result, "status": store.status()} + + +def _runtime_approve( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + _, approved_hash = store.approve_plan( + "runtime", + _json_object(args.plan_json, "runtime plan"), + ) + return {"planHash": approved_hash, "status": store.status()} + + +def _record_connections( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + evidence = validate_connections_evidence( + _json_object(args.evidence_json, "connection evidence") + ) + actions = ( + ( + "physical-connections-verified", + { + "workdayConnected": evidence["workdayConnectionConnected"], + "dataverseConnected": evidence[ + "dataverseConnectionConnected" + ], + }, + ), + ( + "agent-parameter-sharing-verified", + {"checkpoint": "WD-CONN-013", "outcome": "passed"}, + ), + ( + "flow-attachment-confirmed", + {"makerConfirmed": evidence["flowAttachmentConfirmed"]}, + ), + ) + for action, details in actions: + store.complete_action( + "connections", + action, + evidence={"outcome": "verified", **details}, + ) + store.set_phase_status("connections", "complete") + return {"verified": True, "status": store.status()} + + +def _record_validation( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + evidence = validate_employee_evidence( + _json_object( + args.evidence_json, + "employee validation evidence", + ) + ) + store.complete_action( + "employee-validation", + "signed-in-scenario", + evidence=evidence, + ) + store.set_phase_status("employee-validation", "complete") + return {"verified": True, "status": store.status()} + + +def _preflight( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + return run_preflight( + Path(args.root), + dataverse_url=args.dataverse_url, + maker_username=args.maker_username, + store=store, + ) + + +_COMMAND_HANDLERS: dict[ + str, + Callable[[argparse.Namespace, WorkdayConnectStore], dict[str, Any]], +] = { + "status": _status, + "set-workday-tenant": _set_workday_tenant, + "entra-handoff": _entra_handoff, + "record-entra": _record_entra, + "workday-admin-packet": _workday_admin_packet, + "record-workday-admin": _record_workday_admin, + "runtime-plan": _runtime_plan, + "runtime-apply": _runtime_apply, + "runtime-approve": _runtime_approve, + "record-connections": _record_connections, + "record-validation": _record_validation, + "preflight": _preflight, +} def main() -> None: @@ -139,137 +347,10 @@ def main() -> None: args = parser.parse_args() store = WorkdayConnectStore(Path(args.root)) try: - if args.command == "initialize": - state = store.initialize() - _emit("initialize", {"state": state, "status": store.status()}) - elif args.command == "status": - _emit("status", store.status()) - elif args.command == "auth-plan": - _emit( - "auth-plan", - {"authenticationPlan": authentication_plan()}, - ) - elif args.command == "entra-plan": - plan = build_entra_plan( - store.load(), - _json_object(args.discovery_json, "Entra discovery"), - ) - _emit("entra-plan", {"plan": plan}) - elif args.command == "workday-admin-packet": - packet = build_workday_admin_packet(store.load()) - _emit("workday-admin-packet", {"packet": packet}) - elif args.command == "runtime-plan": - _emit( - "runtime-plan", - run_runtime_operation( - store.load(), - apply=False, - workday_connection_id=args.workday_connection_id, - dataverse_connection_id=args.dataverse_connection_id, - ), - ) - elif args.command == "runtime-apply": - result = run_runtime_operation( - store.load(), - apply=True, - approved_hash=args.plan_hash, - verifier=lambda plan, approved_hash: store.verify_plan( - "runtime", - plan, - approved_hash, - ), - workday_connection_id=args.workday_connection_id, - dataverse_connection_id=args.dataverse_connection_id, - ) - for action, evidence in ( - ("connection-references-bound", "Dataverse reread"), - ("runtime-flows-active", "Dataverse reread"), - ("delegated-authorization-configured", "authorization script"), - ("user-context-v2-configured", "Dataverse reread"), - ): - store.complete_action( - "runtime", - action, - evidence={ - "outcome": "verified", - "provenance": evidence, - }, - ) - state = store.set_phase_status("runtime", "complete") - _emit("runtime-apply", {**result, "state": state}) - elif args.command == "preflight": - _emit( - "preflight", - run_preflight( - Path(args.root), - dataverse_url=args.dataverse_url, - maker_username=args.maker_username, - store=store, - ), - ) - elif args.command == "merge-section": - state = store.merge_section( - args.section, - _json_object(args.json, "section data"), - ) - _emit("merge-section", {"state": state}) - elif args.command == "set-phase-status": - blocker = ( - _json_object(args.blocker_json, "blocker") - if args.blocker_json - else None - ) - state = store.set_phase_status( - args.phase, - args.status, - blocker=blocker, - ) - _emit("set-phase-status", {"state": state}) - elif args.command == "complete-action": - evidence = ( - _json_object(args.evidence_json, "evidence") - if args.evidence_json - else None - ) - state = store.complete_action( - args.phase, - args.action, - evidence=evidence, - ) - _emit("complete-action", {"state": state}) - elif args.command == "record-handoff": - state = store.record_handoff( - args.phase, - _json_object(args.json, "handoff"), - ) - _emit("record-handoff", {"state": state}) - elif args.command == "approve-plan": - state, approved_hash = store.approve_plan( - args.phase, - _json_object(args.plan_json, "plan"), - ) - _emit( - "approve-plan", - {"state": state, "planHash": approved_hash}, - ) - elif args.command == "verify-plan": - verified_hash = store.verify_plan( - args.phase, - _json_object(args.plan_json, "plan"), - args.plan_hash, - ) - _emit("verify-plan", {"planHash": verified_hash, "verified": True}) - elif args.command == "plan-hash": - _emit( - "plan-hash", - { - "planHash": plan_hash( - _json_object(args.plan_json, "plan") - ) - }, - ) - else: + handler = _COMMAND_HANDLERS.get(args.command) + if handler is None: parser.error(f"Unsupported command: {args.command}") + _emit(args.command, handler(args, store)) except ( OSError, WorkdayConnectModelError, diff --git a/solutions/ess-maker-skills/scripts/workday_connect_catalog.json b/solutions/ess-maker-skills/scripts/workday_connect_catalog.json index e102dd01..3ba28e11 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_catalog.json +++ b/solutions/ess-maker-skills/scripts/workday_connect_catalog.json @@ -5,13 +5,10 @@ "gptagent_copilotforemployeeselfservicehr": { "architecture": "native-da", "packageFlavor": "runtime" - }, - "msdyn_copilotforemployeeselfservicedahr": { - "architecture": "classic-da", - "packageFlavor": "legacy-da" } }, "unsupportedAgents": [ + "msdyn_copilotforemployeeselfservicedahr", "gptagent_copilotforemployeeselfserviceit", "msdyn_copilotforemployeeselfservicedait" ], diff --git a/solutions/ess-maker-skills/scripts/workday_connect_contracts.py b/solutions/ess-maker-skills/scripts/workday_connect_contracts.py index 5f18f5f4..316a6b96 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_contracts.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_contracts.py @@ -6,11 +6,11 @@ from __future__ import annotations from typing import Any, Mapping +from urllib.parse import urlparse from workday_connect_model import ( PhaseStatus, WorkdayConnectModelError, - plan_hash, workday_saml_entity_id, ) @@ -85,11 +85,11 @@ def _require_preflight(state: Mapping[str, Any]) -> None: ) -def build_entra_plan( +def build_entra_handoff( state: Mapping[str, Any], discovery: Mapping[str, Any], ) -> dict[str, Any]: - """Build one approval-ready Entra plan after exact app discovery.""" + """Build one exact Entra administrator handoff after discovery.""" _require_preflight(state) if not isinstance(discovery, Mapping): raise WorkdayConnectContractError( @@ -138,7 +138,7 @@ def build_entra_plan( } ) app_id_uri = f"api://{app['appId']}" if app else None - plan = { + return { "phase": "entra", "scope": { "entraTenantId": entra_tenant_id, @@ -169,7 +169,85 @@ def build_entra_plan( "Grant administrator consent and verify the final configuration", ], } - return {**plan, "planHash": plan_hash(plan)} + + +def validate_entra_verification( + state: Mapping[str, Any], + verification: Mapping[str, Any], +) -> dict[str, Any]: + """Validate safe Graph reread evidence after the administrator handoff.""" + if not isinstance(verification, Mapping): + raise WorkdayConnectContractError( + "Entra verification must contain a JSON object." + ) + application = _candidate(verification.get("application")) + scope = state.get("scope") or {} + tenant = _required_text(scope, "workdayTenant", "Workday tenant") + expected_entity_id = workday_saml_entity_id(tenant) + expected_app_uri = f"api://{application['appId']}" + observed_uris = { + _normalized_uri(value) for value in application["identifierUris"] + } + missing_uris = [ + value + for value in (expected_entity_id, expected_app_uri) + if _normalized_uri(value) not in observed_uris + ] + if missing_uris: + raise WorkdayConnectContractError( + "The verified Entra application is missing required identifier " + "URIs: " + ", ".join(missing_uris) + ) + required_checks = { + "samlMode", + "signingCertificate", + "connectorPreauthorized", + "graphDelegatedPermissions", + "adminConsent", + "userAssignmentAndNameId", + } + checks = verification.get("checks") + if not isinstance(checks, Mapping): + raise WorkdayConnectContractError( + "Entra verification checks must contain an object." + ) + failed_checks = sorted( + check for check in required_checks if checks.get(check) is not True + ) + if failed_checks: + raise WorkdayConnectContractError( + "Entra verification is incomplete: " + ", ".join(failed_checks) + ) + scope_guid = _required_text( + verification, + "scopeGuid", + "Entra user_impersonation scope ID", + ) + certificate = verification.get("certificate") + if certificate is not None and not isinstance(certificate, Mapping): + raise WorkdayConnectContractError( + "Entra certificate metadata must contain an object." + ) + safe_certificate = { + key: certificate[key] + for key in ("thumbprint", "validFrom", "validTo") + if isinstance(certificate, Mapping) and certificate.get(key) + } + return { + "identifiers": { + "entraAppId": application["appId"], + "entraAppObjectId": application["objectId"], + "entraServicePrincipalId": application["servicePrincipalId"], + "entraAppIdUri": expected_app_uri, + "workdaySamlEntityId": expected_entity_id, + "scopeGuid": scope_guid, + "signingCertificate": safe_certificate or None, + }, + "evidence": { + "applicationDisplayName": application["displayName"], + "checks": {key: True for key in sorted(required_checks)}, + }, + } def build_workday_admin_packet( @@ -231,6 +309,8 @@ def build_workday_admin_packet( "certificateValidTo", "oauthClientId", "oauthTokenUrl", + "restBaseUrl", + "soapBaseUrl", "authenticationPolicyOutcome", ], "note": ( @@ -239,4 +319,130 @@ def build_workday_admin_packet( ), }, } - return {**packet, "planHash": plan_hash(packet)} + return packet + + +def _https_url(value: Any, label: str) -> str: + text = str(value or "").strip().rstrip("/") + parsed = urlparse(text) + if parsed.scheme != "https" or not parsed.netloc: + raise WorkdayConnectContractError(f"{label} must be an HTTPS URL.") + return text + + +def validate_workday_admin_response( + state: Mapping[str, Any], + response: Mapping[str, Any], +) -> dict[str, Any]: + if not isinstance(response, Mapping): + raise WorkdayConnectContractError( + "Workday administrator response must contain a JSON object." + ) + scope = state.get("scope") or {} + tenant = _required_text(scope, "workdayTenant", "Workday tenant") + expected_entity_id = workday_saml_entity_id(tenant) + observed_entity_id = _required_text( + response, + "enabledServiceProviderId", + "Enabled Workday Service Provider ID", + ) + if _normalized_uri(observed_entity_id) != _normalized_uri( + expected_entity_id + ): + raise WorkdayConnectContractError( + "The enabled Workday Service Provider ID does not match the " + "selected Workday tenant." + ) + oauth_token_url = _https_url( + response.get("oauthTokenUrl"), + "Workday OAuth token URL", + ) + rest_base_url = _https_url( + response.get("restBaseUrl"), + "Workday REST base URL", + ) + if not rest_base_url.casefold().endswith("/ccx/api"): + raise WorkdayConnectContractError( + "Workday REST base URL must end exactly at /ccx/api." + ) + soap_base_url = _https_url( + response.get("soapBaseUrl"), + "Workday SOAP base URL", + ) + required = { + "certificateValidFrom", + "certificateValidTo", + "oauthClientId", + "authenticationPolicyOutcome", + } + values = { + key: _required_text(response, key, key) + for key in required + } + return { + "identifiers": { + "workdaySamlEntityId": expected_entity_id, + "oauthClientId": values["oauthClientId"], + }, + "endpoints": { + "oauthTokenUrl": oauth_token_url, + "restBaseUrl": rest_base_url, + "soapBaseUrl": soap_base_url, + }, + "evidence": { + "serviceProviderId": expected_entity_id, + "certificateValidFrom": values["certificateValidFrom"], + "certificateValidTo": values["certificateValidTo"], + "authenticationPolicyOutcome": values[ + "authenticationPolicyOutcome" + ], + }, + } + + +def validate_connections_evidence( + evidence: Mapping[str, Any], +) -> dict[str, Any]: + if not isinstance(evidence, Mapping): + raise WorkdayConnectContractError( + "Connection evidence must contain a JSON object." + ) + required_true = ( + "workdayConnectionConnected", + "dataverseConnectionConnected", + "parameterSharingPassed", + "flowAttachmentConfirmed", + ) + missing = sorted( + key for key in required_true if evidence.get(key) is not True + ) + if missing: + raise WorkdayConnectContractError( + "Connection evidence is incomplete: " + ", ".join(missing) + ) + return {key: True for key in required_true} + + +def validate_employee_evidence( + evidence: Mapping[str, Any], +) -> dict[str, Any]: + if not isinstance(evidence, Mapping): + raise WorkdayConnectContractError( + "Employee validation evidence must contain a JSON object." + ) + allowed = {"scenarioName", "testUserCategory", "timestamp", "outcome"} + unexpected = sorted(set(evidence) - allowed) + if unexpected: + raise WorkdayConnectContractError( + "Employee validation evidence contains unsupported fields: " + + ", ".join(unexpected) + ) + result = { + key: _required_text(evidence, key, key) + for key in allowed + } + if result["outcome"].casefold() not in {"passed", "verified"}: + raise WorkdayConnectContractError( + "Employee validation outcome must be passed or verified." + ) + return result diff --git a/solutions/ess-maker-skills/scripts/workday_connect_model.py b/solutions/ess-maker-skills/scripts/workday_connect_model.py index 95d80120..abe0a1b9 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_model.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_model.py @@ -15,7 +15,7 @@ from typing import Any, Mapping -STATE_SCHEMA_VERSION = 2 +STATE_SCHEMA_VERSION = 3 CONTROLLER_CONTRACT_VERSION = 1 CATALOG_PATH = Path(__file__).with_name("workday_connect_catalog.json") @@ -36,7 +36,6 @@ class Phase(str, Enum): class PhaseStatus(str, Enum): PENDING = "pending" ACTIVE = "active" - WAITING = "waiting" BLOCKED = "blocked" COMPLETE = "complete" @@ -87,6 +86,35 @@ class PhaseDefinition: for definition in PHASE_DEFINITIONS } +PHASE_REQUIRED_ACTIONS = { + Phase.PREFLIGHT.value: frozenset({"verify-target", "verify-package"}), + Phase.ENTRA.value: frozenset( + { + "exact-application-discovered", + "administrator-configuration-verified", + } + ), + Phase.WORKDAY_ADMIN.value: frozenset( + {"administrator-response-validated"} + ), + Phase.CONNECTIONS.value: frozenset( + { + "physical-connections-verified", + "agent-parameter-sharing-verified", + "flow-attachment-confirmed", + } + ), + Phase.RUNTIME.value: frozenset( + { + "connection-references-bound", + "runtime-flows-active", + "delegated-authorization-configured", + "user-context-v2-configured", + } + ), + Phase.EMPLOYEE_VALIDATION.value: frozenset({"signed-in-scenario"}), +} + LEGACY_PHASE_ROWS = { Phase.PREFLIGHT: ("DA1.1",), Phase.ENTRA: ( @@ -206,19 +234,6 @@ def plan_hash(plan: Mapping[str, Any]) -> str: return hashlib.sha256(encoded).hexdigest() -def scope_hash(scope: Mapping[str, Any]) -> str: - if not isinstance(scope, Mapping): - raise WorkdayConnectModelError("Workday scope must be an object.") - reject_sensitive_data(scope) - encoded = json.dumps( - scope, - sort_keys=True, - separators=(",", ":"), - ensure_ascii=True, - ).encode("utf-8") - return hashlib.sha256(encoded).hexdigest() - - def reject_sensitive_data(document: Any, path: str = "state") -> None: if isinstance(document, dict): for key, value in document.items(): @@ -236,11 +251,9 @@ def reject_sensitive_data(document: Any, path: str = "state") -> None: def default_phase_state() -> dict[str, Any]: return { "status": PhaseStatus.PENDING.value, - "scopeHash": None, "completedActions": [], "approvedPlanHash": None, "approvedPlan": None, - "manualHandoff": None, "evidence": [], "blocker": None, "updatedAt": None, @@ -272,11 +285,9 @@ def _validate_phase_state(phase_id: str, value: Any) -> None: ) required = { "status", - "scopeHash", "completedActions", "approvedPlanHash", "approvedPlan", - "manualHandoff", "evidence", "blocker", "updatedAt", @@ -301,9 +312,14 @@ def _validate_phase_state(phase_id: str, value: Any) -> None: raise WorkdayConnectModelError( f"Phase '{phase_id}' completedActions contains duplicates." ) - if not isinstance(value["evidence"], list): + if not isinstance(value["evidence"], list) or any( + not isinstance(record, dict) + or not isinstance(record.get("action"), str) + or not record.get("action") + for record in value["evidence"] + ): raise WorkdayConnectModelError( - f"Phase '{phase_id}' evidence must be an array." + f"Phase '{phase_id}' evidence must contain action records." ) approved_plan = value["approvedPlan"] approved_hash = value["approvedPlanHash"] @@ -313,6 +329,29 @@ def _validate_phase_state(phase_id: str, value: Any) -> None: raise WorkdayConnectModelError( f"Phase '{phase_id}' approved plan hash does not match." ) + if value["status"] == PhaseStatus.COMPLETE.value: + required_actions = PHASE_REQUIRED_ACTIONS[phase_id] + completed_actions = set(value["completedActions"]) + missing_actions = sorted(required_actions - completed_actions) + evidence_actions = { + str(record.get("action") or "") for record in value["evidence"] + } + missing_evidence = sorted(required_actions - evidence_actions) + if missing_actions or missing_evidence: + details = [] + if missing_actions: + details.append( + "actions=" + ", ".join(missing_actions) + ) + if missing_evidence: + details.append( + "evidence=" + ", ".join(missing_evidence) + ) + raise WorkdayConnectModelError( + f"Phase '{phase_id}' cannot be complete without required " + + " and ".join(details) + + "." + ) def validate_state(state: Any) -> dict[str, Any]: @@ -342,6 +381,19 @@ def validate_state(state: Any) -> dict[str, Any]: ) for phase_id, value in phases.items(): _validate_phase_state(phase_id, value) + for definition in PHASE_DEFINITIONS: + if definition.prerequisite is None: + continue + phase = phases[definition.identifier.value] + prerequisite = phases[definition.prerequisite.value] + if ( + phase["status"] == PhaseStatus.COMPLETE.value + and prerequisite["status"] != PhaseStatus.COMPLETE.value + ): + raise WorkdayConnectModelError( + f"Phase '{definition.identifier.value}' cannot be complete " + f"before '{definition.prerequisite.value}'." + ) expected_status = ( "ready" if all( @@ -372,7 +424,6 @@ def progress_text(state: Mapping[str, Any]) -> str: markers = { PhaseStatus.PENDING.value: "", PhaseStatus.ACTIVE.value: "→", - PhaseStatus.WAITING.value: "…", PhaseStatus.BLOCKED.value: "!", PhaseStatus.COMPLETE.value: "✓", } diff --git a/solutions/ess-maker-skills/scripts/workday_connect_preflight.py b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py index 74f2a7c3..c53a2539 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_preflight.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py @@ -149,7 +149,8 @@ def resolve_target( if supported is None: if schema in set(active_catalog["unsupportedAgents"]): raise WorkdayConnectPreflightError( - "Workday connection supports the ESS HR agent only." + "This Workday lifecycle supports the native ESS HR agent " + "only. Classic DA and ESS IT agents are not supported." ) raise WorkdayConnectPreflightError( "The selected agent is not a supported ESS HR architecture." @@ -336,7 +337,7 @@ def run_preflight( evidence={ "outcome": "passed", "packageSchema": required_schema, - "action": package_action, + "packageAction": package_action, "pacAccount": pac_identity, }, ) diff --git a/solutions/ess-maker-skills/scripts/workday_connect_runtime.py b/solutions/ess-maker-skills/scripts/workday_connect_runtime.py index 25781cb9..6d30e5ef 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_runtime.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_runtime.py @@ -415,37 +415,16 @@ def _default_runner(command: list[str], **kwargs) -> subprocess.CompletedProcess return subprocess.run(command, **kwargs) -def discover_runtime_plan( +def _runtime_discovery_context( state: Mapping[str, Any], - *, - workday_connection_id: str | None = None, - dataverse_connection_id: str | None = None, - catalog: Mapping[str, Any] | None = None, - token: str | None = None, - token_provider: Callable[..., str] = authenticate, - query: Callable[..., list[dict[str, Any]]] = query_all, - pac_resolver: Callable[[], Path] = resolve_pac_executable, - pac_auth: Callable[..., Any] = ensure_pac_auth, - runner: Callable[..., subprocess.CompletedProcess] = _default_runner, + catalog: Mapping[str, Any] | None, ) -> dict[str, Any]: - """Discover exact runtime targets and return a stable approval plan.""" scope = state.get("scope") or {} operators = state.get("operators") or {} agent = scope.get("agent") or {} - environment_url = _required_text( - scope, "dataverseUrl", "Dataverse environment URL" - ).rstrip("/") package_flavor = _required_text( scope, "packageFlavor", "Workday package flavor" ) - bot_id = _required_text(agent, "botId", "Workday agent bot ID") - maker = _required_text( - operators.get("powerPlatformMaker") or {}, - "username", - "Power Platform maker account", - ) - ring = str(scope.get("ring") or "prod").casefold() - pac_ring = "preprod" if ring in {"test", "preprod"} else "prod" active_catalog = catalog or load_catalog() package = (active_catalog.get("packages") or {}).get(package_flavor) if not isinstance(package, Mapping): @@ -458,69 +437,40 @@ def discover_runtime_plan( "This Workday architecture requires a manual runtime handoff; " "no reviewed flow catalog is available." ) + ring = str(scope.get("ring") or "prod").casefold() + return { + "agent": agent, + "environmentUrl": _required_text( + scope, "dataverseUrl", "Dataverse environment URL" + ).rstrip("/"), + "packageFlavor": package_flavor, + "botId": _required_text(agent, "botId", "Workday agent bot ID"), + "maker": _required_text( + operators.get("powerPlatformMaker") or {}, + "username", + "Power Platform maker account", + ), + "pacRing": "preprod" if ring in {"test", "preprod"} else "prod", + "package": package, + "flowNames": [str(name) for name in flow_names], + "referencesCatalog": active_catalog["connectionReferences"], + } - pac = pac_resolver() - pac_auth( - pac, - ring=pac_ring, - environment_url=environment_url, - preferred_username=maker, - runner=runner, - ) - connections = _list_connections( - pac, - environment_url, - runner=runner, - ) - references_catalog = active_catalog["connectionReferences"] - workday = _select_connection( - connections, - references_catalog["workday"]["connectorName"], - explicit_id=workday_connection_id, - ) - dataverse = _select_connection( - connections, - references_catalog["dataverse"]["connectorName"], - explicit_id=dataverse_connection_id, - ) - active_token = token or token_provider( - environment_url, - preferred_username=maker, - ) +def _build_runtime_discovery( + context: Mapping[str, Any], + *, + workday: Mapping[str, Any], + dataverse: Mapping[str, Any], + references: Mapping[str, Mapping[str, Any]], + flows: Mapping[str, Mapping[str, Any]], + topics: Mapping[str, Any], +) -> dict[str, Any]: + references_catalog = context["referencesCatalog"] logical_names = [ references_catalog["workday"]["logicalName"], references_catalog["dataverse"]["logicalName"], ] - references = _runtime_references( - environment_url, - active_token, - logical_names, - query=query, - ) - solution_component_ids = _solution_component_ids( - environment_url, - active_token, - _required_text( - package, - "solutionSchemaName", - "Workday package solution schema", - ), - query=query, - ) - flows = _runtime_flows( - environment_url, - active_token, - [str(name) for name in flow_names], - solution_component_ids, - query=query, - ) - topics = _runtime_topics( - environment_url, - active_token, - bot_id, - query=query, - ) target_connections = { logical_names[0]: { "connectionId": str(workday.get("name") or ""), @@ -546,15 +496,15 @@ def discover_runtime_plan( flows[name], "workflowid", f"Workflow ID for {name}" ), } - for name in flow_names + for name in context["flowNames"] ] plan = { "phase": "runtime", "scope": { - "dataverseUrl": environment_url, - "botId": bot_id, - "packageFlavor": package_flavor, - "makerUsername": maker, + "dataverseUrl": context["environmentUrl"], + "botId": context["botId"], + "packageFlavor": context["packageFlavor"], + "makerUsername": context["maker"], }, "connectionBindings": target_connections, "flows": flow_targets, @@ -571,7 +521,7 @@ def discover_runtime_plan( ), }, "delegatedAuthorization": { - "botId": bot_id, + "botId": context["botId"], "workflowIds": [target["workflowId"] for target in flow_targets], "script": AUTHORIZATION_SCRIPT, }, @@ -600,15 +550,17 @@ def discover_runtime_plan( "statecode": flows[name].get("statecode"), "statuscode": flows[name].get("statuscode"), } - for name in flow_names + for name in context["flowNames"] }, "userContext": topics["redirectState"], } return { "plan": {**plan, "planHash": plan_hash(plan)}, "approvalSummary": { - "environmentUrl": environment_url, - "agentName": str(agent.get("name") or "ESS HR agent"), + "environmentUrl": context["environmentUrl"], + "agentName": str( + context["agent"].get("name") or "ESS HR agent" + ), "connections": [ value["displayName"] for value in target_connections.values() ], @@ -619,6 +571,93 @@ def discover_runtime_plan( } +def discover_runtime_plan( + state: Mapping[str, Any], + *, + workday_connection_id: str | None = None, + dataverse_connection_id: str | None = None, + catalog: Mapping[str, Any] | None = None, + token: str | None = None, + token_provider: Callable[..., str] = authenticate, + query: Callable[..., list[dict[str, Any]]] = query_all, + pac_resolver: Callable[[], Path] = resolve_pac_executable, + pac_auth: Callable[..., Any] = ensure_pac_auth, + runner: Callable[..., subprocess.CompletedProcess] = _default_runner, +) -> dict[str, Any]: + """Discover exact runtime targets and return a stable approval plan.""" + context = _runtime_discovery_context(state, catalog) + pac = pac_resolver() + pac_auth( + pac, + ring=context["pacRing"], + environment_url=context["environmentUrl"], + preferred_username=context["maker"], + runner=runner, + ) + connections = _list_connections( + pac, + context["environmentUrl"], + runner=runner, + ) + references_catalog = context["referencesCatalog"] + workday = _select_connection( + connections, + references_catalog["workday"]["connectorName"], + explicit_id=workday_connection_id, + ) + dataverse = _select_connection( + connections, + references_catalog["dataverse"]["connectorName"], + explicit_id=dataverse_connection_id, + ) + + active_token = token or token_provider( + context["environmentUrl"], + preferred_username=context["maker"], + ) + logical_names = [ + references_catalog["workday"]["logicalName"], + references_catalog["dataverse"]["logicalName"], + ] + references = _runtime_references( + context["environmentUrl"], + active_token, + logical_names, + query=query, + ) + solution_component_ids = _solution_component_ids( + context["environmentUrl"], + active_token, + _required_text( + context["package"], + "solutionSchemaName", + "Workday package solution schema", + ), + query=query, + ) + flows = _runtime_flows( + context["environmentUrl"], + active_token, + context["flowNames"], + solution_component_ids, + query=query, + ) + topics = _runtime_topics( + context["environmentUrl"], + active_token, + context["botId"], + query=query, + ) + return _build_runtime_discovery( + context, + workday=workday, + dataverse=dataverse, + references=references, + flows=flows, + topics=topics, + ) + + def run_runtime_operation( state: Mapping[str, Any], *, @@ -634,6 +673,7 @@ def run_runtime_operation( authorization_runner: Callable[ ..., subprocess.CompletedProcess ] = _default_runner, + stage_recorder: Callable[[str, Mapping[str, Any]], Any] | None = None, **discovery_dependencies: Any, ) -> dict[str, Any]: """Run runtime preview or apply while reusing one Dataverse token.""" @@ -685,9 +725,9 @@ def run_runtime_operation( query=query, updater=updater, authorization_runner=authorization_runner, + stage_recorder=stage_recorder, ) return { - "plan": discovery["plan"], "observedBeforeApply": discovery["observed"], "applied": applied, "authenticatedAccount": identity["username"], @@ -782,22 +822,29 @@ def _require_approved_flow_targets( ) -def apply_runtime_plan( +def _record_runtime_stage( + stages: list[str], + action: str, + evidence: Mapping[str, Any], + recorder: Callable[[str, Mapping[str, Any]], Any] | None, +) -> None: + if recorder is not None: + recorder(action, evidence) + stages.append(action) + + +def _apply_connection_binding_stage( plan: Mapping[str, Any], *, token: str, - query: Callable[..., list[dict[str, Any]]] = query_all, - updater: Callable[..., bool] = update_record, - authorization_runner: Callable[ - ..., subprocess.CompletedProcess - ] = _default_runner, -) -> dict[str, Any]: - """Apply one approved runtime plan with one shared Dataverse token.""" + query: Callable[..., list[dict[str, Any]]], + updater: Callable[..., bool], +) -> dict[str, str]: environment_url = plan["scope"]["dataverseUrl"] - binding_targets = plan["connectionBindings"] + targets = plan["connectionBindings"] bindings = { logical_name: target["connectionId"] - for logical_name, target in binding_targets.items() + for logical_name, target in targets.items() } references = _runtime_references( environment_url, @@ -822,17 +869,49 @@ def apply_runtime_plan( ), {"connectionid": target_id}, ) + verified = _runtime_references( + environment_url, + token, + list(bindings), + query=query, + ) + wrong = [ + name + for name, target_id in bindings.items() + if str(verified[name].get("connectionid") or "").casefold() + != str(target_id).casefold() + ] + if wrong: + raise WorkdayConnectRuntimeError( + "Connection-reference verification failed: " + + ", ".join(sorted(wrong)) + ) + return { + logical_name: target["displayName"] + for logical_name, target in targets.items() + } - flow_names = [value["name"] for value in plan["flows"]] + +def _apply_flow_activation_stage( + plan: Mapping[str, Any], + *, + token: str, + query: Callable[..., list[dict[str, Any]]], + updater: Callable[..., bool], +) -> list[str]: + environment_url = plan["scope"]["dataverseUrl"] + targets = plan["flows"] + flow_names = [value["name"] for value in targets] + approved_ids = {value["workflowId"].casefold() for value in targets} flows = _runtime_flows( environment_url, token, flow_names, - {value["workflowId"].casefold() for value in plan["flows"]}, + approved_ids, query=query, ) - _require_approved_flow_targets(flows, plan["flows"]) - for target in plan["flows"]: + _require_approved_flow_targets(flows, targets) + for target in targets: flow = flows[target["name"]] if ( flow.get("statecode") == ACTIVE_FLOW_STATE @@ -849,10 +928,38 @@ def apply_runtime_plan( "statuscode": ACTIVE_FLOW_STATUS, }, ) + verified = _runtime_flows( + environment_url, + token, + flow_names, + approved_ids, + query=query, + ) + _require_approved_flow_targets(verified, targets) + inactive = [ + name + for name, row in verified.items() + if row.get("statecode") != ACTIVE_FLOW_STATE + or row.get("statuscode") != ACTIVE_FLOW_STATUS + ] + if inactive: + raise WorkdayConnectRuntimeError( + "Runtime flow verification failed: " + + ", ".join(sorted(inactive)) + ) + return flow_names - _run_authorization(plan, runner=authorization_runner) +def _apply_user_context_stage( + plan: Mapping[str, Any], + *, + token: str, + query: Callable[..., list[dict[str, Any]]], + updater: Callable[..., bool], +) -> str: + environment_url = plan["scope"]["dataverseUrl"] setup_topic_id = plan["userContext"]["setupTopicId"] + target_schema = plan["userContext"]["targetTopicSchema"] topic_rows = query( environment_url, token, @@ -866,7 +973,7 @@ def apply_runtime_plan( ) redirect_state = _redirect_state( str(topic_rows[0].get("data") or ""), - plan["userContext"]["targetTopicSchema"], + target_schema, ) if redirect_state == "custom": raise WorkdayConnectRuntimeError( @@ -878,67 +985,93 @@ def apply_runtime_plan( token, "botcomponents", setup_topic_id, - { - "data": _redirect_yaml( - plan["userContext"]["targetTopicSchema"] - ) - }, + {"data": _redirect_yaml(target_schema)}, ) - - verified_references = _runtime_references( - environment_url, - token, - list(bindings), - query=query, - ) - wrong_bindings = [ - name - for name, target_id in bindings.items() - if str(verified_references[name].get("connectionid") or "").casefold() - != str(target_id).casefold() - ] - verified_flows = _runtime_flows( - environment_url, - token, - flow_names, - {value["workflowId"].casefold() for value in plan["flows"]}, - query=query, - ) - _require_approved_flow_targets(verified_flows, plan["flows"]) - inactive = [ - name - for name, row in verified_flows.items() - if row.get("statecode") != ACTIVE_FLOW_STATE - or row.get("statuscode") != ACTIVE_FLOW_STATUS - ] - verified_topic = query( + verified = query( environment_url, token, "botcomponents", "botcomponentid,data", f"botcomponentid eq '{_odata_literal(setup_topic_id)}'", ) - topic_ok = ( - len(verified_topic) == 1 - and _redirect_state( - str(verified_topic[0].get("data") or ""), - plan["userContext"]["targetTopicSchema"], + if ( + len(verified) != 1 + or _redirect_state( + str(verified[0].get("data") or ""), + target_schema, ) - == "configured" - ) - if wrong_bindings or inactive or not topic_ok: + != "configured" + ): raise WorkdayConnectRuntimeError( - "Runtime post-write verification failed: " - f"bindings={wrong_bindings}, flows={inactive}, " - f"userContext={topic_ok}." + "User Context V2 redirect verification failed." ) + return target_schema + + +def apply_runtime_plan( + plan: Mapping[str, Any], + *, + token: str, + query: Callable[..., list[dict[str, Any]]] = query_all, + updater: Callable[..., bool] = update_record, + authorization_runner: Callable[ + ..., subprocess.CompletedProcess + ] = _default_runner, + stage_recorder: Callable[[str, Mapping[str, Any]], Any] | None = None, +) -> dict[str, Any]: + """Apply and verify ordered idempotent stages with one Dataverse token.""" + verified_stages: list[str] = [] + connection_bindings = _apply_connection_binding_stage( + plan, + token=token, + query=query, + updater=updater, + ) + _record_runtime_stage( + verified_stages, + "connection-references-bound", + {"outcome": "verified", "provenance": "Dataverse reread"}, + stage_recorder, + ) + + flow_names = _apply_flow_activation_stage( + plan, + token=token, + query=query, + updater=updater, + ) + _record_runtime_stage( + verified_stages, + "runtime-flows-active", + {"outcome": "verified", "provenance": "Dataverse reread"}, + stage_recorder, + ) + + _run_authorization(plan, runner=authorization_runner) + _record_runtime_stage( + verified_stages, + "delegated-authorization-configured", + {"outcome": "verified", "provenance": "authorization script"}, + stage_recorder, + ) + + user_context = _apply_user_context_stage( + plan, + token=token, + query=query, + updater=updater, + ) + _record_runtime_stage( + verified_stages, + "user-context-v2-configured", + {"outcome": "verified", "provenance": "Dataverse reread"}, + stage_recorder, + ) return { "verified": True, - "connectionBindings": { - logical_name: target["displayName"] - for logical_name, target in binding_targets.items() - }, + "verifiedStages": verified_stages, + "connectionBindings": connection_bindings, "flows": flow_names, - "userContext": plan["userContext"]["targetTopicSchema"], + "userContext": user_context, "delegatedAuthorization": "verified-by-script", } diff --git a/solutions/ess-maker-skills/scripts/workday_connect_store.py b/solutions/ess-maker-skills/scripts/workday_connect_store.py index 451d5998..7ab9a70f 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_store.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_store.py @@ -19,12 +19,12 @@ LEGACY_PHASE_ROWS, PHASE_BY_ID, PHASE_DEFINITIONS, + PHASE_REQUIRED_ACTIONS, PhaseStatus, WorkdayConnectModelError, default_state, plan_hash, progress_text, - scope_hash, utc_now, validate_state, workday_saml_entity_id, @@ -33,6 +33,17 @@ CONFIG_PATH = Path(".local/connect/workday-da/config.json") +_PREFLIGHT_SCOPE_KEYS = { + "agent", + "architecture", + "dataverseUrl", + "environmentId", + "packageFlavor", + "ring", + "vertical", +} +_ENTRA_SCOPE_KEYS = {"entraTenantId", "workdayTenant"} + class WorkdayConnectStoreError(RuntimeError): """Raised when Workday connect state cannot be persisted safely.""" @@ -247,6 +258,18 @@ def migrate_legacy_state(document: Mapping[str, Any]) -> dict[str, Any]: and setup_status[row].get("state") == "done" ] phase_state["evidence"] = _legacy_evidence(setup_status, rows) + if phase_state["status"] == PhaseStatus.COMPLETE.value: + for action in PHASE_REQUIRED_ACTIONS[phase.value]: + if action not in phase_state["completedActions"]: + phase_state["completedActions"].append(action) + phase_state["evidence"].append( + { + "source": "legacy-state", + "action": action, + "outcome": "verified", + "capturedAt": utc_now(), + } + ) if phase_state["status"] != PhaseStatus.PENDING.value: phase_state["updatedAt"] = utc_now() @@ -267,6 +290,85 @@ def migrate_legacy_state(document: Mapping[str, Any]) -> dict[str, Any]: return validate_state(state) +def _reset_phase(phase: dict[str, Any]) -> None: + phase.update( + { + "status": PhaseStatus.PENDING.value, + "completedActions": [], + "approvedPlanHash": None, + "approvedPlan": None, + "evidence": [], + "blocker": None, + "updatedAt": utc_now(), + } + ) + + +def _invalidate_from_phase( + state: dict[str, Any], + phase_id: str, +) -> None: + invalidate = False + for definition in PHASE_DEFINITIONS: + if definition.identifier.value == phase_id: + invalidate = True + if invalidate: + _reset_phase(state["phases"][definition.identifier.value]) + + +def _scope_invalidation_phase(changed_keys: set[str]) -> str: + if changed_keys & _PREFLIGHT_SCOPE_KEYS: + return "preflight" + if changed_keys and changed_keys <= _ENTRA_SCOPE_KEYS: + return "entra" + return "preflight" + + +def upgrade_v2_state(document: Mapping[str, Any]) -> dict[str, Any]: + state = copy.deepcopy(dict(document)) + state["schemaVersion"] = 3 + first_incomplete: str | None = None + for definition in PHASE_DEFINITIONS: + phase_id = definition.identifier.value + phase = state["phases"][phase_id] + phase.pop("scopeHash", None) + phase.pop("manualHandoff", None) + if phase["status"] == "waiting": + phase["status"] = PhaseStatus.ACTIVE.value + if first_incomplete is not None: + if phase["status"] == PhaseStatus.COMPLETE.value: + _reset_phase(phase) + continue + if phase["status"] != PhaseStatus.COMPLETE.value: + first_incomplete = phase_id + continue + required = PHASE_REQUIRED_ACTIONS[phase_id] + completed = set(phase.get("completedActions") or []) + evidence_actions = { + str(record.get("action") or "") + for record in (phase.get("evidence") or []) + if isinstance(record, dict) + } + if not required <= completed or not required <= evidence_actions: + phase["status"] = PhaseStatus.ACTIVE.value + phase["updatedAt"] = utc_now() + first_incomplete = phase_id + state["status"] = ( + "ready" + if all( + phase["status"] == PhaseStatus.COMPLETE.value + for phase in state["phases"].values() + ) + else "in-progress" + ) + state["migration"] = { + "source": "workday-connect-state-v2", + "migratedAt": utc_now(), + } + state["updatedAt"] = utc_now() + return validate_state(state) + + class WorkdayConnectStore: """Own the single durable Workday connect state file.""" @@ -279,7 +381,7 @@ def __init__( self.workspace_root = workspace_root.resolve() self.config_path = self.workspace_root / CONFIG_PATH self.lock_path = self.config_path.with_name("state.lock") - self.backup_path = self.config_path.with_name("config.pre-v2.json") + self.backup_path = self.config_path.with_name("config.pre-v3.json") self.lock_timeout = lock_timeout def initialize(self) -> dict[str, Any]: @@ -289,12 +391,16 @@ def initialize(self) -> dict[str, Any]: state = default_state() _atomic_write_json(self.config_path, state) return state - if existing.get("schemaVersion") == 2: + if existing.get("schemaVersion") == 3: return validate_state(existing) if not self.backup_path.exists(): self.backup_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(self.config_path, self.backup_path) - state = migrate_legacy_state(existing) + state = ( + upgrade_v2_state(existing) + if existing.get("schemaVersion") == 2 + else migrate_legacy_state(existing) + ) _atomic_write_json(self.config_path, state) return state @@ -308,10 +414,14 @@ def _mutate(self, mutation) -> dict[str, Any]: current = _read_json(self.config_path) if not current: current = default_state() - elif current.get("schemaVersion") != 2: + elif current.get("schemaVersion") != 3: if not self.backup_path.exists(): shutil.copy2(self.config_path, self.backup_path) - current = migrate_legacy_state(current) + current = ( + upgrade_v2_state(current) + if current.get("schemaVersion") == 2 + else migrate_legacy_state(current) + ) state = copy.deepcopy(validate_state(current)) mutation(state) state["status"] = ( @@ -342,6 +452,16 @@ def merge_section( ) def mutation(state: dict[str, Any]) -> None: + changed_keys = { + key + for key, value in values.items() + if state[section].get(key) != value + } + if section == "scope" and changed_keys: + _invalidate_from_phase( + state, + _scope_invalidation_phase(changed_keys), + ) state[section].update(dict(values)) return self._mutate(mutation) @@ -370,11 +490,24 @@ def mutation(state: dict[str, Any]) -> None: f"Complete '{prerequisite.value}' before '{phase_id}'." ) phase = state["phases"][phase_id] + if status == PhaseStatus.COMPLETE.value: + required = PHASE_REQUIRED_ACTIONS[phase_id] + completed = set(phase["completedActions"]) + evidence_actions = { + str(record.get("action") or "") + for record in phase["evidence"] + } + missing = sorted( + (required - completed) | (required - evidence_actions) + ) + if missing: + raise WorkdayConnectStoreError( + f"Phase '{phase_id}' is missing required verified " + "actions: " + ", ".join(missing) + "." + ) phase["status"] = status phase["blocker"] = dict(blocker) if blocker else None phase["updatedAt"] = utc_now() - if status == PhaseStatus.COMPLETE.value: - phase["scopeHash"] = scope_hash(state["scope"]) return self._mutate(mutation) @@ -397,11 +530,16 @@ def mutation(state: dict[str, Any]) -> None: if action not in phase["completedActions"]: phase["completedActions"].append(action) if evidence is not None: + phase["evidence"] = [ + record + for record in phase["evidence"] + if record.get("action") != action + ] phase["evidence"].append( { + **dict(evidence), "action": action, "capturedAt": utc_now(), - **dict(evidence), } ) if phase["status"] == PhaseStatus.PENDING.value: @@ -411,32 +549,16 @@ def mutation(state: dict[str, Any]) -> None: return self._mutate(mutation) - def record_handoff( - self, - phase_id: str, - handoff: Mapping[str, Any], - ) -> dict[str, Any]: - if phase_id not in PHASE_BY_ID: - raise WorkdayConnectStoreError(f"Unknown Workday phase: {phase_id}.") - - def mutation(state: dict[str, Any]) -> None: - phase = state["phases"][phase_id] - phase["manualHandoff"] = { - **dict(handoff), - "capturedAt": utc_now(), - } - phase["status"] = PhaseStatus.WAITING.value - phase["updatedAt"] = utc_now() - - return self._mutate(mutation) - def approve_plan( self, phase_id: str, plan: Mapping[str, Any], ) -> tuple[dict[str, Any], str]: - if phase_id not in PHASE_BY_ID: - raise WorkdayConnectStoreError(f"Unknown Workday phase: {phase_id}.") + if phase_id != "runtime": + raise WorkdayConnectStoreError( + "Exact apply-plan approval is supported only for the " + "controller-owned runtime phase." + ) if plan.get("phase") != phase_id: raise WorkdayConnectStoreError( "Workday plan phase does not match the requested phase." diff --git a/solutions/ess-maker-skills/src/skills/connect/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/SKILL.md index 4efe7ce6..6fd58398 100644 --- a/solutions/ess-maker-skills/src/skills/connect/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/connect/SKILL.md @@ -51,10 +51,10 @@ Workday routes by architecture before package detection: `src/skills/connect/workday/contract.json`. - **CEA full/legacy package or no package** — stop at the current unsupported installation boundary without changing state. - - **DA HR agent** — use `src/skills/setup/workday-da/SKILL.md` for the + - **Native DA HR agent** — use `src/skills/setup/workday-da/SKILL.md` for the resumable six-phase controller lifecycle. - - **DA IT or another DA agent** — unsupported for Workday in this release; - stop before creating state or entering a Workday lifecycle. + - **Classic DA HR, DA IT, or another DA agent** — unsupported for this + Workday lifecycle; stop before creating state. CEA per-agent lifecycle state is stored at `.local/connect/workday/agents/{agent-slug}/lifecycle.json`. DA Workday state diff --git a/solutions/ess-maker-skills/src/skills/connect/step1.md b/solutions/ess-maker-skills/src/skills/connect/step1.md index cf323e97..3b90d9c7 100644 --- a/solutions/ess-maker-skills/src/skills/connect/step1.md +++ b/solutions/ess-maker-skills/src/skills/connect/step1.md @@ -269,13 +269,16 @@ Please select the ESS HR Agent or contact your administrator. Stop immediately without creating Workday state or entering a lifecycle. -For `gptagent_copilotforemployeeselfservicehr` or the legacy -`msdyn_copilotforemployeeselfservicedahr` alias, read +For `gptagent_copilotforemployeeselfservicehr`, read `src/skills/setup/workday-da/SKILL.md` and follow it. That setup uses `WD-DA-PKG-001`. Do not create CEA Workday lifecycle state or run `WD-PKG-001`: DA packages share some Workday connection-reference names with CEA, so the CEA package fingerprint is not an architecture discriminator. +For the classic DA HR schema `msdyn_copilotforemployeeselfservicedahr`, explain +that the simplified Workday lifecycle currently supports only the native ESS +HR agent. Stop without creating or changing Workday state. + For a CEA agent, check the currently installed Workday extension before honoring lifecycle state: diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md index 54370fb0..9f796474 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md @@ -8,9 +8,10 @@ copy, update, or infer status from a Markdown checklist. ## Safety contract -- Support only the active ESS HR agent recorded by `/setup`. The controller - verifies the exact agent, workspace materialization, architecture, and - Dataverse environment during preflight. +- Support only the active native ESS HR agent recorded by `/setup`. Classic DA + and ESS IT agents are outside this lifecycle. The controller verifies the + exact agent, workspace materialization, architecture, and Dataverse + environment during preflight. - Never ask for a Workday password, client secret, access token, refresh token, cookie, certificate private key, or certificate body in chat. - Explain an authentication prompt before launching it. Azure CLI/Graph, PAC, @@ -32,7 +33,7 @@ Describe each action according to who actually performs it: | Phase | What the skill can do | What remains a user or administrator action | | --- | --- | --- | | Preflight | Verify the selected agent, environment, account, and package; install the reviewed package through PAC when needed | Complete Microsoft sign-in and choose an environment when no exact URL is known | -| Microsoft Entra | Discover exact applications, validate roles, build and hash the plan, reread Graph, and record verified evidence | Create or change the Entra application in the portal; the controller has no `entra-apply` command | +| Microsoft Entra | Discover exact applications, validate roles, generate one administrator handoff, reread Graph, and record verified evidence | Create or change the Entra application in the portal | | Workday administrator | Generate the handoff, validate returned non-secret values, derive endpoints, and record evidence | Change SAML, OAuth, API-client, certificate, or authentication-policy settings in Workday | | Connections | Discover connected physical connections, verify agent parameter sharing, and record the maker's flow-attachment confirmation | Create connector connections, complete connector OAuth, connect flows to the agent, and enable parameter sharing in Copilot Studio | | Runtime | After approval, bind reviewed solution connection references, activate reviewed package flows, configure delegated authorization, redirect an empty User Context scaffold, and reread every write | Resolve custom topic content or a package without a reviewed runtime catalog | @@ -48,7 +49,6 @@ command succeeded and the target was reread. Run: ```powershell -python scripts/workday_connect.py initialize python scripts/workday_connect.py status ``` diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md index c007a1ba..811025c1 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md @@ -49,10 +49,17 @@ flows, selected agent, and User Context V2 topics. not, show only its safe display-name remediation, have the maker enable parameter sharing in Copilot Studio, and rerun that check. -After successful discovery, a passing `WD-CONN-013`, and the maker's recorded -confirmation that the reviewed flows are connected to the selected agent, -record the distinct automated and manual evidence and set `connections` to -`complete`. +After successful discovery, a passing `WD-CONN-013`, and the maker's +confirmation that the reviewed flows are connected to the selected agent, run: + +```powershell +python scripts/workday_connect.py record-connections --evidence-json '{...}' +``` + +Set all four required booleans only from observed or confirmed evidence: +Workday connected, Dataverse connected, parameter sharing passed, and flow +attachment confirmed. The controller records the distinct automated and +manual evidence and completes the phase atomically. ## Runtime approval and apply @@ -76,7 +83,7 @@ overwrite or approximate the topic. Approve the exact plan: ```powershell -python scripts/workday_connect.py approve-plan --phase runtime --plan-json '{...}' +python scripts/workday_connect.py runtime-approve --plan-json '{...}' ``` Apply using the returned hash and the same disambiguating connection IDs, if @@ -89,7 +96,9 @@ python scripts/workday_connect.py runtime-apply --plan-hash "{hash}" The controller rediscovers the current target, rejects stale approval, reuses one Dataverse token for Python mutations, invokes the checked-in delegated authorization script, and verifies bindings, flow state, authorization, and -User Context V2 after the write. Report permission issues only from an +User Context V2 after each ordered stage. It records each verified stage +immediately, so a later failure resumes from durable evidence rather than +hiding earlier successful changes. Report permission issues only from an explicit forbidden response, `[FAIL]` marker, ambiguity result, or nonzero script exit. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md index 7cd8233f..c577a1b3 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md @@ -38,14 +38,19 @@ Collect one response form containing only: - certificate Valid From and Valid To dates; - Workday OAuth client ID; - OAuth token URL; +- REST base URL ending at `/ccx/api`; +- SOAP base URL; - authentication-policy outcome. Never collect a secret, password, token, cookie, certificate body, or private -key. Validate the Service Provider ID against the selected tenant and derive -the SOAP and REST endpoints deterministically. The REST base must end exactly -at `/ccx/api`. - -Merge validated non-secret values into `identifiers` and `endpoints`, record -the administrator evidence with `complete-action`, and set -`workday-admin` to `complete`. If the administrator is not available, record -the packet with `record-handoff` and return without losing prior progress. +key. Pass the response once: + +```powershell +python scripts/workday_connect.py record-workday-admin --response-json '{...}' +``` + +The controller validates the Service Provider ID, HTTPS endpoints, and exact +REST base suffix, then records the non-secret identifiers, endpoints, and +evidence atomically. If the administrator is not available, stop here; rerun +`workday-admin-packet` later to regenerate the same handoff without losing +prior progress. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md index 707f913e..eb730121 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md @@ -1,12 +1,6 @@ # Phase 1 - Preflight -Run the controller authentication briefing once: - -```powershell -python scripts/workday_connect.py auth-plan -``` - Explain only credential stores that may prompt during this phase: - Dataverse browser sign-in verifies the exact environment and maker account. @@ -41,7 +35,7 @@ identity automatically. The command performs the complete phase: -- verifies the selected setup-complete ESS HR agent; +- verifies the selected setup-complete native ESS HR agent; - chooses the architecture-specific Workday package; - verifies the exact Dataverse URL directly rather than relying on inventory visibility; diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md index f8ffc690..33242587 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md @@ -1,23 +1,26 @@ # Phase 2 - Microsoft Entra -This phase discovers and plans one exact Workday SAML application, then guides -an administrator through the approved Entra changes and verifies the result. +This phase discovers one exact Workday SAML application, then guides an +administrator through the required Entra changes and verifies the result. It requires an Application Administrator or Cloud Application Administrator; administrator consent may require a consent-capable role. -`workday_connect.py` has no `entra-apply` command. It validates discovery, -builds and hashes the exact plan, protects approval, and records state; it does -not create or modify the Entra application. Do not say that the skill will -create, configure, update, grant, or enable an Entra setting. +`workday_connect.py` does not create or modify the Entra application. It +validates exact discovery, generates one administrator handoff, validates the +Graph reread, and records evidence. Do not say that the skill will create, +configure, update, grant, or enable an Entra setting. -## Discover before approval +## Discover before handoff 1. Read the canonical Entra tenant ID and Workday tenant from controller state. If the Workday tenant is missing, ask for the signed-in Workday URL and validate its first path segment against - `^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`, then merge it into `scope` as - `workdayTenant`. + `^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`, then run: + + ```powershell + python scripts/workday_connect.py set-workday-tenant --tenant "{tenant}" + ``` 2. Align Azure CLI to the canonical Entra tenant. Explain that this is the Microsoft Graph/Azure CLI credential store before any sign-in. 3. Query the signed-in user's directory roles by stable role-template ID. @@ -37,7 +40,7 @@ Build discovery JSON with `displayName`, application `appId`, application run: ```powershell -python scripts/workday_connect.py entra-plan --discovery-json '{...}' +python scripts/workday_connect.py entra-handoff --discovery-json '{...}' ``` If no exact app exists, include `"allowCreate": true` only after the user @@ -45,17 +48,12 @@ chooses to create the Workday gallery app. If multiple apps have the exact Service Provider ID, stop for administrator remediation. Show the returned target, Service Provider ID, Entra Application ID URI, -permissions, and actions. Obtain one approval for this exact plan, then store -it: - -```powershell -python scripts/workday_connect.py approve-plan --phase entra --plan-json '{...}' -``` +permissions, and administrator actions once as one handoff. Do not add a +separate apply approval: the controller does not perform these portal changes. ## Administrator apply, then skill verify -Immediately before presenting the handoff, run `verify-plan` with the current -plan and approved hash. Present only these approved administrator actions: +Present only these administrator actions: - reuse the exact app or instantiate the Workday gallery app; - configure SAML mode and the signing certificate; @@ -79,9 +77,21 @@ exposes the setting. Persist `entraAppId`, safe certificate metadata under `identifiers`. Never persist certificate contents. -Record Graph-verified actions with `complete-action`. For a portal-only setting -that Graph cannot prove, record one explicit administrator handoff and its -non-secret confirmation rather than claiming the skill changed it. Set the -phase to `complete` only after all required evidence is present; otherwise set -it to `waiting`. Resume by rereading available settings, not by repeating all +For a portal-only setting that Graph cannot prove, include its non-secret +administrator confirmation in the `checks` object rather than claiming the +skill changed it. + +Pass the Graph reread as: + +```powershell +python scripts/workday_connect.py record-entra --verification-json '{...}' +``` + +The JSON must contain the exact application and service-principal identity, +both identifier URIs, the `user_impersonation` scope GUID, safe certificate +metadata, and true verification flags for SAML mode, signing certificate, +connector preauthorization, delegated permissions, administrator consent, and +user assignment/NameID. The command validates and completes the phase +atomically. If evidence is incomplete, record one handoff and leave the phase +waiting. Resume by rereading available settings, not by repeating all instructions. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md index f753a361..902a31c1 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md @@ -11,11 +11,11 @@ The only writable lifecycle state is: writes, and phase transitions. Skills must use `scripts/workday_connect.py`; they must not edit this file directly or create a Markdown state mirror. -## Schema version 2 +## Schema version 3 ```json { - "schemaVersion": 2, + "schemaVersion": 3, "provider": "workday", "status": "in-progress", "scope": {}, @@ -35,8 +35,8 @@ they must not edit this file directly or create a Markdown state mirror. - `operators` contains safe account and tenant provenance. - `phases` contains exactly the six controller phases. -Each phase stores status, scope hash, completed action keys, one approved plan -and hash, manual handoff, evidence, current blocker, and updated time. +Each phase stores status, completed action keys, optional runtime approval, +evidence, current blocker, and updated time. ## Identifier invariant @@ -52,12 +52,13 @@ These values are independent and must never be aliases: - Never persist passwords, client secrets, access or refresh tokens, cookies, certificate bodies, private keys, or employee data. - Persist account usernames and tenant IDs only as authentication provenance. -- Approved mutations must carry an exact plan hash. A scope or target change - invalidates the approval. +- Controller-owned runtime mutations must carry an exact plan hash. +- A relevant scope change invalidates the affected phase and all downstream + phase state, evidence, handoffs, and approvals. - A phase is complete only after the target has been reread and matching - evidence is persisted. + evidence exists for every compact required action. - The provider status becomes `ready` only when all six phases are complete. -Legacy row-based state is backed up to `config.pre-v2.json` before one-time -migration. Legacy Markdown task files, when present, are historical snapshots -and are never rewritten. +Schema-v2 or legacy row-based state is backed up to `config.pre-v3.json` +before one-time migration. Legacy Markdown task files, when present, are +historical snapshots and are never rewritten. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md index dd4aec79..9e05c4f5 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md @@ -22,11 +22,12 @@ On success, record only the scenario name, test-user category, timestamp, and outcome: ```powershell -python scripts/workday_connect.py complete-action --phase employee-validation --action signed-in-scenario --evidence-json '{...}' -python scripts/workday_connect.py set-phase-status --phase employee-validation --status complete +python scripts/workday_connect.py record-validation --evidence-json '{...}' ``` -Never record employee data or credentials. +Provide only `scenarioName`, `testUserCategory`, `timestamp`, and a passed or +verified `outcome`. The controller rejects additional fields. Never record +employee data or credentials. On failure, keep the phase active and persist one current blocker. Use the failing surface to choose the next check: diff --git a/tests/scripts/test_workday_connect_contracts.py b/tests/scripts/test_workday_connect_contracts.py index 345cc47b..5bae1958 100644 --- a/tests/scripts/test_workday_connect_contracts.py +++ b/tests/scripts/test_workday_connect_contracts.py @@ -17,8 +17,12 @@ from workday_connect_contracts import ( # noqa: E402 WorkdayConnectContractError, - build_entra_plan, + build_entra_handoff, build_workday_admin_packet, + validate_connections_evidence, + validate_employee_evidence, + validate_entra_verification, + validate_workday_admin_response, ) from workday_connect_model import default_state # noqa: E402 @@ -35,8 +39,8 @@ def _state(): return state -def test_entra_plan_selects_only_exact_service_provider_id(): - plan = build_entra_plan( +def test_entra_handoff_selects_only_exact_service_provider_id(): + handoff = build_entra_handoff( _state(), { "applications": [ @@ -62,32 +66,65 @@ def test_entra_plan_selects_only_exact_service_provider_id(): }, ) - assert plan["target"]["displayName"] == "Workday exact" - assert plan["identifiers"]["workdaySamlEntityId"] == ( + assert handoff["target"]["displayName"] == "Workday exact" + assert handoff["identifiers"]["workdaySamlEntityId"] == ( "http://www.workday.com/contoso_impl" ) - assert plan["identifiers"]["entraAppIdUri"] == ( + assert handoff["identifiers"]["entraAppIdUri"] == ( "api://44444444-4444-4444-4444-444444444444" ) - assert plan["identifiers"]["workdaySamlEntityId"] != ( - plan["identifiers"]["entraAppIdUri"] + assert handoff["identifiers"]["workdaySamlEntityId"] != ( + handoff["identifiers"]["entraAppIdUri"] ) - assert len(plan["planHash"]) == 64 + assert "planHash" not in handoff -def test_entra_plan_rejects_approval_before_exact_discovery(): +def test_entra_handoff_rejects_missing_exact_discovery(): with pytest.raises(WorkdayConnectContractError, match="No exact"): - build_entra_plan(_state(), {"applications": []}) + build_entra_handoff(_state(), {"applications": []}) -def test_entra_plan_can_plan_explicit_creation(): - plan = build_entra_plan( +def test_entra_handoff_can_request_explicit_creation(): + handoff = build_entra_handoff( _state(), {"applications": [], "allowCreate": True}, ) - assert plan["target"]["mode"] == "create" - assert plan["identifiers"]["entraAppIdUri"] is None + assert handoff["target"]["mode"] == "create" + assert handoff["identifiers"]["entraAppIdUri"] is None + + +def test_entra_verification_requires_all_expected_graph_evidence(): + app_id = "44444444-4444-4444-4444-444444444444" + result = validate_entra_verification( + _state(), + { + "application": { + "displayName": "Workday exact", + "appId": app_id, + "objectId": "55555555-5555-5555-5555-555555555555", + "servicePrincipalId": ( + "66666666-6666-6666-6666-666666666666" + ), + "identifierUris": [ + "http://www.workday.com/contoso_impl", + f"api://{app_id}", + ], + }, + "scopeGuid": "77777777-7777-7777-7777-777777777777", + "checks": { + "samlMode": True, + "signingCertificate": True, + "connectorPreauthorized": True, + "graphDelegatedPermissions": True, + "adminConsent": True, + "userAssignmentAndNameId": True, + }, + }, + ) + + assert result["identifiers"]["entraAppId"] == app_id + assert result["identifiers"]["entraAppIdUri"] == f"api://{app_id}" def test_workday_packet_uses_service_provider_id_not_app_id_uri(): @@ -137,3 +174,50 @@ def test_workday_packet_rejects_tenant_drift(): with pytest.raises(WorkdayConnectContractError, match="does not match"): build_workday_admin_packet(deepcopy(state)) + + +def test_workday_admin_response_validates_exact_endpoints(): + result = validate_workday_admin_response( + _state(), + { + "enabledServiceProviderId": ( + "http://www.workday.com/contoso_impl" + ), + "certificateValidFrom": "2026-01-01", + "certificateValidTo": "2027-01-01", + "oauthClientId": "safe-client-id", + "oauthTokenUrl": ( + "https://example.workday.com/ccx/oauth2/" + "contoso_impl/token" + ), + "restBaseUrl": "https://example.workday.com/ccx/api", + "soapBaseUrl": ( + "https://example.workday.com/ccx/service/contoso_impl" + ), + "authenticationPolicyOutcome": "verified", + }, + ) + + assert result["endpoints"]["restBaseUrl"].endswith("/ccx/api") + + +def test_connections_and_employee_evidence_are_strict(): + assert validate_connections_evidence( + { + "workdayConnectionConnected": True, + "dataverseConnectionConnected": True, + "parameterSharingPassed": True, + "flowAttachmentConfirmed": True, + } + )["parameterSharingPassed"] is True + + with pytest.raises(WorkdayConnectContractError, match="unsupported fields"): + validate_employee_evidence( + { + "scenarioName": "Read-only scenario", + "testUserCategory": "non-maker employee", + "timestamp": "2026-09-25T00:00:00Z", + "outcome": "passed", + "employeeName": "not allowed", + } + ) diff --git a/tests/scripts/test_workday_connect_model.py b/tests/scripts/test_workday_connect_model.py index 98cba792..a7b9fae0 100644 --- a/tests/scripts/test_workday_connect_model.py +++ b/tests/scripts/test_workday_connect_model.py @@ -23,6 +23,7 @@ def test_default_state_has_six_primary_phases() -> None: ] assert model.next_phase_id(state) == "preflight" assert state["status"] == "in-progress" + assert state["schemaVersion"] == 3 def test_workday_saml_entity_id_is_not_the_entra_app_uri() -> None: diff --git a/tests/scripts/test_workday_connect_preflight.py b/tests/scripts/test_workday_connect_preflight.py index 49bc0aab..f93ecf51 100644 --- a/tests/scripts/test_workday_connect_preflight.py +++ b/tests/scripts/test_workday_connect_preflight.py @@ -15,7 +15,12 @@ ENV_URL = "https://target.crm.dynamics.com" -def _write_foundation(root: Path, *, dataverse_url: str | None = None) -> None: +def _write_foundation( + root: Path, + *, + dataverse_url: str | None = None, + schema_name: str = "gptagent_copilotforemployeeselfservicehr", +) -> None: local = root / ".local" local.mkdir(parents=True, exist_ok=True) config = { @@ -29,14 +34,14 @@ def _write_foundation(root: Path, *, dataverse_url: str | None = None) -> None: "agent": { "slug": "ess-hr", "botId": BOT_ID, - "schemaName": "gptagent_copilotforemployeeselfservicehr", + "schemaName": schema_name, "name": "Employee Self-Service HR", }, "agents": [ { "slug": "ess-hr", "botId": BOT_ID, - "schemaName": "gptagent_copilotforemployeeselfservicehr", + "schemaName": schema_name, "name": "Employee Self-Service HR", } ], @@ -99,6 +104,26 @@ def test_resolve_target_accepts_exact_url_without_inventory_lookup( assert target.dataverse_url == ENV_URL +def test_resolve_target_rejects_classic_da(tmp_path: Path) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + _write_foundation( + tmp_path, + schema_name="msdyn_copilotforemployeeselfservicedahr", + ) + + with pytest.raises( + preflight.WorkdayConnectPreflightError, + match="native ESS HR agent only", + ): + preflight.resolve_target( + tmp_path, + dataverse_url=ENV_URL, + state=model.default_state(), + ) + + def test_preflight_skips_install_when_package_exists(tmp_path: Path) -> None: import workday_connect_preflight as preflight import workday_connect_store as store_module diff --git a/tests/scripts/test_workday_connect_runtime.py b/tests/scripts/test_workday_connect_runtime.py index aa7fb25b..e5e2baed 100644 --- a/tests/scripts/test_workday_connect_runtime.py +++ b/tests/scripts/test_workday_connect_runtime.py @@ -269,6 +269,7 @@ def test_runtime_plan_stops_on_custom_user_context(): def test_runtime_apply_verifies_all_mutations(monkeypatch): records = _records() verified_hashes = [] + recorded_stages = [] def updater(_url, _token, entity_set, record_id, data): if entity_set == "connectionreferences": @@ -307,11 +308,23 @@ def authorization_runner(command, **_kwargs): identity_provider=_identity, updater=updater, authorization_runner=authorization_runner, + stage_recorder=lambda action, evidence: recorded_stages.append( + (action, evidence["outcome"]) + ), **_discovery_dependencies(records), ) - assert verified_hashes == [(result["plan"]["planHash"], "approved")] + assert len(verified_hashes) == 1 + assert len(verified_hashes[0][0]) == 64 + assert verified_hashes[0][1] == "approved" + assert "plan" not in result assert result["applied"]["verified"] is True + assert recorded_stages == [ + ("connection-references-bound", "verified"), + ("runtime-flows-active", "verified"), + ("delegated-authorization-configured", "verified"), + ("user-context-v2-configured", "verified"), + ] assert all( value["connectionid"] for value in records["references"].values() @@ -326,6 +339,69 @@ def authorization_runner(command, **_kwargs): ) == "configured" +def test_runtime_records_verified_stages_before_later_failure(monkeypatch): + records = _records() + recorded_stages = [] + + def updater(_url, _token, entity_set, record_id, data): + collections = { + "connectionreferences": records["references"].values(), + "workflows": records["flows"].values(), + } + if entity_set in collections: + id_key = ( + "connectionreferenceid" + if entity_set == "connectionreferences" + else "workflowid" + ) + for value in collections[entity_set]: + if value[id_key] == record_id: + value.update(data) + return True + raise AssertionError((entity_set, record_id, data)) + + monkeypatch.setattr(runtime.shutil, "which", lambda _name: "pwsh.exe") + + with pytest.raises( + runtime.WorkdayConnectRuntimeError, + match="authorization failed", + ): + runtime.run_runtime_operation( + _state(), + apply=True, + approved_hash="approved", + verifier=lambda *_args: None, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=_identity, + updater=updater, + authorization_runner=lambda *_args, **_kwargs: SimpleNamespace( + returncode=1, + stdout="[FAIL] denied", + stderr="", + ), + stage_recorder=lambda action, _evidence: recorded_stages.append( + action + ), + **_discovery_dependencies(records), + ) + + assert recorded_stages == [ + "connection-references-bound", + "runtime-flows-active", + ] + assert all( + value["connectionid"] for value in records["references"].values() + ) + assert all( + value["statecode"] == 1 and value["statuscode"] == 2 + for value in records["flows"].values() + ) + assert runtime._redirect_state( + records["setup"]["data"], + records["target"]["schemaname"], + ) == "empty" + + def test_runtime_requires_completed_connection_phase(): state = _state() state["phases"]["connections"]["status"] = "active" diff --git a/tests/scripts/test_workday_connect_store.py b/tests/scripts/test_workday_connect_store.py index ced3c9ed..6dbef40b 100644 --- a/tests/scripts/test_workday_connect_store.py +++ b/tests/scripts/test_workday_connect_store.py @@ -21,7 +21,7 @@ def test_initialize_creates_only_json_state(tmp_path: Path) -> None: store = store_module.WorkdayConnectStore(tmp_path) state = store.initialize() - assert state["schemaVersion"] == 2 + assert state["schemaVersion"] == 3 assert _config_path(tmp_path).exists() assert not (tmp_path / ".local/connect/workday-da/tasks.md").exists() assert not (tmp_path / ".local/setup/workday-da/tasks.md").exists() @@ -61,7 +61,7 @@ def test_migrates_legacy_rows_without_using_app_uri_as_saml_id( ) assert state["migration"]["source"] == "legacy-workday-da-config" assert state["operators"]["entraAdmin"]["username"] == "admin@example.com" - assert path.with_name("config.pre-v2.json").exists() + assert path.with_name("config.pre-v3.json").exists() def test_migration_preserves_existing_tasks_as_snapshot(tmp_path: Path) -> None: @@ -116,7 +116,7 @@ def test_complete_action_is_idempotent(tmp_path: Path) -> None: assert state["phases"]["preflight"]["completedActions"] == [ "verify-target" ] - assert len(state["phases"]["preflight"]["evidence"]) == 2 + assert len(state["phases"]["preflight"]["evidence"]) == 1 def test_approved_plan_rejects_changed_target(tmp_path: Path) -> None: @@ -125,19 +125,19 @@ def test_approved_plan_rejects_changed_target(tmp_path: Path) -> None: store = store_module.WorkdayConnectStore(tmp_path) store.initialize() plan = { - "phase": "entra", + "phase": "runtime", "scope": {"tenantId": "tenant-a", "applicationId": "app-a"}, "actions": ["configure-saml"], } - _state, approved_hash = store.approve_plan("entra", plan) + _state, approved_hash = store.approve_plan("runtime", plan) - assert store.verify_plan("entra", plan, approved_hash) == approved_hash + assert store.verify_plan("runtime", plan, approved_hash) == approved_hash changed = { **plan, "scope": {"tenantId": "tenant-a", "applicationId": "app-b"}, } with pytest.raises(store_module.WorkdayConnectPlanChangedError): - store.verify_plan("entra", changed, approved_hash) + store.verify_plan("runtime", changed, approved_hash) def test_status_returns_one_progress_line_and_next_phase(tmp_path: Path) -> None: @@ -145,6 +145,12 @@ def test_status_returns_one_progress_line_and_next_phase(tmp_path: Path) -> None store = store_module.WorkdayConnectStore(tmp_path) store.initialize() + for action in ("verify-target", "verify-package"): + store.complete_action( + "preflight", + action, + evidence={"outcome": "verified"}, + ) store.set_phase_status("preflight", "complete") status = store.status() @@ -152,3 +158,59 @@ def test_status_returns_one_progress_line_and_next_phase(tmp_path: Path) -> None assert status["nextPhaseId"] == "entra" assert status["progressText"].startswith("Progress: Preflight ✓ · Entra") assert len(status["phases"]) == 6 + + +def test_phase_cannot_complete_without_required_evidence( + tmp_path: Path, +) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + + with pytest.raises( + store_module.WorkdayConnectStoreError, + match="missing required verified actions", + ): + store.set_phase_status("preflight", "complete") + + +def test_scope_change_invalidates_affected_phases(tmp_path: Path) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + store.merge_section("scope", {"agent": {"slug": "agent-a"}}) + for action in ("verify-target", "verify-package"): + store.complete_action( + "preflight", + action, + evidence={"outcome": "verified"}, + ) + store.set_phase_status("preflight", "complete") + + state = store.merge_section("scope", {"agent": {"slug": "agent-b"}}) + + assert state["phases"]["preflight"]["status"] == "pending" + assert state["phases"]["preflight"]["completedActions"] == [] + assert state["phases"]["entra"]["status"] == "pending" + + +def test_v2_state_is_downgraded_when_completion_has_no_evidence( + tmp_path: Path, +) -> None: + import workday_connect_store as store_module + + path = _config_path(tmp_path) + path.parent.mkdir(parents=True) + state = store_module.default_state() + state["schemaVersion"] = 2 + state["phases"]["preflight"]["status"] = "complete" + state["status"] = "in-progress" + path.write_text(json.dumps(state), encoding="utf-8") + + upgraded = store_module.WorkdayConnectStore(tmp_path).initialize() + + assert upgraded["schemaVersion"] == 3 + assert upgraded["phases"]["preflight"]["status"] == "active" + assert upgraded["migration"]["source"] == "workday-connect-state-v2" diff --git a/tests/setup/test_workday_da_foundation.py b/tests/setup/test_workday_da_foundation.py index 078dd645..8c5e1e83 100644 --- a/tests/setup/test_workday_da_foundation.py +++ b/tests/setup/test_workday_da_foundation.py @@ -27,7 +27,7 @@ def test_lifecycle_has_one_json_state_authority() -> None: assert "scripts/workday_connect.py" in skill assert ".local/connect/workday-da/config.json" in skill assert "must not edit this file directly" in schema - assert '"schemaVersion": 2' in schema + assert '"schemaVersion": 3' in schema assert "Markdown state mirror" in schema assert not (_WORKDAY_DA / "tasks.md").exists() assert not (_WORKDAY_DA / "shared" / "checklist-updater.md").exists() @@ -69,7 +69,7 @@ def test_entra_and_workday_identifiers_remain_distinct() -> None: assert "api://" in text assert "Never select by display name alone" in entra assert "Never alias" in schema or "must never be aliases" in schema - assert "entra-plan" in entra + assert "entra-handoff" in entra assert "workday-admin-packet" in tenant diff --git a/tests/setup/test_workday_da_orchestration.py b/tests/setup/test_workday_da_orchestration.py index 62ea1e0d..6465d4f4 100644 --- a/tests/setup/test_workday_da_orchestration.py +++ b/tests/setup/test_workday_da_orchestration.py @@ -3,8 +3,11 @@ """Contracts for the simplified Workday DA orchestration.""" +import argparse from pathlib import Path +import pytest + _REPO_ROOT = Path(__file__).resolve().parents[2] _WORKDAY_DA = ( @@ -22,7 +25,6 @@ def test_orchestrator_resumes_from_controller_status() -> None: text = (_WORKDAY_DA / "SKILL.md").read_text(encoding="utf-8") normalized = " ".join(text.split()) - assert "python scripts/workday_connect.py initialize" in text assert "python scripts/workday_connect.py status" in text assert "nextPhaseId" in text assert "Do not create, copy, update, or infer status from a Markdown" in ( @@ -52,7 +54,7 @@ def test_connections_are_proven_before_runtime_apply() -> None: "## Runtime approval and apply" ) assert "runtime-plan" in text - assert "set `connections` to" in text + assert "record-connections" in text assert "runtime-apply" in text assert "one shared Dataverse session" in normalized assert "delegated" in text @@ -66,7 +68,7 @@ def test_readiness_requires_real_employee_runtime_evidence() -> None: assert "real signed-in employee scenario" in text assert "returns real" in text assert "without an unexpected repeated sign-in" in text - assert "Never record employee data or credentials" in text + assert "Never record employee data or credentials" in normalized assert "Do not reset completed phases" in normalized @@ -95,10 +97,10 @@ def test_capability_claims_match_controller_surface() -> None: assert "## Capability contract" in skill assert "Claim an automated change only after" in normalized["skill"] - assert "has no `entra-apply` command" in normalized["entra"] assert "does not create or modify the Entra application" in ( normalized["entra"] ) + assert "record-entra" in normalized["entra"] assert "This phase never modifies Workday" in normalized["tenant"] assert "does not create physical connector connections" in ( normalized["power_platform"] @@ -110,3 +112,52 @@ def test_capability_claims_match_controller_surface() -> None: normalized["power_platform"] ) assert "The skill cannot publish the agent" in normalized["employee"] + + +def test_runtime_apply_persists_verified_stages_before_later_failure( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + import workday_connect as controller + + store = controller.WorkdayConnectStore(tmp_path) + store.initialize() + + def fail_after_two_stages(_state, **kwargs): + recorder = kwargs["stage_recorder"] + recorder( + "connection-references-bound", + {"outcome": "verified", "provenance": "Dataverse reread"}, + ) + recorder( + "runtime-flows-active", + {"outcome": "verified", "provenance": "Dataverse reread"}, + ) + raise controller.WorkdayConnectRuntimeError("authorization failed") + + monkeypatch.setattr( + controller, + "run_runtime_operation", + fail_after_two_stages, + ) + args = argparse.Namespace( + plan_hash="approved", + workday_connection_id=None, + dataverse_connection_id=None, + ) + + with pytest.raises( + controller.WorkdayConnectRuntimeError, + match="authorization failed", + ): + controller._runtime_apply(args, store) + + phase = store.load()["phases"]["runtime"] + assert phase["status"] == "active" + assert phase["completedActions"] == [ + "connection-references-bound", + "runtime-flows-active", + ] + assert { + record["action"] for record in phase["evidence"] + } == set(phase["completedActions"]) From 5d0d56a093790b67e05104298dbf3b1230e8018d Mon Sep 17 00:00:00 2001 From: is-goutham Date: Fri, 25 Sep 2026 23:49:54 -0700 Subject: [PATCH 09/20] Fix Workday connect intent routing --- .../.github/copilot-instructions.md | 2 +- .../src/skills/setup/SKILL.md | 13 ++++++++---- tests/setup/test_workday_command_discovery.py | 20 +++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/solutions/ess-maker-skills/.github/copilot-instructions.md b/solutions/ess-maker-skills/.github/copilot-instructions.md index d3496289..01d20f38 100644 --- a/solutions/ess-maker-skills/.github/copilot-instructions.md +++ b/solutions/ess-maker-skills/.github/copilot-instructions.md @@ -430,7 +430,7 @@ pushed. Run the push pipeline when the maker asks to push local changes. | User intent | Skill to read | |-------------|--------------| | Run common ESS foundation setup (`/setup`) | `src/skills/foundation-setup/SKILL.md` | -| Provision/connect the Workday setup environment (`/connect workday`) | `src/skills/setup/SKILL.md` | +| Provision/connect Workday for the active ESS HR agent (`/connect workday` or `/connect-workday`) | `src/skills/connect/SKILL.md` | | Connect to ServiceNow/Workday | `src/skills/connect/SKILL.md` | | Create a topic | `src/skills/topics/create-eval-driven/SKILL.md` | | Create a workflow | `src/skills/workflows/create/SKILL.md` | diff --git a/solutions/ess-maker-skills/src/skills/setup/SKILL.md b/solutions/ess-maker-skills/src/skills/setup/SKILL.md index ac20b04d..31b965d0 100644 --- a/solutions/ess-maker-skills/src/skills/setup/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/setup/SKILL.md @@ -1,6 +1,14 @@ # Hybrid Workday Extension Setup +This file is only the boundary for an explicit request to configure the retired +hybrid Workday extension. It is **not** the entry point for `/connect workday` +or `/connect-workday`. + +If this file is reached from either Workday connect command, immediately read +`src/skills/connect/SKILL.md` and follow its architecture-aware routing. Stop +processing this file; do not show the hybrid-unavailable message below. + Hybrid Workday keeps its flows, connections, plugins, template configurations, and environment configuration in a separately owned Dataverse-backed extension while the agent itself uses the DA-GA platform. @@ -14,10 +22,7 @@ Hybrid Workday extension setup is not available in this release. Your DA-GA agent setup is unchanged, and no Dataverse environment, solution, connection, or flow was modified. -If a hybrid Workday extension is already configured and its Dataverse endpoint -is recorded in this workspace, `/backup-template-configs` and -`/restore-template-configs` remain available for its reference-data -customisations. +Use `/connect workday` to connect Workday to a supported ESS HR agent. **End message.** diff --git a/tests/setup/test_workday_command_discovery.py b/tests/setup/test_workday_command_discovery.py index 274fbb1b..88bc8843 100644 --- a/tests/setup/test_workday_command_discovery.py +++ b/tests/setup/test_workday_command_discovery.py @@ -60,3 +60,23 @@ def test_connect_workday_bypasses_runtime_readiness_gate() -> None: assert "typed `/connect` or `/connect-workday`" in normalized assert "even when runtime `connect_ready` is false" in normalized + + +def test_connect_workday_routes_to_architecture_aware_connect() -> None: + instructions = ( + _SOLUTION / ".github" / "copilot-instructions.md" + ).read_text(encoding="utf-8") + hybrid_boundary = ( + _SOLUTION / "src" / "skills" / "setup" / "SKILL.md" + ).read_text(encoding="utf-8") + normalized = _normalize(instructions) + + assert ( + "(`/connect workday` or `/connect-workday`) | " + "`src/skills/connect/SKILL.md`" + ) in normalized + assert ( + "Provision/connect the Workday setup environment (`/connect workday`) " + "| `src/skills/setup/SKILL.md`" + ) not in normalized + assert "immediately read\n`src/skills/connect/SKILL.md`" in hybrid_boundary From 4ed7c110923df0af81e402359e37f5f645f240c2 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Fri, 25 Sep 2026 23:54:28 -0700 Subject: [PATCH 10/20] Reuse setup environment in Workday preflight --- .../scripts/workday_connect_preflight.py | 69 ++++++++++++++++--- .../setup/workday-da/install-extension.md | 26 ++++--- .../scripts/test_workday_connect_preflight.py | 34 +++++++++ 3 files changed, 113 insertions(+), 16 deletions(-) diff --git a/solutions/ess-maker-skills/scripts/workday_connect_preflight.py b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py index c53a2539..8967098d 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_preflight.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py @@ -131,6 +131,53 @@ def _pac_ring(foundation_ring: str) -> str: ) +def _cached_dataverse_url( + workspace_root: Path, + *, + environment_id: str, + ring: str, +) -> str: + if not environment_id: + return "" + inventory = _read_json( + workspace_root + / ".local" + / "setup" + / f"environment-list-{ring}.json" + ) + environments = inventory.get("environments") + if not isinstance(environments, list): + return "" + matches = [ + environment + for environment in environments + if isinstance(environment, dict) + and str(environment.get("id") or "").casefold() + == environment_id.casefold() + ] + if len(matches) > 1: + raise WorkdayConnectPreflightError( + "Setup environment inventory contains duplicate records for " + f"environment {environment_id}." + ) + if not matches: + return "" + match = matches[0] + properties = match.get("properties") + if not isinstance(properties, dict): + properties = {} + linked = properties.get("linkedEnvironmentMetadata") + if not isinstance(linked, dict): + linked = {} + return str( + match.get("url") + or match.get("instanceUrl") + or linked.get("instanceApiUrl") + or linked.get("instanceUrl") + or "" + ).strip() + + def resolve_target( workspace_root: Path, *, @@ -157,26 +204,32 @@ def resolve_target( ) _require_materialized_workspace(setup_state, agent) + foundation_ring = str(foundation.get("ring") or "prod").casefold() + environment_id = str( + foundation.get("environmentId") + or (setup_state.get("environment") or {}).get("id") + or "" + ).strip() exact_url = ( str(foundation.get("dataverseEndpoint") or "").strip() or str(state.get("scope", {}).get("dataverseUrl") or "").strip() or str(dataverse_url or "").strip() + or _cached_dataverse_url( + workspace_root, + environment_id=environment_id, + ring=foundation_ring, + ) ).rstrip("/") if not exact_url: raise WorkdayConnectPreflightError( - "Select the Dataverse environment that will host the Workday " - "runtime package." + "The setup environment does not have a resolved Dataverse URL. " + "Refresh environment inventory for the recorded environment ID " + "or provide that environment's exact Dataverse URL." ) if not exact_url.startswith("https://"): raise WorkdayConnectPreflightError( "The Workday Dataverse environment URL must use HTTPS." ) - foundation_ring = str(foundation.get("ring") or "prod").casefold() - environment_id = str( - foundation.get("environmentId") - or (setup_state.get("environment") or {}).get("id") - or "" - ).strip() return PreflightTarget( agent={ key: agent[key] diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md index eb730121..8738f5f4 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md @@ -14,19 +14,29 @@ Then run: python scripts/workday_connect.py preflight ``` -Fresh native-agent setup state normally identifies the Agent Builder -environment but may not contain a Dataverse organization URL. When no exact -Dataverse URL is available, explain that Power Platform environment inventory -uses its own Microsoft sign-in, then run: +Fresh native-agent setup state identifies the exact environment ID but may not +contain a Dataverse organization URL. The controller first resolves that URL +from setup's cached environment inventory. Do not ask the maker to re-enter or +reselect the environment when an exact ID match exists. + +When the controller reports that no Dataverse URL can be resolved, explain +that refreshing Power Platform environment inventory uses its own Microsoft +sign-in, then run: ```powershell python scripts/list_environments.py ``` -Show only Dataverse-linked environment display names, types, regions, and -URLs. Ask the maker to choose from that list and pass the selected exact URL -with `--dataverse-url`. Inventory is a discovery fallback only; once an exact -URL is known, direct Dataverse verification is authoritative even if a later +Match the inventory result to `.local/config.json` `environmentId`. When there +is exactly one matching environment with a Dataverse URL, rerun preflight with +that URL automatically. Do not show a selection list or ask the maker to +choose again. + +If the recorded environment ID is absent or has no linked Dataverse URL, stop +and explain the exact mismatch. Ask for an exact Dataverse URL only when the +maker confirms it belongs to the already recorded setup environment; never +silently select a different environment by display name. Once an exact URL is +known, direct Dataverse verification is authoritative even if a later inventory call omits it. Use `--maker-username` only to pin an intended maker account or resolve account diff --git a/tests/scripts/test_workday_connect_preflight.py b/tests/scripts/test_workday_connect_preflight.py index f93ecf51..791c54c5 100644 --- a/tests/scripts/test_workday_connect_preflight.py +++ b/tests/scripts/test_workday_connect_preflight.py @@ -104,6 +104,40 @@ def test_resolve_target_accepts_exact_url_without_inventory_lookup( assert target.dataverse_url == ENV_URL +def test_resolve_target_reuses_setup_environment_inventory( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + _write_foundation(tmp_path) + inventory = ( + tmp_path / ".local" / "setup" / "environment-list-prod.json" + ) + inventory.write_text( + json.dumps( + { + "environments": [ + { + "id": "agent-environment", + "displayName": "ESS HR", + "url": ENV_URL, + } + ] + } + ), + encoding="utf-8", + ) + + target = preflight.resolve_target( + tmp_path, + dataverse_url=None, + state=model.default_state(), + ) + + assert target.dataverse_url == ENV_URL + + def test_resolve_target_rejects_classic_da(tmp_path: Path) -> None: import workday_connect_model as model import workday_connect_preflight as preflight From 1132d8454612533079775f4a46ac80baaf3adfa2 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Sat, 26 Sep 2026 00:02:33 -0700 Subject: [PATCH 11/20] Resolve Workday Dataverse URL from PAC --- .../scripts/workday_connect_preflight.py | 67 ++++++++++++++++++- .../setup/workday-da/install-extension.md | 6 +- .../scripts/test_workday_connect_preflight.py | 67 +++++++++++++++++++ 3 files changed, 137 insertions(+), 3 deletions(-) diff --git a/solutions/ess-maker-skills/scripts/workday_connect_preflight.py b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py index 8967098d..14fc08c7 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_preflight.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py @@ -8,10 +8,15 @@ from dataclasses import dataclass import json from pathlib import Path +import subprocess from typing import Any, Callable from auth import authenticate, query_all -from install_workday_da_extension import install_workday_package +from install_workday_da_extension import ( + PacCliError, + install_workday_package, + resolve_pac_executable, +) from workday_connect_auth import ( WorkdayConnectIdentityError, authentication_plan, @@ -178,12 +183,67 @@ def _cached_dataverse_url( ).strip() +def _pac_dataverse_url( + environment_id: str, + *, + pac_resolver: Callable[[], Path], + runner: Callable[..., subprocess.CompletedProcess], +) -> str: + if not environment_id: + return "" + try: + pac = pac_resolver() + except PacCliError: + return "" + try: + result = runner( + [ + str(pac), + "org", + "who", + "--environment", + environment_id, + "--json", + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise WorkdayConnectPreflightError( + "PAC did not resolve the setup environment within one minute." + ) from exc + if result.returncode != 0: + return "" + try: + identity = json.loads(result.stdout or "") + except json.JSONDecodeError as exc: + raise WorkdayConnectPreflightError( + "PAC returned invalid environment identity JSON." + ) from exc + if not isinstance(identity, dict): + raise WorkdayConnectPreflightError( + "PAC returned an invalid environment identity result." + ) + observed_id = str(identity.get("EnvironmentId") or "").strip() + if observed_id.casefold() != environment_id.casefold(): + raise WorkdayConnectPreflightError( + "PAC resolved a different environment than setup recorded." + ) + return str(identity.get("OrgUrl") or "").strip() + + def resolve_target( workspace_root: Path, *, dataverse_url: str | None, state: dict[str, Any], catalog: dict[str, Any] | None = None, + pac_resolver: Callable[[], Path] = resolve_pac_executable, + pac_runner: Callable[..., subprocess.CompletedProcess] = subprocess.run, ) -> PreflightTarget: active_catalog = catalog or load_catalog() foundation = _read_json(workspace_root / ".local" / "config.json") @@ -219,6 +279,11 @@ def resolve_target( environment_id=environment_id, ring=foundation_ring, ) + or _pac_dataverse_url( + environment_id, + pac_resolver=pac_resolver, + runner=pac_runner, + ) ).rstrip("/") if not exact_url: raise WorkdayConnectPreflightError( diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md index 8738f5f4..fe3095fa 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md @@ -16,8 +16,10 @@ python scripts/workday_connect.py preflight Fresh native-agent setup state identifies the exact environment ID but may not contain a Dataverse organization URL. The controller first resolves that URL -from setup's cached environment inventory. Do not ask the maker to re-enter or -reselect the environment when an exact ID match exists. +from setup's cached environment inventory, then asks an existing PAC profile +for the organization whose environment ID exactly matches setup. Do not ask +the maker to re-enter or reselect the environment when either source proves an +exact ID match. When the controller reports that no Dataverse URL can be resolved, explain that refreshing Power Platform environment inventory uses its own Microsoft diff --git a/tests/scripts/test_workday_connect_preflight.py b/tests/scripts/test_workday_connect_preflight.py index 791c54c5..c33303b7 100644 --- a/tests/scripts/test_workday_connect_preflight.py +++ b/tests/scripts/test_workday_connect_preflight.py @@ -7,6 +7,7 @@ import json from pathlib import Path +import subprocess import pytest @@ -138,6 +139,72 @@ def test_resolve_target_reuses_setup_environment_inventory( assert target.dataverse_url == ENV_URL +def test_resolve_target_reuses_exact_pac_environment(tmp_path: Path) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + _write_foundation(tmp_path) + + def runner(command, **_kwargs): + assert command[-3:] == [ + "--environment", + "agent-environment", + "--json", + ] + return subprocess.CompletedProcess( + command, + 0, + stdout=json.dumps( + { + "EnvironmentId": "agent-environment", + "OrgUrl": f"{ENV_URL}/", + } + ), + stderr="", + ) + + target = preflight.resolve_target( + tmp_path, + dataverse_url=None, + state=model.default_state(), + pac_resolver=lambda: Path("pac.exe"), + pac_runner=runner, + ) + + assert target.dataverse_url == ENV_URL + + +def test_resolve_target_rejects_mismatched_pac_environment( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + _write_foundation(tmp_path) + + with pytest.raises( + preflight.WorkdayConnectPreflightError, + match="different environment", + ): + preflight.resolve_target( + tmp_path, + dataverse_url=None, + state=model.default_state(), + pac_resolver=lambda: Path("pac.exe"), + pac_runner=lambda command, **_kwargs: subprocess.CompletedProcess( + command, + 0, + stdout=json.dumps( + { + "EnvironmentId": "other-environment", + "OrgUrl": ENV_URL, + } + ), + stderr="", + ), + ) + + def test_resolve_target_rejects_classic_da(tmp_path: Path) -> None: import workday_connect_model as model import workday_connect_preflight as preflight From eba05d7a9da3590dc4bfd15eeae936064b63d591 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Sat, 26 Sep 2026 00:13:08 -0700 Subject: [PATCH 12/20] Carry verified tenant into Entra phase --- .../scripts/workday_connect_preflight.py | 1 + .../scripts/test_workday_connect_preflight.py | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/solutions/ess-maker-skills/scripts/workday_connect_preflight.py b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py index 14fc08c7..d77071c0 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_preflight.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py @@ -425,6 +425,7 @@ def run_preflight( "packageFlavor": target.package_flavor, "ring": target.foundation_ring, "vertical": "hr", + "entraTenantId": identity["tenantId"], }, ) state_store.merge_section( diff --git a/tests/scripts/test_workday_connect_preflight.py b/tests/scripts/test_workday_connect_preflight.py index c33303b7..618c5f5a 100644 --- a/tests/scripts/test_workday_connect_preflight.py +++ b/tests/scripts/test_workday_connect_preflight.py @@ -253,6 +253,57 @@ def query(_url, _token, entity_set, _select, _filter): assert installer_calls == [] assert result["package"]["action"] == "unchanged" assert result["status"]["nextPhaseId"] == "entra" + assert result["scope"]["entraTenantId"] == "tenant-id" + + +def test_preflight_carries_verified_tenant_into_entra_handoff( + tmp_path: Path, +) -> None: + import workday_connect_contracts as contracts + import workday_connect_preflight as preflight + import workday_connect_store as store_module + + _write_foundation(tmp_path) + store = store_module.WorkdayConnectStore(tmp_path) + preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username="maker@example.com", + store=store, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=lambda *_args, **_kwargs: { + "username": "maker@example.com", + "tenantId": "tenant-id", + }, + query=lambda *_args, **_kwargs: [ + {"uniquename": "msdyn_EssWorkdayRuntime"} + ], + ) + state = store.merge_section( + "scope", + {"workdayTenant": "contoso_impl"}, + ) + + handoff = contracts.build_entra_handoff( + state, + { + "applications": [ + { + "displayName": "Workday exact", + "appId": "44444444-4444-4444-4444-444444444444", + "objectId": "55555555-5555-5555-5555-555555555555", + "servicePrincipalId": ( + "66666666-6666-6666-6666-666666666666" + ), + "identifierUris": [ + "http://www.workday.com/contoso_impl" + ], + } + ] + }, + ) + + assert handoff["scope"]["entraTenantId"] == "tenant-id" def test_preflight_installs_and_reverifies_with_same_account( From e12a28a94e39e82cdd0a2133dd3db62241d04c8f Mon Sep 17 00:00:00 2001 From: is-goutham Date: Sat, 26 Sep 2026 01:37:45 -0700 Subject: [PATCH 13/20] Complete native Workday agent wiring --- .../scripts/agentbuilder_object_model.py | 49 ++++ .../alm/Enable-CosmosDAFlowAuthorization.ps1 | 41 ++- .../scripts/alm/get_dataverse_token.py | 6 +- .../flightcheck/checks/workday_extension.py | 132 +++++---- .../scripts/minimalbot_evaluation.py | 222 ++++++++++++++- solutions/ess-maker-skills/scripts/push.py | 205 ++++++++++++- .../scripts/workday_connect.py | 76 +++-- .../scripts/workday_connect_contracts.py | 35 ++- .../scripts/workday_connect_model.py | 10 +- .../scripts/workday_connect_runtime.py | 228 +-------------- .../actions/activate-workday-topics.md | 89 ++++++ .../actions/wire-user-context-redirect.md | 41 ++- .../src/skills/setup/workday-da/SKILL.md | 2 +- .../workday-da/configure-power-platform.md | 96 +++++-- .../checks/test_workday_extension.py | 54 +++- .../scripts/test_agentbuilder_object_model.py | 39 +++ tests/scripts/test_minimalbot_detection.py | 269 +++++++++++++++++- .../scripts/test_workday_connect_contracts.py | 7 + tests/scripts/test_workday_connect_runtime.py | 76 +---- tests/setup/test_workday_da_orchestration.py | 64 +++++ 20 files changed, 1280 insertions(+), 461 deletions(-) create mode 100644 solutions/ess-maker-skills/src/skills/connect/workday/actions/activate-workday-topics.md diff --git a/solutions/ess-maker-skills/scripts/agentbuilder_object_model.py b/solutions/ess-maker-skills/scripts/agentbuilder_object_model.py index 6e5c114c..98f1a666 100644 --- a/solutions/ess-maker-skills/scripts/agentbuilder_object_model.py +++ b/solutions/ess-maker-skills/scripts/agentbuilder_object_model.py @@ -230,3 +230,52 @@ def object_models_to_yaml( except Exception as exc: results.append(_error_result(key, exc)) return results + + +def yaml_to_object_models( + items: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Convert canonical Copilot Studio YAML to Object Model JSON elements.""" + if not items: + return [] + + types = _load_object_model() + options = types.element_serializer.CreateOptions(False) + results: list[dict[str, Any]] = [] + for item in items: + key = item.get("key") + if not isinstance(key, str) or not key.strip(): + raise ObjectModelConverterError( + "Each item key must be a non-empty string." + ) + yaml_content = item.get("yaml") + if not isinstance(yaml_content, str) or not yaml_content.strip(): + raise ObjectModelConverterError("yaml must be a non-empty string.") + + try: + element = types.yaml_serializer.Deserialize[types.bot_element]( + yaml_content + ) + if element is None: + raise ValueError( + "The Copilot Studio YAML did not contain a BotElement." + ) + serialized = types.json_serializer.Serialize[ + types.bot_element + ](element, options) + object_model = json.loads(str(serialized)) + if not isinstance(object_model, dict): + raise ValueError( + "The converted Object Model JSON was not an object." + ) + results.append( + { + "key": key, + "success": True, + "elementType": element.GetType().Name, + "objectModel": object_model, + } + ) + except Exception as exc: + results.append(_error_result(key, exc)) + return results diff --git a/solutions/ess-maker-skills/scripts/alm/Enable-CosmosDAFlowAuthorization.ps1 b/solutions/ess-maker-skills/scripts/alm/Enable-CosmosDAFlowAuthorization.ps1 index 02d4c05f..c29e0b69 100644 --- a/solutions/ess-maker-skills/scripts/alm/Enable-CosmosDAFlowAuthorization.ps1 +++ b/solutions/ess-maker-skills/scripts/alm/Enable-CosmosDAFlowAuthorization.ps1 @@ -25,6 +25,9 @@ .PARAMETER WorkflowId One or more workflow GUIDs (the 'workflowid' of each cloud flow the agent calls). +.PARAMETER PreferredUsername + Environment Maker username that must be used for Dataverse authentication. + .PARAMETER TeamName Optional display name for the access team. @@ -57,6 +60,7 @@ param( [Parameter(Mandatory = $true)][string] $OrgUrl, [Parameter(Mandatory = $true)][guid] $BotId, [Parameter(Mandatory = $true)][guid[]] $WorkflowId, + [string] $PreferredUsername, [string] $TeamName, [guid] $AdministratorId, [guid] $BusinessUnitId, @@ -100,25 +104,38 @@ function Test-DataverseToken { } function Get-DataverseToken { - param([string]$Resource) + param( + [string]$Resource, + [string]$PreferredUsername + ) $tok = $null - try { - $tok = az account get-access-token --resource $Resource --query accessToken -o tsv 2>$null - } catch { - $tok = $null - } - if (-not [string]::IsNullOrWhiteSpace($tok) -and (Test-DataverseToken -Resource $Resource -Token $tok)) { - return $tok + if ([string]::IsNullOrWhiteSpace($PreferredUsername)) { + try { + $tok = az account get-access-token --resource $Resource --query accessToken -o tsv 2>$null + } catch { + $tok = $null + } + if (-not [string]::IsNullOrWhiteSpace($tok) -and (Test-DataverseToken -Resource $Resource -Token $tok)) { + return $tok + } } - Write-Host " Azure CLI token was unavailable or rejected; using the kit's Dataverse sign-in." -ForegroundColor Yellow + if ($PreferredUsername) { + Write-Host " Using the kit's Dataverse sign-in for $PreferredUsername." -ForegroundColor Yellow + } else { + Write-Host " Azure CLI token was unavailable or rejected; using the kit's Dataverse sign-in." -ForegroundColor Yellow + } $helper = Join-Path $PSScriptRoot 'get_dataverse_token.py' $python = Get-Command python -ErrorAction SilentlyContinue if (-not $python -or -not (Test-Path $helper)) { throw "Could not acquire a valid Dataverse token. Install Python, then run the kit setup or sign in with an account that can access $Resource." } - $output = @(& $python.Source $helper --environment $Resource 2>&1) + $helperArgs = @('--environment', $Resource) + if ($PreferredUsername) { + $helperArgs += @('--preferred-username', $PreferredUsername) + } + $output = @(& $python.Source $helper @helperArgs 2>&1) if ($LASTEXITCODE -ne 0) { $safeError = ($output | Where-Object { $_ -notmatch '^ESS_DATAVERSE_TOKEN=' }) -join [Environment]::NewLine throw "Kit Dataverse authentication failed: $safeError" @@ -171,7 +188,9 @@ function Invoke-Dv { # -------------------------------------------------------------------------------------------- Write-Step "Connecting to $OrgUrl" -$script:Token = Get-DataverseToken -Resource $OrgUrl +$script:Token = Get-DataverseToken ` + -Resource $OrgUrl ` + -PreferredUsername $PreferredUsername $who = Invoke-Dv -Path 'WhoAmI' Write-Ok "Authenticated. UserId $($who.UserId), OrgId $($who.OrganizationId)" diff --git a/solutions/ess-maker-skills/scripts/alm/get_dataverse_token.py b/solutions/ess-maker-skills/scripts/alm/get_dataverse_token.py index 87729242..cf52d615 100644 --- a/solutions/ess-maker-skills/scripts/alm/get_dataverse_token.py +++ b/solutions/ess-maker-skills/scripts/alm/get_dataverse_token.py @@ -18,9 +18,13 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--environment", required=True) + parser.add_argument("--preferred-username") args = parser.parse_args() - token = auth.authenticate(args.environment.rstrip("/")) + token = auth.authenticate( + args.environment.rstrip("/"), + preferred_username=args.preferred_username, + ) print(f"ESS_DATAVERSE_TOKEN={token}") return 0 diff --git a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py index 12746ce5..d68dc426 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py @@ -27,10 +27,9 @@ documented-tier Dataverse ``connectionreferences`` read. * ``WD-REST-001`` (S5.5) — the captured ``restBaseUrl`` is present and **trimmed to** ``/api``. Pure-config check, no client. - * ``WD-REST-002`` (S5.7) — the agent's ``user-context-setup.mcs.yml`` topic - contains a ``BeginDialog`` redirect to the Workday user-context system topic - (``WorkdaySystemGetUserContextV2`` on the simplified pack). Pure local-file - check; SKIPPED on the legacy install path. + * ``WD-REST-002`` (S5.7) — the agent's mapped admin user-context topic + contains a ``BeginDialog`` redirect to the mapped Workday user-context + system topic. Pure local-file check; SKIPPED on the legacy install path. * ``WD-NET-001`` (S5.8) — the Workday REST + SOAP endpoints are allowlisted at the corporate firewall. **Always MANUAL attestation:** the kit has no reliable probe (a local reachability test proves only the dev machine's @@ -52,6 +51,7 @@ from __future__ import annotations +import json import os import re import sys @@ -102,9 +102,9 @@ # ---- Local user-context topic (WD-REST-002) ---- _AGENTS_ROOT = "workspace/agents" -_INSTALLED_AGENTS_ROOT = ".local/agents" -_USER_CONTEXT_FILE = "user-context-setup.mcs.yml" -_SCHEMA_NAME_RE = re.compile(r"(?m)^\s*schemaName:\s*['\"]?([^'\"\s]+)") +_COMPONENT_MAP_FILE = ".component-map.json" +_SETUP_TOPIC_DISPLAY = "[Admin] - User Context - Setup" +_TARGET_TOPIC_DISPLAY = "Workday [System] - 1: Set User Context V2" _CONN_AUTH_DESC = ( "Workday connection authentication type is Microsoft Entra ID Integrated" @@ -121,8 +121,8 @@ _REDIRECT_REMEDIATION = ( "Wire the user-context redirect: save a rollback checkpoint " "(scripts/checkpoint.py), then set the agent's " - "topics/user-context-setup.mcs.yml OnRedirect to a BeginDialog that calls " - "the installed Workday user-context system topic, and push " + "mapped [Admin] - User Context - Setup topic OnRedirect to a BeginDialog " + "that calls the mapped Workday user-context system topic, and push " "(scripts/push.py). See the connect skill step 3 (§3.5d)." ) @@ -142,31 +142,58 @@ def _selected_agent_slug(runner) -> str: return validate_agent_slug(str(slug)) if slug else "" -def _installed_user_context_dialogs(agent_slug: str) -> list[str]: - agent_dir = resolve_agent_directory( - Path(_INSTALLED_AGENTS_ROOT), - agent_slug, - ) - topics_dir = agent_dir / "topics" - if not topics_dir.is_dir(): - return [] +def _mapped_user_context_topics( + agent_dir: Path, +) -> tuple[Path | None, str | None, str | None]: + component_map_path = agent_dir / _COMPONENT_MAP_FILE + try: + component_map = json.loads( + component_map_path.read_text(encoding="utf-8") + ) + except (OSError, json.JSONDecodeError) as exc: + return None, None, ( + f"The selected agent '{agent_dir.name}' {_COMPONENT_MAP_FILE} " + f"could not be read: {exc}" + ) + if not isinstance(component_map, dict): + return None, None, ( + f"The selected agent's {_COMPONENT_MAP_FILE} is not a JSON object." + ) - dialogs: set[str] = set() - for topic_file in sorted(topics_dir.glob("*.mcs.yml")): - try: - text = topic_file.read_text(encoding="utf-8", errors="replace") - except OSError: - continue - match = _SCHEMA_NAME_RE.search(text) - dialog = ( - match.group(1) - if match - else topic_file.name.removesuffix(".mcs.yml") + setup_matches = [ + (relative_path, entry) + for relative_path, entry in component_map.items() + if isinstance(entry, dict) + and entry.get("componentKind") == "DialogComponent" + and str(entry.get("displayName") or "").casefold() + == _SETUP_TOPIC_DISPLAY.casefold() + ] + target_matches = [ + entry + for entry in component_map.values() + if isinstance(entry, dict) + and entry.get("componentKind") == "DialogComponent" + and str(entry.get("displayName") or "").casefold() + == _TARGET_TOPIC_DISPLAY.casefold() + ] + if len(setup_matches) != 1 or len(target_matches) != 1: + return None, None, ( + f"Expected exactly one mapped admin user-context topic and one " + f"mapped Workday User Context V2 topic for selected agent " + f"'{agent_dir.name}'." ) - normalized = re.sub(r"[^a-z0-9]", "", dialog.casefold()) - if "workday" in normalized and "usercontext" in normalized: - dialogs.add(dialog) - return sorted(dialogs) + + relative_path = Path(str(setup_matches[0][0])) + if relative_path.is_absolute() or ".." in relative_path.parts: + return None, None, ( + "The mapped admin user-context topic path is unsafe." + ) + target_schema = str(target_matches[0].get("schemaName") or "").strip() + if not target_schema: + return None, None, ( + "The mapped Workday User Context V2 topic has no schemaName." + ) + return agent_dir / relative_path, target_schema, None def _fmt(config, key: str) -> str: @@ -638,30 +665,28 @@ def _check_user_context_redirect(runner) -> list[CheckResult]: )] agent_dir = resolve_agent_directory(agents_root, agent_slug) - topic_file = agent_dir / "topics" / _USER_CONTEXT_FILE - if not topic_file.is_file(): + topic_file, target_dialog, mapping_error = _mapped_user_context_topics( + agent_dir + ) + if mapping_error: return [CheckResult(roles=_MAKER_ROLES, checkpoint_id="WD-REST-002", category=_CATEGORY, priority=Priority.HIGH.value, status=Status.FAILED.value, description=_REDIRECT_DESC, - result=( - f"No {_USER_CONTEXT_FILE} found for selected agent " - f"'{agent_slug}' under {_AGENTS_ROOT}/{agent_slug}/topics/." - ), + result=mapping_error, remediation=_REDIRECT_REMEDIATION, doc_link=_DOC_SIMPLIFIED, )] - - installed_dialogs = _installed_user_context_dialogs(agent_slug) - if not installed_dialogs: + assert topic_file is not None + assert target_dialog is not None + if not topic_file.is_file(): return [CheckResult(roles=_MAKER_ROLES, checkpoint_id="WD-REST-002", category=_CATEGORY, priority=Priority.HIGH.value, status=Status.FAILED.value, description=_REDIRECT_DESC, result=( - "No installed Workday user-context system topic was found for " - f"selected agent '{agent_slug}' under " - f"{_INSTALLED_AGENTS_ROOT}/{agent_slug}/topics/." + f"No mapped admin user-context topic found for selected " + f"agent '{agent_slug}' at {topic_file}." ), remediation=_REDIRECT_REMEDIATION, doc_link=_DOC_SIMPLIFIED, @@ -675,30 +700,23 @@ def _check_user_context_redirect(runner) -> list[CheckResult]: priority=Priority.HIGH.value, status=Status.FAILED.value, description=_REDIRECT_DESC, result=( - f"The selected agent's {_USER_CONTEXT_FILE} could not be read: " + "The selected agent's mapped user-context topic could not be " + "read: " f"{e}" ), remediation=_REDIRECT_REMEDIATION, doc_link=_DOC_SIMPLIFIED, )] - matched_dialog = next( - ( - dialog - for dialog in installed_dialogs - if "BeginDialog" in text and dialog in text - ), - None, - ) - if not matched_dialog: + if "BeginDialog" not in text or target_dialog not in text: return [CheckResult(roles=_MAKER_ROLES, checkpoint_id="WD-REST-002", category=_CATEGORY, priority=Priority.HIGH.value, status=Status.FAILED.value, description=_REDIRECT_DESC, result=( f"The selected agent '{agent_slug}' user-context topic does " - "not redirect to any installed Workday user-context system " - f"topic. Installed candidate(s): {', '.join(installed_dialogs)}." + "not redirect to its mapped Workday user-context system " + f"topic '{target_dialog}'." ), remediation=_REDIRECT_REMEDIATION, doc_link=_DOC_SIMPLIFIED, @@ -710,7 +728,7 @@ def _check_user_context_redirect(runner) -> list[CheckResult]: description=_REDIRECT_DESC, result=( "The user-context topic redirects to the Workday " - f"'{matched_dialog}' system topic for selected agent " + f"'{target_dialog}' system topic for selected agent " f"'{agent_slug}'." ), doc_link=_DOC_SIMPLIFIED, diff --git a/solutions/ess-maker-skills/scripts/minimalbot_evaluation.py b/solutions/ess-maker-skills/scripts/minimalbot_evaluation.py index 58914bf6..5edd9384 100644 --- a/solutions/ess-maker-skills/scripts/minimalbot_evaluation.py +++ b/solutions/ess-maker-skills/scripts/minimalbot_evaluation.py @@ -25,6 +25,7 @@ from __future__ import annotations import base64 +import copy from datetime import datetime, timezone import fnmatch import json @@ -219,6 +220,18 @@ def _wire_kinds(value: Any) -> Any: return value +def _without_diagnostics(value: Any) -> Any: + if isinstance(value, dict): + return { + key: _without_diagnostics(child) + for key, child in value.items() + if key != "diagnostics" + } + if isinstance(value, list): + return [_without_diagnostics(child) for child in value] + return value + + def _folder_matches_globs(folder: Path, root: Path, only_globs: list[str]) -> bool: """True if any ``*.mcs.yml`` in ``folder`` matches one of ``only_globs``. @@ -273,7 +286,9 @@ def _connection_id(connection: dict[str, Any]) -> str: def acquire_test_pp_token( - tenant_id: str, scope: str | None = None + tenant_id: str, + scope: str | None = None, + preferred_username: str | None = None, ) -> tuple[str, str]: """Acquire a Power Platform token, reusing the shared MSAL cache. @@ -295,15 +310,28 @@ def acquire_test_pp_token( CLIENT_ID, authority=authority, token_cache=cache ) accounts = app.get_accounts() - selected_account = accounts[0] if accounts else None + preferred = str(preferred_username or "").casefold() + selected_account = next( + ( + account + for account in accounts + if str(account.get("username") or "").casefold() == preferred + ), + None, + ) + if selected_account is None and not preferred: + selected_account = accounts[0] if accounts else None result = None if selected_account: result = app.acquire_token_silent([scope], account=selected_account) if not result or "access_token" not in result: print("Opening browser for Power Platform sign-in...") - result = app.acquire_token_interactive( - [scope], prompt="select_account" + interactive_options = ( + {"login_hint": preferred_username} + if preferred_username + else {"prompt": "select_account"} ) + result = app.acquire_token_interactive([scope], **interactive_options) if "access_token" not in result: # Don't echo error_description (CWE-209); mirror auth.py. error = result.get("error", "unknown_error") @@ -389,11 +417,21 @@ def from_config(cls, config: dict[str, Any]) -> "MinimalBotEvaluationClient": ) # -- auth --------------------------------------------------------------- - def authenticate(self) -> str: + def authenticate(self, preferred_username: str | None = None) -> str: """Acquire a Power Platform token for this ring, reusing the MSAL cache.""" self._token, self.signed_in_username = acquire_test_pp_token( - self.tenant_id, scope=self.scope + self.tenant_id, + scope=self.scope, + preferred_username=preferred_username, ) + if preferred_username and ( + str(self.signed_in_username or "").casefold() + != preferred_username.casefold() + ): + raise MinimalBotEvaluationError( + "Power Platform authentication used a different account from " + "the selected Environment Maker." + ) return self._token def _require_token(self) -> str: @@ -457,6 +495,178 @@ def read_components(self) -> dict[str, Any]: ) return body + def update_dialog_components( + self, + updates: list[dict[str, Any]], + ) -> dict[str, Any]: + """Update existing dialog components with drift and identity checks.""" + if not updates: + raise MinimalBotEvaluationError( + "No MinimalBot dialog updates were requested." + ) + + before = self.read_components() + change_token = str(before.get("changeToken") or "") + if not change_token: + raise MinimalBotEvaluationError( + "MinimalBot component read did not return a changeToken." + ) + remote_changes = before.get("botComponentChanges") + if not isinstance(remote_changes, list): + raise MinimalBotEvaluationError( + "MinimalBot component read returned no component changes." + ) + + changes: list[dict[str, Any]] = [] + for update in updates: + component_id = str(update.get("componentId") or "") + schema_name = str(update.get("schemaName") or "") + matches = [ + change.get("component") + for change in remote_changes + if isinstance(change, dict) + and isinstance(change.get("component"), dict) + and str(change["component"].get("id") or "").casefold() + == component_id.casefold() + ] + if len(matches) != 1: + raise MinimalBotEvaluationError( + "The selected MinimalBot dialog component is no longer " + "unique." + ) + component = matches[0] + if ( + component.get("$kind") != "DialogComponent" + or str(component.get("schemaName") or "").casefold() + != schema_name.casefold() + ): + raise MinimalBotEvaluationError( + "The selected MinimalBot dialog identity changed after " + "local extraction." + ) + has_dialog_update = "dialog" in update + desired_dialog = update.get("dialog") + expected_dialog = update.get("expectedDialog") + if has_dialog_update and ( + not isinstance(expected_dialog, dict) + or not isinstance(desired_dialog, dict) + ): + raise MinimalBotEvaluationError( + "MinimalBot dialog content updates require expected and " + "desired Object Model payloads." + ) + desired_state = update.get("state") + desired_status = update.get("status") + if ( + not has_dialog_update + and desired_state is None + and desired_status is None + ): + raise MinimalBotEvaluationError( + "MinimalBot dialog update did not request content or state " + "changes." + ) + remote_dialog = _without_diagnostics(component.get("dialog")) + dialog_is_desired = ( + not has_dialog_update + or remote_dialog == _without_diagnostics(desired_dialog) + ) + state_is_desired = ( + desired_state is None + or component.get("state") == desired_state + ) + status_is_desired = ( + desired_status is None + or component.get("status") == desired_status + ) + if dialog_is_desired and state_is_desired and status_is_desired: + continue + if ( + has_dialog_update + and not dialog_is_desired + and remote_dialog != _without_diagnostics(expected_dialog) + ): + raise MinimalBotEvaluationError( + "The selected MinimalBot dialog changed remotely after " + "the workspace baseline was captured." + ) + updated_component = copy.deepcopy(component) + if has_dialog_update: + updated_component["dialog"] = desired_dialog + if desired_state is not None: + updated_component["state"] = desired_state + if desired_status is not None: + updated_component["status"] = desired_status + changes.append( + { + "$kind": "BotComponentUpdate", + "component": updated_component, + } + ) + + if changes: + payload = { + "changeToken": change_token, + "botComponentChanges": changes, + "cloudFlowDefinitionChanges": [], + "connectionReferenceChanges": [], + "connectorDefinitionChanges": [], + "environmentVariableChanges": [], + "aIPluginOperationChanges": [], + "componentCollectionChanges": [], + "dataverseTableSearchChanges": [], + "connectedAgentDefinitionChanges": [], + } + response, _ = self._request( + "PUT", + self._components_url, + body=payload, + operation="dialog update", + ) + if response.status_code != 200: + raise MinimalBotEvaluationError( + "MinimalBot dialog update failed " + f"(HTTP {response.status_code})." + ) + + verified = self.read_components() + verified_changes = verified.get("botComponentChanges") + if not isinstance(verified_changes, list): + raise MinimalBotEvaluationError( + "MinimalBot dialog verification returned no component changes." + ) + verified_by_id = { + str(component.get("id") or "").casefold(): component + for change in verified_changes + if isinstance(change, dict) + and isinstance((component := change.get("component")), dict) + } + for update in updates: + component_id = str(update["componentId"]) + component = verified_by_id.get(component_id.casefold()) + verified_update = component is not None + if verified_update and "dialog" in update: + verified_update = _without_diagnostics( + component.get("dialog") + ) == _without_diagnostics(update["dialog"]) + if verified_update and update.get("state") is not None: + verified_update = ( + component.get("state") == update["state"] + ) + if verified_update and update.get("status") is not None: + verified_update = ( + component.get("status") == update["status"] + ) + if not verified_update: + raise MinimalBotEvaluationError( + "MinimalBot dialog verification failed for component " + f"{component_id}." + ) + return { + "updatedComponents": len(changes), + "verifiedComponents": len(updates), + } + # -- push --------------------------------------------------------------- def push_agent_evaluations( self, diff --git a/solutions/ess-maker-skills/scripts/push.py b/solutions/ess-maker-skills/scripts/push.py index 8ab0eb42..1117dc7e 100644 --- a/solutions/ess-maker-skills/scripts/push.py +++ b/solutions/ess-maker-skills/scripts/push.py @@ -51,6 +51,10 @@ metadata_description, parse_review_metadata, ) +from agentbuilder_object_model import ( + ObjectModelConverterError, + yaml_to_object_models, +) from minimalbot_evaluation import ( MinimalBotEvaluationClient, MinimalBotEvaluationError, @@ -1151,6 +1155,135 @@ def _warn_minimalbot_non_eval_changes(agent_dir): print(f" ... and {len(non_eval) - 20} more") +def _minimalbot_topic_update_plan( + agent_dir, + only_globs, + *, + activate_topics=False, +): + """Build guarded updates for scoped, existing MinimalBot topics.""" + if not only_globs: + return [] + baseline_dir = os.path.join(agent_dir, ".baseline") + if not os.path.isdir(baseline_dir): + return [] + baseline = collect_files(baseline_dir) + working = collect_files(agent_dir) + changed, new, deleted = compute_diff(baseline, working) + selected_changed = sorted( + path + for path in changed + if matches_only(path, only_globs) + and path.replace("\\", "/").startswith("topics/") + and path.endswith(".mcs.yml") + ) + selected_new_or_deleted = sorted( + path + for path in (*new, *deleted) + if matches_only(path, only_globs) + and path.replace("\\", "/").startswith("topics/") + ) + if selected_new_or_deleted: + raise MinimalBotEvaluationError( + "MinimalBot scoped topic push supports updates to existing topics " + "only; create/delete is not allowed: " + + ", ".join(selected_new_or_deleted) + ) + component_map = load_component_map(agent_dir) + selected_activation = sorted( + path + for path, entry in component_map.items() + if activate_topics + and matches_only(path, only_globs) + and path.replace("\\", "/").startswith("topics/") + and path.endswith(".mcs.yml") + and isinstance(entry, dict) + and entry.get("componentKind") == "DialogComponent" + and path in working + ) + if activate_topics and not selected_activation: + raise MinimalBotEvaluationError( + "No existing dialog topics matched the requested activation scope." + ) + selected_paths = sorted( + set(selected_changed) | set(selected_activation) + ) + if not selected_paths: + return [] + + conversion_items = [] + for path in selected_changed: + conversion_items.extend( + ( + {"key": f"{path}:baseline", "yaml": baseline[path]}, + {"key": f"{path}:working", "yaml": working[path]}, + ) + ) + try: + converted = yaml_to_object_models(conversion_items) + except ObjectModelConverterError as exc: + raise MinimalBotEvaluationError( + f"Could not convert scoped topic YAML: {exc}" + ) from exc + converted_by_key = {entry["key"]: entry for entry in converted} + + updates = [] + for path in selected_paths: + entry = component_map.get(path) + if not isinstance(entry, dict): + raise MinimalBotEvaluationError( + f"Scoped topic is missing from .component-map.json: {path}" + ) + if entry.get("componentKind") != "DialogComponent": + raise MinimalBotEvaluationError( + f"Scoped path is not a dialog component: {path}" + ) + component_id = str(entry.get("componentId") or "").strip() + schema_name = str(entry.get("schemaName") or "").strip() + if not component_id or not schema_name: + raise MinimalBotEvaluationError( + f"Scoped topic identity is incomplete in the component map: " + f"{path}" + ) + update = { + "path": path, + "componentId": component_id, + "schemaName": schema_name, + "displayName": str(entry.get("displayName") or path), + } + if path in selected_changed: + baseline_result = converted_by_key[f"{path}:baseline"] + working_result = converted_by_key[f"{path}:working"] + failed = next( + ( + result + for result in (baseline_result, working_result) + if result.get("success") is not True + ), + None, + ) + if failed: + error = failed.get("error") or {} + raise MinimalBotEvaluationError( + f"Could not convert scoped topic {path}: " + f"{error.get('message') or 'unknown conversion error'}" + ) + if ( + baseline_result.get("elementType") != "AdaptiveDialog" + or working_result.get("elementType") != "AdaptiveDialog" + ): + raise MinimalBotEvaluationError( + f"Scoped topic is not an AdaptiveDialog: {path}" + ) + update["expectedDialog"] = baseline_result["objectModel"] + update["dialog"] = working_result["objectModel"] + if path in selected_activation: + update["state"] = "Active" + update["status"] = "Active" + updates.append(update) + return updates + + def _minimalbot_push( config, *, @@ -1159,16 +1292,15 @@ def _minimalbot_push( repair_mode=False, only_globs=None, auto_yes=False, + preferred_username=None, + activate_topics=False, ): - """Push evaluation sets to a Dataverse-free MinimalBot agent. + """Push supported changes to a Dataverse-free MinimalBot agent. Uses the Power Platform MinimalBot components API on the agent's ring (see - :mod:`minimalbot_evaluation`). Only evaluation components are supported for - MinimalBot agents today; other component types (topics, workflows) still - require a Dataverse-backed environment. Destructive/scoped/repair flags are - rejected or honoured rather than silently ignored, so a ``--force-delete`` - never turns into a duplicate insert and a scoped ``--only`` never expands - into an every-set push. + :mod:`minimalbot_evaluation`). Evaluation inserts and scoped updates to + existing dialog topics are supported. Other component types still require + a different authoring path. The script-level confirmation gate (a second safety layer the ``/push`` prompt relies on) is enforced here before any component insert, mirroring @@ -1194,13 +1326,51 @@ def _minimalbot_push( ) sys.exit(1) - _warn_minimalbot_non_eval_changes(agent_dir) - print("MinimalBot agent detected (Dataverse-free).") if only_globs: print(f"(Scoped push — {len(only_globs)} filter(s) active)") client = MinimalBotEvaluationClient.from_config(config) + try: + topic_updates = _minimalbot_topic_update_plan( + agent_dir, + only_globs, + activate_topics=activate_topics, + ) + except MinimalBotEvaluationError as exc: + print(f"ERROR: {exc}") + sys.exit(1) + + if topic_updates: + print(f"\nWould update {len(topic_updates)} existing topic(s):") + for entry in topic_updates: + print(f" • {entry['displayName']} ({entry['path']})") + if dry_run: + print("\n(Dry run — no changes pushed)") + return + if not auto_yes: + response = input( + "\nPush these changes to Copilot Studio? (yes/no): " + ).strip().lower() + if response not in ("yes", "y"): + print("Push cancelled.") + return + try: + client.authenticate(preferred_username=preferred_username) + result = client.update_dialog_components(topic_updates) + except MinimalBotEvaluationError as exc: + print(f"ERROR: {exc}") + sys.exit(1) + update_baseline_scoped(agent_dir, only_globs) + if client.signed_in_username: + print(f"Signed in as: {client.signed_in_username}") + print( + f"\n✅ Updated and verified " + f"{result['verifiedComponents']} topic component(s)." + ) + return + + _warn_minimalbot_non_eval_changes(agent_dir) # Build the plan offline first (no auth, no mutation) so the change set can # be shown and confirmed BEFORE any component insert. Honouring only_globs @@ -1263,6 +1433,14 @@ def main(): if _idx + 1 < len(sys.argv) and not sys.argv[_idx + 1].startswith("-"): repair_name = sys.argv[_idx + 1] only_globs = parse_only_globs(sys.argv[1:]) + preferred_username = None + if "--preferred-username" in sys.argv: + index = sys.argv.index("--preferred-username") + if index + 1 >= len(sys.argv) or sys.argv[index + 1].startswith("-"): + print("ERROR: --preferred-username requires a value.") + sys.exit(1) + preferred_username = sys.argv[index + 1] + activate_topics = "--activate" in sys.argv config = load_config() @@ -1277,7 +1455,16 @@ def main(): repair_mode=repair_mode, only_globs=only_globs, auto_yes=auto_yes, + preferred_username=preferred_username, + activate_topics=activate_topics, + ) + + if activate_topics: + print( + "ERROR: --activate is supported only for Dataverse-free " + "(MinimalBot) agents." ) + sys.exit(1) agent_dir = config["agent"]["folder"] env_url = config["dataverseEndpoint"] diff --git a/solutions/ess-maker-skills/scripts/workday_connect.py b/solutions/ess-maker-skills/scripts/workday_connect.py index d68a41be..6566d916 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect.py +++ b/solutions/ess-maker-skills/scripts/workday_connect.py @@ -20,6 +20,7 @@ WorkdayConnectContractError, build_entra_handoff, build_workday_admin_packet, + validate_agent_binding_evidence, validate_connections_evidence, validate_employee_evidence, validate_entra_verification, @@ -103,9 +104,10 @@ def build_parser() -> argparse.ArgumentParser: runtime_apply.add_argument("--dataverse-connection-id") runtime_approve = subparsers.add_parser("runtime-approve") runtime_approve.add_argument("--plan-json", required=True) - record_connections = subparsers.add_parser("record-connections") record_connections.add_argument("--evidence-json", required=True) + record_binding = subparsers.add_parser("record-agent-binding") + record_binding.add_argument("--evidence-json", required=True) record_validation = subparsers.add_parser("record-validation") record_validation.add_argument("--evidence-json", required=True) @@ -241,7 +243,6 @@ def _runtime_apply( evidence=evidence, ), ) - store.set_phase_status("runtime", "complete") return {**result, "status": store.status()} @@ -263,35 +264,57 @@ def _record_connections( evidence = validate_connections_evidence( _json_object(args.evidence_json, "connection evidence") ) - actions = ( - ( - "physical-connections-verified", - { - "workdayConnected": evidence["workdayConnectionConnected"], - "dataverseConnected": evidence[ - "dataverseConnectionConnected" - ], - }, - ), - ( - "agent-parameter-sharing-verified", - {"checkpoint": "WD-CONN-013", "outcome": "passed"}, - ), - ( - "flow-attachment-confirmed", - {"makerConfirmed": evidence["flowAttachmentConfirmed"]}, - ), + store.complete_action( + "connections", + "physical-connections-verified", + evidence={ + "outcome": "verified", + "workdayConnected": evidence["workdayConnectionConnected"], + "dataverseConnected": evidence[ + "dataverseConnectionConnected" + ], + }, ) - for action, details in actions: - store.complete_action( - "connections", - action, - evidence={"outcome": "verified", **details}, - ) store.set_phase_status("connections", "complete") return {"verified": True, "status": store.status()} +def _record_agent_binding( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + evidence = validate_agent_binding_evidence( + _json_object(args.evidence_json, "agent binding evidence") + ) + store.complete_action( + "runtime", + "user-context-v2-configured", + evidence={ + "outcome": "verified", + "checkpoint": "WD-REST-002", + }, + ) + store.complete_action( + "runtime", + "agent-parameter-sharing-verified", + evidence={ + "outcome": "verified", + "checkpoint": "WD-CONN-013", + }, + ) + store.complete_action( + "runtime", + "flow-attachment-confirmed", + evidence={ + "outcome": "verified", + "makerConfirmed": evidence["flowAttachmentConfirmed"], + "workdayTopicsActivated": evidence["workdayTopicsActivated"], + }, + ) + store.set_phase_status("runtime", "complete") + return {"verified": True, "status": store.status()} + + def _record_validation( args: argparse.Namespace, store: WorkdayConnectStore, @@ -337,6 +360,7 @@ def _preflight( "runtime-apply": _runtime_apply, "runtime-approve": _runtime_approve, "record-connections": _record_connections, + "record-agent-binding": _record_agent_binding, "record-validation": _record_validation, "preflight": _preflight, } diff --git a/solutions/ess-maker-skills/scripts/workday_connect_contracts.py b/solutions/ess-maker-skills/scripts/workday_connect_contracts.py index 316a6b96..33229b64 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_contracts.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_contracts.py @@ -410,15 +410,48 @@ def validate_connections_evidence( required_true = ( "workdayConnectionConnected", "dataverseConnectionConnected", + ) + unexpected = sorted(set(evidence) - set(required_true)) + if unexpected: + raise WorkdayConnectContractError( + "Connection evidence contains unsupported fields: " + + ", ".join(unexpected) + ) + missing = sorted( + key for key in required_true if evidence.get(key) is not True + ) + if missing: + raise WorkdayConnectContractError( + "Connection evidence is incomplete: " + ", ".join(missing) + ) + return {key: True for key in required_true} + + +def validate_agent_binding_evidence( + evidence: Mapping[str, Any], +) -> dict[str, Any]: + if not isinstance(evidence, Mapping): + raise WorkdayConnectContractError( + "Agent binding evidence must contain a JSON object." + ) + required_true = ( + "userContextRedirectPassed", "parameterSharingPassed", "flowAttachmentConfirmed", + "workdayTopicsActivated", ) + unexpected = sorted(set(evidence) - set(required_true)) + if unexpected: + raise WorkdayConnectContractError( + "Agent binding evidence contains unsupported fields: " + + ", ".join(unexpected) + ) missing = sorted( key for key in required_true if evidence.get(key) is not True ) if missing: raise WorkdayConnectContractError( - "Connection evidence is incomplete: " + ", ".join(missing) + "Agent binding evidence is incomplete: " + ", ".join(missing) ) return {key: True for key in required_true} diff --git a/solutions/ess-maker-skills/scripts/workday_connect_model.py b/solutions/ess-maker-skills/scripts/workday_connect_model.py index abe0a1b9..715cb997 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_model.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_model.py @@ -97,19 +97,15 @@ class PhaseDefinition: Phase.WORKDAY_ADMIN.value: frozenset( {"administrator-response-validated"} ), - Phase.CONNECTIONS.value: frozenset( - { - "physical-connections-verified", - "agent-parameter-sharing-verified", - "flow-attachment-confirmed", - } - ), + Phase.CONNECTIONS.value: frozenset({"physical-connections-verified"}), Phase.RUNTIME.value: frozenset( { "connection-references-bound", "runtime-flows-active", "delegated-authorization-configured", "user-context-v2-configured", + "agent-parameter-sharing-verified", + "flow-attachment-confirmed", } ), Phase.EMPLOYEE_VALIDATION.value: frozenset({"signed-in-scenario"}), diff --git a/solutions/ess-maker-skills/scripts/workday_connect_runtime.py b/solutions/ess-maker-skills/scripts/workday_connect_runtime.py index 6d30e5ef..ab96232a 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_runtime.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_runtime.py @@ -10,9 +10,6 @@ import shutil import subprocess from typing import Any, Callable, Mapping -import uuid - -import yaml from auth import authenticate, query_all, update_record from install_workday_da_extension import ( @@ -29,10 +26,6 @@ ACTIVE_FLOW_STATE = 1 ACTIVE_FLOW_STATUS = 2 CLOUD_FLOW_CATEGORY = 5 -SETUP_TOPIC_NAME = "[Admin] - User Context - Setup" -SETUP_SCHEMA_SUFFIX = ".topic.setusercontext" -TARGET_TOPIC_NAME = "Workday [System] - 1: Set User Context V2" -TARGET_SCHEMA_SUFFIX = ".topic.workdaysystemgetusercontextv2" AUTHORIZATION_SCRIPT = "alm/Enable-CosmosDAFlowAuthorization.ps1" @@ -290,128 +283,10 @@ def _solution_component_ids( return component_ids -def _topic_document(data: str) -> dict[str, Any] | None: - if not data.strip(): - return {} - try: - document = yaml.safe_load(data) - except yaml.YAMLError: - return None - return document if isinstance(document, dict) else None - - -def _begin_dialog(document: Mapping[str, Any]) -> Mapping[str, Any]: - direct = document.get("beginDialog") - if isinstance(direct, Mapping): - return direct - dialog = document.get("dialog") - if isinstance(dialog, Mapping): - nested = dialog.get("beginDialog") - if isinstance(nested, Mapping): - return nested - return {} - - -def _redirect_state(data: str, target_schema: str) -> str: - document = _topic_document(data) - if document is None: - return "custom" - begin = _begin_dialog(document) - actions = begin.get("actions") - if isinstance(actions, list) and len(actions) == 1: - action = actions[0] - if ( - isinstance(action, Mapping) - and str(action.get("kind") or "").casefold() == "begindialog" - and str(action.get("dialog") or "").casefold() - == target_schema.casefold() - ): - return "configured" - if not document or ( - str(begin.get("kind") or "").casefold() == "onredirect" - and actions in (None, []) - ): - return "empty" - return "custom" - - -def _redirect_yaml(target_schema: str) -> str: - return ( - "kind: AdaptiveDialog\n" - "beginDialog:\n" - " kind: OnRedirect\n" - " id: main\n" - " priority: 0\n" - " actions:\n" - " - kind: BeginDialog\n" - " id: QVk2yi\n" - f" dialog: {target_schema}\n" - ) - - -def _runtime_topics( - environment_url: str, - token: str, - bot_id: str, - *, - query: Callable[..., list[dict[str, Any]]], -) -> dict[str, dict[str, Any]]: - try: - normalized_bot_id = str(uuid.UUID(bot_id)) - except ValueError as exc: - raise WorkdayConnectRuntimeError( - "The selected Workday agent has an invalid bot ID." - ) from exc - rows = query( - environment_url, - token, - "botcomponents", - "botcomponentid,name,schemaname,data,statecode,statuscode", - f"_parentbotid_value eq '{normalized_bot_id}' and componenttype eq 9", - ) - setup_matches = [ - row - for row in rows - if str(row.get("name") or "").casefold() - == SETUP_TOPIC_NAME.casefold() - or str(row.get("schemaname") or "").casefold().endswith( - SETUP_SCHEMA_SUFFIX - ) - ] - target_matches = [ - row - for row in rows - if str(row.get("name") or "").casefold() - == TARGET_TOPIC_NAME.casefold() - or str(row.get("schemaname") or "").casefold().endswith( - TARGET_SCHEMA_SUFFIX - ) - ] - if len(setup_matches) != 1 or len(target_matches) != 1: - raise WorkdayConnectRuntimeError( - "Expected exactly one Workday setup topic and one User Context V2 " - "topic in the selected agent." - ) - target_schema = _required_text( - target_matches[0], "schemaname", "User Context V2 topic schema" - ) - state = _redirect_state( - str(setup_matches[0].get("data") or ""), - target_schema, - ) - if state == "custom": - raise WorkdayConnectRuntimeError( - f"'{SETUP_TOPIC_NAME}' contains custom content. Refusing to " - "overwrite it automatically." - ) - return { - "setup": setup_matches[0], - "target": target_matches[0], - "redirectState": state, - } - - def _default_runner(command: list[str], **kwargs) -> subprocess.CompletedProcess: + kwargs.setdefault("text", True) + kwargs.setdefault("encoding", "utf-8") + kwargs.setdefault("errors", "replace") return subprocess.run(command, **kwargs) @@ -464,7 +339,6 @@ def _build_runtime_discovery( dataverse: Mapping[str, Any], references: Mapping[str, Mapping[str, Any]], flows: Mapping[str, Mapping[str, Any]], - topics: Mapping[str, Any], ) -> dict[str, Any]: references_catalog = context["referencesCatalog"] logical_names = [ @@ -508,18 +382,6 @@ def _build_runtime_discovery( }, "connectionBindings": target_connections, "flows": flow_targets, - "userContext": { - "setupTopicId": _required_text( - topics["setup"], - "botcomponentid", - "User-context setup topic ID", - ), - "targetTopicSchema": _required_text( - topics["target"], - "schemaname", - "User Context V2 schema", - ), - }, "delegatedAuthorization": { "botId": context["botId"], "workflowIds": [target["workflowId"] for target in flow_targets], @@ -529,8 +391,6 @@ def _build_runtime_discovery( "Bind the reviewed Workday and Dataverse connection references", "Activate the reviewed Workday runtime cloud flows", "Authorize the selected agent to invoke each reviewed flow", - "Redirect the empty admin user-context scaffold to Workday User " - "Context V2", "Reread and verify every changed Dataverse record", ], } @@ -552,7 +412,6 @@ def _build_runtime_discovery( } for name in context["flowNames"] }, - "userContext": topics["redirectState"], } return { "plan": {**plan, "planHash": plan_hash(plan)}, @@ -565,7 +424,6 @@ def _build_runtime_discovery( value["displayName"] for value in target_connections.values() ], "flows": [target["name"] for target in flow_targets], - "userContextTarget": TARGET_TOPIC_NAME, }, "observed": observed, } @@ -642,19 +500,12 @@ def discover_runtime_plan( solution_component_ids, query=query, ) - topics = _runtime_topics( - context["environmentUrl"], - active_token, - context["botId"], - query=query, - ) return _build_runtime_discovery( context, workday=workday, dataverse=dataverse, references=references, flows=flows, - topics=topics, ) @@ -757,6 +608,8 @@ def _run_authorization( plan["scope"]["dataverseUrl"], "-BotId", authorization["botId"], + "-PreferredUsername", + plan["scope"]["makerUsername"], "-WorkflowId", workflow_id, ], @@ -950,64 +803,6 @@ def _apply_flow_activation_stage( return flow_names -def _apply_user_context_stage( - plan: Mapping[str, Any], - *, - token: str, - query: Callable[..., list[dict[str, Any]]], - updater: Callable[..., bool], -) -> str: - environment_url = plan["scope"]["dataverseUrl"] - setup_topic_id = plan["userContext"]["setupTopicId"] - target_schema = plan["userContext"]["targetTopicSchema"] - topic_rows = query( - environment_url, - token, - "botcomponents", - "botcomponentid,data", - f"botcomponentid eq '{_odata_literal(setup_topic_id)}'", - ) - if len(topic_rows) != 1: - raise WorkdayConnectRuntimeError( - "The approved user-context setup topic is no longer unique." - ) - redirect_state = _redirect_state( - str(topic_rows[0].get("data") or ""), - target_schema, - ) - if redirect_state == "custom": - raise WorkdayConnectRuntimeError( - "The user-context setup topic changed after approval." - ) - if redirect_state == "empty": - updater( - environment_url, - token, - "botcomponents", - setup_topic_id, - {"data": _redirect_yaml(target_schema)}, - ) - verified = query( - environment_url, - token, - "botcomponents", - "botcomponentid,data", - f"botcomponentid eq '{_odata_literal(setup_topic_id)}'", - ) - if ( - len(verified) != 1 - or _redirect_state( - str(verified[0].get("data") or ""), - target_schema, - ) - != "configured" - ): - raise WorkdayConnectRuntimeError( - "User Context V2 redirect verification failed." - ) - return target_schema - - def apply_runtime_plan( plan: Mapping[str, Any], *, @@ -1055,23 +850,10 @@ def apply_runtime_plan( stage_recorder, ) - user_context = _apply_user_context_stage( - plan, - token=token, - query=query, - updater=updater, - ) - _record_runtime_stage( - verified_stages, - "user-context-v2-configured", - {"outcome": "verified", "provenance": "Dataverse reread"}, - stage_recorder, - ) return { "verified": True, "verifiedStages": verified_stages, "connectionBindings": connection_bindings, "flows": flow_names, - "userContext": user_context, "delegatedAuthorization": "verified-by-script", } diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/actions/activate-workday-topics.md b/solutions/ess-maker-skills/src/skills/connect/workday/actions/activate-workday-topics.md new file mode 100644 index 00000000..1203fd2c --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/connect/workday/actions/activate-workday-topics.md @@ -0,0 +1,89 @@ +# Action: Activate all Workday topics + +Run this action only after the reviewed Workday flows are connected to the +agent and **Allow permission to share parameters** is enabled. + +Every **Message** block is the exact text to show the user. Copy it verbatim. + +--- + +## B.1 — Resolve the complete Workday topic set + +Read `workspace/agents/{AGENT_SLUG}/.component-map.json`. This map is the +materialized workspace projection of the source `agent.yml`. + +Select every entry that satisfies all of these conditions: + +- `componentKind` is `DialogComponent`; +- `schemaName` starts with `{AGENT_SCHEMA}.topic.Workday`; +- `displayName` starts with `Workday`; +- the mapped path starts with `topics/` and ends with `.mcs.yml`; +- the mapped file exists beneath the selected agent directory. + +Reject unsafe paths, duplicate component IDs, missing schema names, or an empty +result. Do not use a handwritten filename list. For the current reviewed ESS +HR template this resolves all 21 Workday dialog topics from `agent.yml`, +including business topics and supporting system topics. + +Sort the mapped paths and build `{WORKDAY_TOPIC_ARGS}` as one exact +`--only "{path}"` argument per selected topic. + +--- + +## B.2 — Preview activation + +Run from the solution root containing `.local/config.json`: + +```powershell +python scripts/push.py {WORKDAY_TOPIC_ARGS} --activate --dry-run --preferred-username "{POWER_PLATFORM_MAKER}" +``` + +The preview count must equal the selected component-map count. Every previewed +component must be one of the resolved Workday dialog topics. Stop if the count +differs or any non-Workday topic appears. + +**Message:** + +The Workday connections and shared parameters are ready. I found +**{WORKDAY_TOPIC_COUNT}** Workday topics from the installed agent definition. +I can now enable that exact set without changing their dialog content. + +**End message.** + +Use the `vscode_askQuestions` tool: + +```json +[ + { + "header": "Enable Workday topics", + "question": "Enable all Workday topics in the active ESS HR agent?", + "options": [ + { "label": "Enable", "recommended": true }, + { "label": "Not now" } + ], + "allowFreeformInput": false + } +] +``` + +If the user selects **Not now**, set `ACTION_RESULT = "cancelled"` and leave +the runtime phase active. + +--- + +## B.3 — Activate and verify + +If the user selects **Enable**, run: + +```powershell +python scripts/push.py {WORKDAY_TOPIC_ARGS} --activate --yes --preferred-username "{POWER_PLATFORM_MAKER}" +``` + +`push.py` sends a full `BotComponentUpdate` for each selected topic through the +native MinimalBot components endpoint, preserves the dialog body, sets both +`state` and `status` to `Active`, and rereads every component. Continue only +when the command reports that all `{WORKDAY_TOPIC_COUNT}` topics were verified. + +Set `ACTION_RESULT = "applied"` and +`WORKDAY_TOPICS_ACTIVATED = true`. On any error or count mismatch, stop and +leave the runtime phase active. diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md b/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md index ff3f408c..1361aab7 100644 --- a/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md +++ b/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md @@ -30,22 +30,32 @@ available yet." --- -## A.2 — Resolve the installed system topic +## A.2 — Resolve both topics from the workspace map -Find the installed Workday "Set User Context" system topic's dialog id under -`.local/agents/{AGENT_SLUG}/topics/`. Use the actual installed topic's -`schemaName` — do not assume a fixed name, since it varies by install path -(for example, `WorkdaySystemGetUserContextV2` on the current extension pack). -This installed tree is read-only package evidence. The editable redirect -remains in `workspace/agents/{AGENT_SLUG}/topics/`. +Read `workspace/agents/{AGENT_SLUG}/.component-map.json`. + +- Find exactly one entry whose `displayName` is + `[Admin] - User Context - Setup`. Save its map key as + `{USER_CONTEXT_TOPIC_PATH}`. +- Find exactly one entry whose `displayName` is + `Workday [System] - 1: Set User Context V2`. Save its `schemaName` as + `{USER_CONTEXT_DIALOG}`. + +Both entries must be `DialogComponent` records and both mapped files must +exist. Stop on missing or duplicate matches. Do not assume either filename: +current native agents commonly use `topics/Setusercontext.mcs.yml`, while +older materialized workspaces may use `topics/user-context-setup.mcs.yml`. --- ## A.3 — Edit the redirect -Set the agent's -`workspace/agents/{AGENT_SLUG}/topics/user-context-setup.mcs.yml` -`OnRedirect` to a `BeginDialog` calling that dialog id: +Read `workspace/agents/{AGENT_SLUG}/{USER_CONTEXT_TOPIC_PATH}`. Continue only +when it is either the empty `OnRedirect` scaffold or already contains the +exact redirect below. Refuse to overwrite any other actions or custom +content. + +Set its `OnRedirect` to a `BeginDialog` calling the resolved dialog id: ```yaml kind: AdaptiveDialog @@ -70,11 +80,14 @@ before continuing. Preview and push: ``` -python scripts/push.py --only "topics/user-context-setup.mcs.yml" --dry-run +python scripts/push.py --only "{USER_CONTEXT_TOPIC_PATH}" --dry-run --preferred-username "{POWER_PLATFORM_MAKER}" ``` -Review the preview. The preview must contain only the `user-context-setup` topic. If any other -file appears, stop and report it instead of publishing unrelated work. +Run this command from the solution root containing `.local/config.json`. +Review the preview. It must contain only the setup topic. This action updates +the redirect but deliberately does not activate Workday topics; activation +runs only after flow connection and parameter sharing are complete. If any +other file appears, stop and report it instead of publishing unrelated work. Use the `vscode_askQuestions` tool: @@ -97,7 +110,7 @@ the lifecycle runner without pushing, and leave the phase `in-progress`. If the user selects **Publish**, run: ``` -python scripts/push.py --only "topics/user-context-setup.mcs.yml" --yes +python scripts/push.py --only "{USER_CONTEXT_TOPIC_PATH}" --yes --preferred-username "{POWER_PLATFORM_MAKER}" ``` The explicit question above is the approval for this concrete scoped change; diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md index 9f796474..07d5391e 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md @@ -36,7 +36,7 @@ Describe each action according to who actually performs it: | Microsoft Entra | Discover exact applications, validate roles, generate one administrator handoff, reread Graph, and record verified evidence | Create or change the Entra application in the portal | | Workday administrator | Generate the handoff, validate returned non-secret values, derive endpoints, and record evidence | Change SAML, OAuth, API-client, certificate, or authentication-policy settings in Workday | | Connections | Discover connected physical connections, verify agent parameter sharing, and record the maker's flow-attachment confirmation | Create connector connections, complete connector OAuth, connect flows to the agent, and enable parameter sharing in Copilot Studio | -| Runtime | After approval, bind reviewed solution connection references, activate reviewed package flows, configure delegated authorization, redirect an empty User Context scaffold, and reread every write | Resolve custom topic content or a package without a reviewed runtime catalog | +| Runtime | After approval, bind reviewed solution connection references, activate reviewed package flows, configure delegated authorization, redirect an empty User Context scaffold, and activate the complete mapped Workday topic set after connection sharing | Resolve custom topic content or a package without a reviewed runtime catalog | | Employee validation | Record safe validation evidence and retain the current blocker | Publish the agent, sign in as an employee, and run the real employee scenario | Never say "I changed," "I configured," "I enabled," or "I updated" for a diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md index 811025c1..b94546f8 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md @@ -3,13 +3,9 @@ ## Connections -The skill does not create physical connector connections, complete connector -OAuth, connect flows to the agent, or enable parameter sharing. The maker -performs those actions; the skill discovers and verifies the result. - -The current helpers do not independently read the Copilot Studio -flow-to-agent attachment. Record the maker's confirmation of that attachment -as manual handoff evidence; do not describe it as automatically verified. +The skill does not create physical connector connections or complete connector +OAuth. The maker performs those actions; the skill discovers and verifies the +result. Ask the maker to create or confirm exactly two connected Power Platform connections in the selected environment: @@ -21,12 +17,6 @@ connections in the selected environment: Explain that Workday connector OAuth is another credential store and may open its own sign-in. Do not ask the maker to paste connection IDs. -In Copilot Studio, connect the reviewed Workday flows to the selected ESS HR -agent. For every agent connection used by those flows, enable **Allow -permission to share parameters**. This is what prevents each employee from -receiving an unexpected first-use connection prompt. It is distinct from -binding the package's solution connection references. - Run runtime discovery: ```powershell @@ -45,21 +35,15 @@ flows, selected agent, and User Context V2 topics. maker to paste or repeat an ID. - If none exists or a connection is not connected, leave the phase waiting and show the exact missing connector. -- Run the existing FlightCheck and require `WD-CONN-013` to pass. If it does - not, show only its safe display-name remediation, have the maker enable - parameter sharing in Copilot Studio, and rerun that check. - -After successful discovery, a passing `WD-CONN-013`, and the maker's -confirmation that the reviewed flows are connected to the selected agent, run: +After successful discovery of both physical connections, run: ```powershell python scripts/workday_connect.py record-connections --evidence-json '{...}' ``` -Set all four required booleans only from observed or confirmed evidence: -Workday connected, Dataverse connected, parameter sharing passed, and flow -attachment confirmed. The controller records the distinct automated and -manual evidence and completes the phase atomically. +Set the Workday and Dataverse connected booleans only from observed evidence. +This completes the physical-connections phase so the controller can activate +the reviewed flows before Copilot Studio attaches them. ## Runtime approval and apply @@ -73,8 +57,6 @@ workflow, or bot identifiers. The combined runtime plan will: - bind the two reviewed connection references; - activate only the checked-in Workday flow catalog; - authorize the exact agent to invoke those exact workflow IDs; -- redirect only an empty admin user-context scaffold to Workday User Context - V2; and - reread every changed record. If the setup topic contains custom content, stop and preserve it. Do not @@ -98,10 +80,70 @@ one Dataverse token for Python mutations, invokes the checked-in delegated authorization script, and verifies bindings, flow state, authorization, and User Context V2 after each ordered stage. It records each verified stage immediately, so a later failure resumes from durable evidence rather than -hiding earlier successful changes. Report permission issues only from an -explicit forbidden response, `[FAIL]` marker, ambiguity result, or nonzero +hiding earlier successful changes. The runtime phase remains active until the +maker completes the agent binding below. Report permission issues only from +an explicit forbidden response, `[FAIL]` marker, ambiguity result, or nonzero script exit. +## Agent binding after flow activation + +Only after runtime apply has activated the reviewed flows, wire the native +agent's local `[Admin] - User Context - Setup` topic to +`Workday [System] - 1: Set User Context V2` using the existing guarded +checkpoint, scoped dry-run, approval, and push pattern in +`src/skills/connect/workday/actions/wire-user-context-redirect.md`. Pass the +recorded Power Platform maker as `--preferred-username` so the native push +cannot silently reuse another cached account. This scoped push changes only +the setup redirect; Workday topics remain inactive until connection sharing is +complete. Run `WD-REST-002` after the scoped push and require it to pass. + +If a Workday system topic shows `CloudFlow ... not found`, repair the missing +agent-level flow registration in Copilot Studio. Open the broken **Call an +action** node, select the exact existing flow, preserve its current +input/output mappings, and save the topic: + +- **Workday System Get User Context V2** -> **ESS Workday Runtime** +- **Workday System Get REST Execution** -> + **ESS Workday Runtime REST Execution** +- **Workday System Get CommonExecution** -> + **ESS Workday Runtime References** and **ESS Workday Runtime** + +Do not recreate or clone the flows. Their IDs in the reviewed topics already +match the installed Dataverse flows; reselecting them registers the missing +native flow contract. + +Then open the agent connection settings. Connect **ESS Workday Runtime REST +Execution** and **ESS Workday Runtime**. **ESS Workday Runtime References** is +`EmbeddedOnly`, so it is not expected in the user-facing connection list. For +every agent connection used by the two visible flows, enable **Allow permission +to share parameters**. This prevents each employee from receiving an +unexpected first-use connection prompt. + +Run the existing FlightCheck and require `WD-CONN-013` to pass. Then run +`src/skills/connect/workday/actions/activate-workday-topics.md`. That action +derives the complete Workday dialog list from the selected agent's +`.component-map.json`, previews the exact scope, and uses the native components +endpoint to set both `state` and `status` to `Active` for every Workday topic. +Do not activate only the two User Context setup topics. + +The current +helpers do not independently read the Copilot Studio flow-to-agent attachment, +so retain the maker's explicit confirmation after the broken action nodes are +resolved and do not describe it as automatically verified. + +Then run: + +```powershell +python scripts/workday_connect.py record-agent-binding --evidence-json '{...}' +``` + +Set `userContextRedirectPassed` only from `WD-REST-002`, +`parameterSharingPassed` only from the FlightCheck result, and +`flowAttachmentConfirmed` only after the maker has saved the repaired action +nodes without a missing-flow diagnostic. Set `workdayTopicsActivated` only +when `activate-workday-topics.md` reports that every selected Workday topic was +reread as Active. This completes the runtime phase. + If runtime discovery reports that the selected package has no reviewed flow catalog, record a manual handoff. Do not claim that connection references, flows, authorization, or topics were changed. diff --git a/tests/flightcheck/checks/test_workday_extension.py b/tests/flightcheck/checks/test_workday_extension.py index dbb97f85..38cc4b7b 100644 --- a/tests/flightcheck/checks/test_workday_extension.py +++ b/tests/flightcheck/checks/test_workday_extension.py @@ -26,6 +26,7 @@ from __future__ import annotations from dataclasses import dataclass, field +import json from typing import Any import responses @@ -443,19 +444,49 @@ def test_absent_url_not_configured(self): # ───────────────────────────────────────────────────────────────────── +def _write_component_map( + tmp_path, + agent: str, + *, + setup_file: str = "Setusercontext.mcs.yml", + dialog: str | None = None, +): + agent_dir = tmp_path / "workspace" / "agents" / agent + agent_dir.mkdir(parents=True, exist_ok=True) + component_map_path = agent_dir / ".component-map.json" + component_map = ( + json.loads(component_map_path.read_text(encoding="utf-8")) + if component_map_path.exists() + else {} + ) + component_map.update({ + f"topics/{setup_file}": { + "componentKind": "DialogComponent", + "displayName": "[Admin] - User Context - Setup", + "schemaName": "contoso.topic.Setusercontext", + } + }) + if dialog: + component_map["topics/WorkdaySystemGetUserContextV2.mcs.yml"] = { + "componentKind": "DialogComponent", + "displayName": "Workday [System] - 1: Set User Context V2", + "schemaName": dialog, + } + component_map_path.write_text( + json.dumps(component_map), + encoding="utf-8", + ) + + def _write_topic(tmp_path, agent: str, body: str): topics = tmp_path / "workspace" / "agents" / agent / "topics" topics.mkdir(parents=True, exist_ok=True) - (topics / "user-context-setup.mcs.yml").write_text(body, encoding="utf-8") + (topics / "Setusercontext.mcs.yml").write_text(body, encoding="utf-8") + _write_component_map(tmp_path, agent) def _write_installed_topic(tmp_path, agent: str, dialog: str): - topics = tmp_path / ".local" / "agents" / agent / "topics" - topics.mkdir(parents=True, exist_ok=True) - (topics / "workday-user-context.mcs.yml").write_text( - f"schemaName: {dialog}\nkind: AdaptiveDialog\n", - encoding="utf-8", - ) + _write_component_map(tmp_path, agent, dialog=dialog) class TestUserContextRedirect: @@ -481,13 +512,18 @@ def test_agents_dir_but_no_topic_file_fails(self, tmp_path, monkeypatch): (tmp_path / "workspace" / "agents" / "acme" / "topics").mkdir( parents=True ) + _write_component_map( + tmp_path, + "acme", + dialog="cr123_WorkdaySystemGetUserContextV3", + ) runner = _Runner(config={}, agent_slug="acme") r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-002"] assert r.status == Status.FAILED.value - assert "No user-context-setup.mcs.yml found" in r.result + assert "No mapped admin user-context topic found" in r.result assert "selected agent 'acme'" in r.result - assert "installed Workday user-context" in r.remediation + assert "mapped Workday user-context" in r.remediation def test_topic_missing_redirect_fails(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) diff --git a/tests/scripts/test_agentbuilder_object_model.py b/tests/scripts/test_agentbuilder_object_model.py index ed9bbd74..dd64ac17 100644 --- a/tests/scripts/test_agentbuilder_object_model.py +++ b/tests/scripts/test_agentbuilder_object_model.py @@ -201,11 +201,27 @@ def CreateOptions(_indent: bool) -> object: class _FakeYamlSerializer: + class _Deserialize: + def __getitem__(self, _element_type: object) -> object: + return lambda _payload: _FakeDialog() + + Deserialize = _Deserialize() + @staticmethod def Serialize(_element: object) -> str: return "kind: AdaptiveDialog" +class _FakeSerialize: + def __getitem__(self, _element_type: object) -> object: + return lambda _element, _options: '{"$kind":"AdaptiveDialog"}' + + +class _FakeBidirectionalJsonSerializer: + Deserialize = _FakeDeserialize() + Serialize = _FakeSerialize() + + def test_validate_object_model_runtime_loads_dependencies( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -242,3 +258,26 @@ def test_object_models_to_yaml_preserves_result_contract( "yaml": "kind: AdaptiveDialog", } ] + + +def test_yaml_to_object_models_preserves_result_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + types = converter._ObjectModelTypes( + bot_element=object, + element_serializer=_FakeElementSerializer, + json_serializer=_FakeBidirectionalJsonSerializer, + yaml_serializer=_FakeYamlSerializer, + ) + monkeypatch.setattr(converter, "_load_object_model", lambda: types) + + assert converter.yaml_to_object_models( + [{"key": "topic", "yaml": "kind: AdaptiveDialog"}] + ) == [ + { + "key": "topic", + "success": True, + "elementType": "FakeDialog", + "objectModel": {"$kind": "AdaptiveDialog"}, + } + ] diff --git a/tests/scripts/test_minimalbot_detection.py b/tests/scripts/test_minimalbot_detection.py index 274c75eb..61b7224f 100644 --- a/tests/scripts/test_minimalbot_detection.py +++ b/tests/scripts/test_minimalbot_detection.py @@ -15,6 +15,8 @@ from __future__ import annotations +import json +from types import SimpleNamespace from typing import Any import pytest @@ -391,9 +393,11 @@ def __init__(self): self.signed_in_username = "tester@example.com" self.authenticated = False self.real_push = False + self.topic_updates = [] - def authenticate(self): + def authenticate(self, preferred_username=None): self.authenticated = True + self.preferred_username = preferred_username def push_agent_evaluations(self, agent_dir, *, dry_run=False, only_globs=None): if dry_run: @@ -406,6 +410,13 @@ def push_agent_evaluations(self, agent_dir, *, dry_run=False, only_globs=None): "testSetId": "real-id", "cases": "1"}], "componentCount": 2, "verifiedComponents": 2} + def update_dialog_components(self, updates): + self.topic_updates = updates + return { + "updatedComponents": len(updates), + "verifiedComponents": len(updates), + } + def _patch_client(monkeypatch, fake): monkeypatch.setattr( @@ -466,3 +477,259 @@ def _no_input(*a, **k): # pragma: no cover - must never be reached assert "Dry run — no changes pushed" in out assert fake.authenticated is False assert fake.real_push is False + + +def _write_existing_topic_change(root): + baseline = root / ".baseline" / "topics" + working = root / "topics" + baseline.mkdir(parents=True) + working.mkdir(parents=True) + baseline_body = "kind: AdaptiveDialog\nbeginDialog:\n kind: OnRedirect\n" + working_body = ( + f"{baseline_body}" + " actions:\n" + " - kind: BeginDialog\n" + " dialog: contoso.topic.WorkdaySystemGetUserContextV2\n" + ) + baseline.joinpath("Setusercontext.mcs.yml").write_text( + baseline_body, + encoding="utf-8", + ) + working.joinpath("Setusercontext.mcs.yml").write_text( + working_body, + encoding="utf-8", + ) + root.joinpath(".component-map.json").write_text( + json.dumps( + { + "topics/Setusercontext.mcs.yml": { + "componentKind": "DialogComponent", + "componentId": "setup-topic", + "schemaName": "contoso.topic.Setusercontext", + "displayName": "[Admin] - User Context - Setup", + } + } + ), + encoding="utf-8", + ) + + +def _fake_topic_conversion(items): + return [ + { + "key": item["key"], + "success": True, + "elementType": "AdaptiveDialog", + "objectModel": { + "$kind": "AdaptiveDialog", + "source": item["yaml"], + }, + } + for item in items + ] + + +def test_scoped_minimalbot_topic_dry_run_is_non_mutating( + tmp_path, monkeypatch, capsys +): + _write_existing_topic_change(tmp_path) + fake = _RecordingClient() + _patch_client(monkeypatch, fake) + monkeypatch.setattr(push, "yaml_to_object_models", _fake_topic_conversion) + + push._minimalbot_push( + _minimalbot_config(str(tmp_path)), + dry_run=True, + only_globs=["topics/Setusercontext.mcs.yml"], + ) + + out = capsys.readouterr().out + assert "Would update 1 existing topic" in out + assert fake.authenticated is False + assert fake.topic_updates == [] + + +def test_scoped_minimalbot_topic_push_pins_account_and_updates_baseline( + tmp_path, monkeypatch +): + _write_existing_topic_change(tmp_path) + fake = _RecordingClient() + _patch_client(monkeypatch, fake) + monkeypatch.setattr(push, "yaml_to_object_models", _fake_topic_conversion) + + push._minimalbot_push( + _minimalbot_config(str(tmp_path)), + auto_yes=True, + only_globs=["topics/Setusercontext.mcs.yml"], + preferred_username="maker@contoso.com", + ) + + assert fake.authenticated is True + assert fake.preferred_username == "maker@contoso.com" + assert fake.topic_updates[0]["componentId"] == "setup-topic" + assert ( + tmp_path / ".baseline" / "topics" / "Setusercontext.mcs.yml" + ).read_text(encoding="utf-8") == ( + tmp_path / "topics" / "Setusercontext.mcs.yml" + ).read_text(encoding="utf-8") + + +def test_scoped_minimalbot_topic_activation_does_not_require_content_diff( + tmp_path, +): + _write_existing_topic_change(tmp_path) + topic = tmp_path / "topics" / "Setusercontext.mcs.yml" + baseline = tmp_path / ".baseline" / "topics" / "Setusercontext.mcs.yml" + baseline.write_text(topic.read_text(encoding="utf-8"), encoding="utf-8") + + plan = push._minimalbot_topic_update_plan( + str(tmp_path), + ["topics/Setusercontext.mcs.yml"], + activate_topics=True, + ) + + assert len(plan) == 1 + assert plan[0]["state"] == "Active" + assert plan[0]["status"] == "Active" + assert "dialog" not in plan[0] + + +def test_minimalbot_dialog_update_uses_update_envelope_and_verifies( + monkeypatch, +): + client = _mb_client() + client._token = "token" + before_component = { + "$kind": "DialogComponent", + "id": "setup-topic", + "schemaName": "contoso.topic.Setusercontext", + "dialog": {"$kind": "AdaptiveDialog", "state": "before"}, + } + desired_dialog = {"$kind": "AdaptiveDialog", "state": "after"} + after_component = { + **before_component, + "dialog": { + **desired_dialog, + "diagnostics": [{"$kind": "Informational"}], + }, + } + reads = iter( + ( + { + "changeToken": "token-1", + "botComponentChanges": [ + { + "$kind": "BotComponentInsert", + "component": before_component, + } + ], + }, + { + "changeToken": "token-2", + "botComponentChanges": [ + { + "$kind": "BotComponentInsert", + "component": after_component, + } + ], + }, + ) + ) + monkeypatch.setattr(client, "read_components", lambda: next(reads)) + captured = {} + + def request(method, url, *, body, operation): + captured.update( + method=method, + url=url, + body=body, + operation=operation, + ) + return SimpleNamespace(status_code=200), {} + + monkeypatch.setattr(client, "_request", request) + + result = client.update_dialog_components( + [ + { + "componentId": "setup-topic", + "schemaName": "contoso.topic.Setusercontext", + "expectedDialog": before_component["dialog"], + "dialog": desired_dialog, + } + ] + ) + + assert result["verifiedComponents"] == 1 + assert captured["method"] == "PUT" + assert captured["body"]["changeToken"] == "token-1" + assert captured["body"]["botComponentChanges"][0]["$kind"] == ( + "BotComponentUpdate" + ) + + +def test_minimalbot_dialog_activation_preserves_content(monkeypatch): + client = _mb_client() + client._token = "token" + dialog = {"$kind": "AdaptiveDialog"} + before_component = { + "$kind": "DialogComponent", + "id": "setup-topic", + "schemaName": "contoso.topic.Setusercontext", + "state": "Inactive", + "status": "Inactive", + "dialog": dialog, + } + after_component = { + **before_component, + "state": "Active", + "status": "Active", + } + reads = iter( + ( + { + "changeToken": "token-1", + "botComponentChanges": [ + { + "$kind": "BotComponentInsert", + "component": before_component, + } + ], + }, + { + "changeToken": "token-2", + "botComponentChanges": [ + { + "$kind": "BotComponentInsert", + "component": after_component, + } + ], + }, + ) + ) + monkeypatch.setattr(client, "read_components", lambda: next(reads)) + captured = {} + + def request(_method, _url, *, body, operation): + captured["body"] = body + captured["operation"] = operation + return SimpleNamespace(status_code=200), {} + + monkeypatch.setattr(client, "_request", request) + + result = client.update_dialog_components( + [ + { + "componentId": "setup-topic", + "schemaName": "contoso.topic.Setusercontext", + "state": "Active", + "status": "Active", + } + ] + ) + + updated = captured["body"]["botComponentChanges"][0]["component"] + assert result["verifiedComponents"] == 1 + assert updated["dialog"] == dialog + assert updated["state"] == "Active" + assert updated["status"] == "Active" diff --git a/tests/scripts/test_workday_connect_contracts.py b/tests/scripts/test_workday_connect_contracts.py index 5bae1958..0fafd38e 100644 --- a/tests/scripts/test_workday_connect_contracts.py +++ b/tests/scripts/test_workday_connect_contracts.py @@ -19,6 +19,7 @@ WorkdayConnectContractError, build_entra_handoff, build_workday_admin_packet, + validate_agent_binding_evidence, validate_connections_evidence, validate_employee_evidence, validate_entra_verification, @@ -206,8 +207,14 @@ def test_connections_and_employee_evidence_are_strict(): { "workdayConnectionConnected": True, "dataverseConnectionConnected": True, + } + )["workdayConnectionConnected"] is True + assert validate_agent_binding_evidence( + { + "userContextRedirectPassed": True, "parameterSharingPassed": True, "flowAttachmentConfirmed": True, + "workdayTopicsActivated": True, } )["parameterSharingPassed"] is True diff --git a/tests/scripts/test_workday_connect_runtime.py b/tests/scripts/test_workday_connect_runtime.py index e5e2baed..4d1575ab 100644 --- a/tests/scripts/test_workday_connect_runtime.py +++ b/tests/scripts/test_workday_connect_runtime.py @@ -139,27 +139,6 @@ def _records(): "uniquename": "msdyn_EssWorkdayRuntime", }, "flows": flows, - "setup": { - "botcomponentid": "setup-topic", - "name": runtime.SETUP_TOPIC_NAME, - "schemaname": "contoso.topic.setusercontext", - "data": ( - "kind: AdaptiveDialog\n" - "beginDialog:\n" - " kind: OnRedirect\n" - " actions: []\n" - ), - "statecode": 0, - "statuscode": 1, - }, - "target": { - "botcomponentid": "target-topic", - "name": runtime.TARGET_TOPIC_NAME, - "schemaname": "contoso.topic.workdaysystemgetusercontextv2", - "data": "", - "statecode": 0, - "statuscode": 1, - }, } @@ -176,10 +155,6 @@ def query(_url, _token, entity_set, _select, filter_expr=None): ] if entity_set == "workflows": return list(records["flows"].values()) - if entity_set == "botcomponents": - if filter_expr and "componenttype eq 9" in filter_expr: - return [records["setup"], records["target"]] - return [records["setup"]] raise AssertionError(entity_set) return query @@ -226,9 +201,6 @@ def test_runtime_plan_uses_one_dataverse_token_and_exact_targets(): "Workday", "Dataverse", ] - assert result["approvalSummary"]["userContextTarget"] == ( - runtime.TARGET_TOPIC_NAME - ) serialized_summary = __import__("json").dumps( result["approvalSummary"], sort_keys=True, @@ -236,36 +208,10 @@ def test_runtime_plan_uses_one_dataverse_token_and_exact_targets(): assert WORKDAY_CONNECTION not in serialized_summary assert DATAVERSE_CONNECTION not in serialized_summary assert len(result["plan"]["flows"]) == 3 - assert result["plan"]["userContext"]["targetTopicSchema"].endswith( - "workdaysystemgetusercontextv2" - ) + assert "userContext" not in result["plan"] assert "token" not in json.dumps(result).casefold() -def test_runtime_plan_stops_on_custom_user_context(): - records = _records() - records["setup"]["data"] = ( - "kind: AdaptiveDialog\n" - "beginDialog:\n" - " kind: OnRedirect\n" - " actions:\n" - " - kind: SendActivity\n" - " activity: custom\n" - ) - - with pytest.raises( - runtime.WorkdayConnectRuntimeError, - match="custom content", - ): - runtime.run_runtime_operation( - _state(), - apply=False, - token_provider=lambda *_args, **_kwargs: "token", - identity_provider=_identity, - **_discovery_dependencies(records), - ) - - def test_runtime_apply_verifies_all_mutations(monkeypatch): records = _records() verified_hashes = [] @@ -282,14 +228,14 @@ def updater(_url, _token, entity_set, record_id, data): if value["workflowid"] == record_id: value.update(data) return True - if entity_set == "botcomponents" and record_id == "setup-topic": - records["setup"].update(data) - return True raise AssertionError((entity_set, record_id, data)) monkeypatch.setattr(runtime.shutil, "which", lambda _name: "pwsh.exe") def authorization_runner(command, **_kwargs): + assert command[command.index("-PreferredUsername") + 1] == ( + "maker@contoso.com" + ) assert command[-2] == "-WorkflowId" return SimpleNamespace( returncode=0, @@ -323,7 +269,6 @@ def authorization_runner(command, **_kwargs): ("connection-references-bound", "verified"), ("runtime-flows-active", "verified"), ("delegated-authorization-configured", "verified"), - ("user-context-v2-configured", "verified"), ] assert all( value["connectionid"] @@ -333,10 +278,6 @@ def authorization_runner(command, **_kwargs): value["statecode"] == 1 and value["statuscode"] == 2 for value in records["flows"].values() ) - assert runtime._redirect_state( - records["setup"]["data"], - records["target"]["schemaname"], - ) == "configured" def test_runtime_records_verified_stages_before_later_failure(monkeypatch): @@ -396,10 +337,6 @@ def updater(_url, _token, entity_set, record_id, data): value["statecode"] == 1 and value["statuscode"] == 2 for value in records["flows"].values() ) - assert runtime._redirect_state( - records["setup"]["data"], - records["target"]["schemaname"], - ) == "empty" def test_runtime_requires_completed_connection_phase(): @@ -572,7 +509,10 @@ def mismatch(_token, *, preferred_username): def test_runtime_authorization_timeout_is_structured(monkeypatch): plan = { - "scope": {"dataverseUrl": "https://contoso.crm.dynamics.com"}, + "scope": { + "dataverseUrl": "https://contoso.crm.dynamics.com", + "makerUsername": "maker@contoso.com", + }, "delegatedAuthorization": { "script": "alm/Enable-CosmosDAFlowAuthorization.ps1", "botId": BOT_ID, diff --git a/tests/setup/test_workday_da_orchestration.py b/tests/setup/test_workday_da_orchestration.py index 6465d4f4..2f1f7d8f 100644 --- a/tests/setup/test_workday_da_orchestration.py +++ b/tests/setup/test_workday_da_orchestration.py @@ -4,6 +4,7 @@ """Contracts for the simplified Workday DA orchestration.""" import argparse +import json from pathlib import Path import pytest @@ -56,9 +57,72 @@ def test_connections_are_proven_before_runtime_apply() -> None: assert "runtime-plan" in text assert "record-connections" in text assert "runtime-apply" in text + assert "record-agent-binding" in text + assert text.index("runtime-apply") < text.index("record-agent-binding") assert "one shared Dataverse session" in normalized assert "delegated" in text assert "User Context V2" in text + assert "activate-workday-topics.md" in text + assert text.index("Allow permission") < text.index( + "activate-workday-topics.md" + ) + + +def test_workday_topic_activation_uses_complete_mapped_scope() -> None: + action = ( + _REPO_ROOT + / "solutions" + / "ess-maker-skills" + / "src" + / "skills" + / "connect" + / "workday" + / "actions" + / "activate-workday-topics.md" + ).read_text(encoding="utf-8") + redirect = ( + _REPO_ROOT + / "solutions" + / "ess-maker-skills" + / "src" + / "skills" + / "connect" + / "workday" + / "actions" + / "wire-user-context-redirect.md" + ).read_text(encoding="utf-8") + + assert ".component-map.json" in action + assert "{AGENT_SCHEMA}.topic.Workday" in action + assert "all 21 Workday dialog topics" in action + assert "--activate --dry-run" in action + assert "--activate --yes" in action + assert "state` and `status` to `Active`" in action + assert "--activate" not in redirect + + agent_dir = ( + _REPO_ROOT + / "solutions" + / "ess-maker-skills" + / "workspace" + / "agents" + / "employee-self-service-hr" + ) + component_map = json.loads( + (agent_dir / ".component-map.json").read_text(encoding="utf-8") + ) + workday_topics = [ + path + for path, entry in component_map.items() + if isinstance(entry, dict) + and entry.get("componentKind") == "DialogComponent" + and str(entry.get("schemaName") or "").split(".")[-1].startswith( + "Workday" + ) + and str(entry.get("displayName") or "").startswith("Workday") + ] + assert len(workday_topics) == 21 + assert all((agent_dir / path).is_file() for path in workday_topics) def test_readiness_requires_real_employee_runtime_evidence() -> None: From 77e2c23a4dc4a73f0ac4c577e6fd02df4f1ed4bb Mon Sep 17 00:00:00 2001 From: is-goutham Date: Sat, 26 Sep 2026 20:59:20 -0700 Subject: [PATCH 14/20] Harden Workday connection lifecycle --- .../scripts/flightcheck/cli.py | 33 +- .../scripts/minimalbot_evaluation.py | 201 ++++++- solutions/ess-maker-skills/scripts/push.py | 117 +++- .../scripts/workday_connect.py | 188 +++++- .../scripts/workday_connect_agent.py | 344 +++++++++++ .../scripts/workday_connect_contracts.py | 557 ++++++++++++++---- .../scripts/workday_connect_model.py | 296 ++++++++-- .../scripts/workday_connect_preflight.py | 84 ++- .../scripts/workday_connect_runtime.py | 166 ++++-- .../scripts/workday_connect_store.py | 349 +++++++++-- .../shared/lifecycle-contract-schema.md | 7 +- .../skills/connect/shared/lifecycle-runner.md | 23 +- .../src/skills/connect/step1.md | 14 +- .../actions/activate-workday-topics.md | 32 +- .../actions/wire-user-context-redirect.md | 23 +- .../src/skills/connect/workday/contract.json | 2 +- .../src/skills/setup/workday-da/SKILL.md | 107 +++- .../workday-da/configure-power-platform.md | 169 ++++-- .../setup/workday-da/configure-tenant.md | 148 ++++- .../setup/workday-da/provision-entra-app.md | 122 +++- .../setup/workday-da/shared/config-schema.md | 28 +- .../flightcheck/test_cli_single_checkpoint.py | 7 +- tests/scripts/test_flow_authorization.py | 4 +- tests/scripts/test_minimalbot_detection.py | 241 +++++++- tests/scripts/test_workday_connect_agent.py | 440 ++++++++++++++ .../scripts/test_workday_connect_contracts.py | 528 +++++++++++++++-- tests/scripts/test_workday_connect_model.py | 57 +- .../scripts/test_workday_connect_preflight.py | 104 +++- tests/scripts/test_workday_connect_runtime.py | 97 +-- tests/scripts/test_workday_connect_store.py | 417 ++++++++++++- tests/setup/test_connect_lifecycle.py | 46 +- tests/setup/test_workday_da_foundation.py | 17 +- tests/setup/test_workday_da_orchestration.py | 195 ++++-- 33 files changed, 4518 insertions(+), 645 deletions(-) create mode 100644 solutions/ess-maker-skills/scripts/workday_connect_agent.py create mode 100644 tests/scripts/test_workday_connect_agent.py diff --git a/solutions/ess-maker-skills/scripts/flightcheck/cli.py b/solutions/ess-maker-skills/scripts/flightcheck/cli.py index 3a2377f7..c88d3266 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/cli.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/cli.py @@ -810,7 +810,7 @@ def _merge_connect_config(config: dict, connect_config_path: str | None) -> dict if not isinstance(overlay, dict): raise ValueError(f"{connect_config_path} must contain a JSON object") - if overlay.get("schemaVersion") == 2: + if overlay.get("schemaVersion") in {2, 3, 4, 5}: scope = overlay.get("scope") or {} identifiers = overlay.get("identifiers") or {} endpoints = overlay.get("endpoints") or {} @@ -1069,7 +1069,14 @@ def _run_single_checkpoint(args): if not quiet_auth: print("Authenticating to Dataverse...") try: - dv_token = authenticate(env_url) + dv_token = authenticate( + env_url, + preferred_username=getattr( + args, + "preferred_username", + None, + ), + ) if not quiet_auth: print(" Dataverse: OK") except Exception as e: @@ -1549,6 +1556,14 @@ def main(): "foundation config." ), ) + parser.add_argument( + "--preferred-username", + default=None, + help=( + "Require Dataverse authentication to use this exact account for " + "a single checkpoint." + ), + ) parser.add_argument( "--agent-slug", default=None, @@ -1775,7 +1790,14 @@ def main(): from auth import authenticate, discover_tenant print("Authenticating to Dataverse (runtime-reachability probe)...") - dv_token = authenticate(env_url) + dv_token = authenticate( + env_url, + preferred_username=getattr( + args, + "preferred_username", + None, + ), + ) tenant_id = discover_tenant(env_url) print("Authenticating to Power Platform Admin API...") @@ -1859,7 +1881,10 @@ def main(): from auth import authenticate, discover_tenant print("Authenticating to Dataverse...") - dv_token = authenticate(env_url) + dv_token = authenticate( + env_url, + preferred_username=getattr(args, "preferred_username", None), + ) tenant_id = discover_tenant(env_url) print(f"Tenant: {tenant_id}") diff --git a/solutions/ess-maker-skills/scripts/minimalbot_evaluation.py b/solutions/ess-maker-skills/scripts/minimalbot_evaluation.py index 5edd9384..25ec565b 100644 --- a/solutions/ess-maker-skills/scripts/minimalbot_evaluation.py +++ b/solutions/ess-maker-skills/scripts/minimalbot_evaluation.py @@ -30,7 +30,7 @@ import fnmatch import json import os -from pathlib import Path +from pathlib import Path, PurePosixPath import re from typing import Any import uuid @@ -71,6 +71,9 @@ MCS_CONNECTOR = "shared_microsoftcopilotstudio" _TOKEN_CACHE_PATH = os.path.join(".local", ".token_cache.bin") _EVAL_KINDS = {"EvaluationSet", "EvaluationData"} +_EXPECTED_WORKDAY_TOPIC_COUNTS = { + "gptagent_copilotforemployeeselfservicehr": 21, +} # Shared session with bounded retry-with-backoff, mirroring auth.py / # powerplatform_client.py. Unlike those read-only clients this path also issues @@ -232,6 +235,142 @@ def _without_diagnostics(value: Any) -> Any: return value +def blocking_diagnostics( + value: Any, + *, + path: str = "$", +) -> list[dict[str, str]]: + """Return only error diagnostics from a component Object Model tree.""" + findings: list[dict[str, str]] = [] + if isinstance(value, dict): + diagnostics = value.get("diagnostics") + if isinstance(diagnostics, list): + for index, diagnostic in enumerate(diagnostics): + if not isinstance(diagnostic, dict): + continue + kind = str(diagnostic.get("$kind") or "") + code = str(diagnostic.get("errorCode") or "") + if not code and not kind.casefold().endswith("error"): + continue + findings.append( + { + "path": f"{path}.diagnostics[{index}]", + "kind": kind, + "errorCode": code, + "message": str( + diagnostic.get("errorMessage") or "" + ), + "referenceType": str( + diagnostic.get("referenceType") or "" + ), + "referenceId": str( + diagnostic.get("referenceId") or "" + ), + } + ) + for key, child in value.items(): + if key != "diagnostics": + findings.extend( + blocking_diagnostics(child, path=f"{path}.{key}") + ) + elif isinstance(value, list): + for index, child in enumerate(value): + findings.extend( + blocking_diagnostics(child, path=f"{path}[{index}]") + ) + return findings + + +def resolve_workday_dialogs( + agent_folder: str | Path, + agent_schema: str, +) -> list[dict[str, str]]: + """Resolve the complete mapped Workday dialog set for one agent.""" + root = Path(agent_folder).resolve() + schema = str(agent_schema or "").strip() + if not schema: + raise MinimalBotEvaluationError( + "The active agent schema name is required for Workday activation." + ) + map_path = root / ".component-map.json" + try: + component_map = json.loads(map_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise MinimalBotEvaluationError( + f"Could not read the active agent component map: {map_path}: {exc}" + ) from exc + if not isinstance(component_map, dict): + raise MinimalBotEvaluationError( + "The active agent component map must contain a JSON object." + ) + + schema_prefix = f"{schema}.topic.Workday".casefold() + entries: list[dict[str, str]] = [] + component_ids: set[str] = set() + schema_names: set[str] = set() + for raw_path, raw_entry in component_map.items(): + if not isinstance(raw_path, str) or not isinstance(raw_entry, dict): + continue + component_schema = str(raw_entry.get("schemaName") or "").strip() + display_name = str(raw_entry.get("displayName") or "").strip() + if ( + raw_entry.get("componentKind") != "DialogComponent" + or not component_schema.casefold().startswith(schema_prefix) + or not display_name.startswith("Workday") + ): + continue + relative_path = raw_path.replace("\\", "/") + safe_path = PurePosixPath(relative_path) + if ( + safe_path.is_absolute() + or ".." in safe_path.parts + or not safe_path.parts + or safe_path.parts[0] != "topics" + or not relative_path.endswith(".mcs.yml") + ): + raise MinimalBotEvaluationError( + f"Unsafe Workday topic path in component map: {raw_path}" + ) + local_path = root.joinpath(*safe_path.parts) + if not local_path.is_file(): + raise MinimalBotEvaluationError( + f"Mapped Workday topic is missing: {relative_path}" + ) + component_id = str(raw_entry.get("componentId") or "").strip() + if not component_id or not component_schema: + raise MinimalBotEvaluationError( + f"Mapped Workday topic has incomplete identity: {relative_path}" + ) + normalized_id = component_id.casefold() + normalized_schema = component_schema.casefold() + if normalized_id in component_ids or normalized_schema in schema_names: + raise MinimalBotEvaluationError( + "The Workday topic map contains duplicate component identity." + ) + component_ids.add(normalized_id) + schema_names.add(normalized_schema) + entries.append( + { + "path": relative_path, + "componentId": component_id, + "schemaName": component_schema, + "displayName": display_name, + } + ) + if not entries: + raise MinimalBotEvaluationError( + "No mapped Workday dialog topics were found for the active agent." + ) + expected_count = _EXPECTED_WORKDAY_TOPIC_COUNTS.get(schema.casefold()) + if expected_count is not None and len(entries) != expected_count: + raise MinimalBotEvaluationError( + "The active ESS HR component map contains " + f"{len(entries)} Workday topics; expected {expected_count}. " + "Refresh the workspace before activation." + ) + return sorted(entries, key=lambda entry: entry["path"]) + + def _folder_matches_globs(folder: Path, root: Path, only_globs: list[str]) -> bool: """True if any ``*.mcs.yml`` in ``folder`` matches one of ``only_globs``. @@ -630,6 +769,34 @@ def update_dialog_components( ) verified = self.read_components() + verification = self._verify_dialog_components_payload( + updates, + verified, + ) + return { + "updatedComponents": len(changes), + **verification, + } + + def verify_dialog_components( + self, + expectations: list[dict[str, Any]], + ) -> dict[str, Any]: + """Reread and verify existing dialog identity, state, and diagnostics.""" + if not expectations: + raise MinimalBotEvaluationError( + "No MinimalBot dialog verification was requested." + ) + return self._verify_dialog_components_payload( + expectations, + self.read_components(), + ) + + @staticmethod + def _verify_dialog_components_payload( + updates: list[dict[str, Any]], + verified: dict[str, Any], + ) -> dict[str, Any]: verified_changes = verified.get("botComponentChanges") if not isinstance(verified_changes, list): raise MinimalBotEvaluationError( @@ -641,6 +808,7 @@ def update_dialog_components( if isinstance(change, dict) and isinstance((component := change.get("component")), dict) } + all_diagnostics: list[dict[str, str]] = [] for update in updates: component_id = str(update["componentId"]) component = verified_by_id.get(component_id.casefold()) @@ -657,14 +825,43 @@ def update_dialog_components( verified_update = ( component.get("status") == update["status"] ) + diagnostics = ( + blocking_diagnostics(component) + if component is not None + else [] + ) + for diagnostic in diagnostics: + all_diagnostics.append( + { + **diagnostic, + "componentId": component_id, + "schemaName": str(update.get("schemaName") or ""), + } + ) + if ( + diagnostics + and update.get("requireCleanDiagnostics") is True + ): + first = diagnostics[0] + detail = first["errorCode"] or first["kind"] or "error" + raise MinimalBotEvaluationError( + "MinimalBot dialog has blocking diagnostics after " + f"verification ({update.get('schemaName')}): {detail}." + ) if not verified_update: raise MinimalBotEvaluationError( "MinimalBot dialog verification failed for component " f"{component_id}." ) return { - "updatedComponents": len(changes), "verifiedComponents": len(updates), + "activeComponents": sum( + 1 + for update in updates + if update.get("state") == "Active" + and update.get("status") == "Active" + ), + "blockingDiagnostics": all_diagnostics, } # -- push --------------------------------------------------------------- diff --git a/solutions/ess-maker-skills/scripts/push.py b/solutions/ess-maker-skills/scripts/push.py index 1117dc7e..5fd4b549 100644 --- a/solutions/ess-maker-skills/scripts/push.py +++ b/solutions/ess-maker-skills/scripts/push.py @@ -59,6 +59,7 @@ MinimalBotEvaluationClient, MinimalBotEvaluationError, is_minimalbot, + resolve_workday_dialogs, ) EXCLUDE_DIRS = {".baseline", ".checkpoints"} @@ -1123,6 +1124,34 @@ def update_baseline_scoped(agent_dir, only_globs): pass +def update_baseline_paths(agent_dir, relative_paths): + """Refresh exact successfully pushed files in the local baseline.""" + import shutil + + baseline_dir = os.path.join(agent_dir, ".baseline") + working = collect_files(agent_dir) + selected = { + str(path).replace("\\", "/") + for path in relative_paths + if isinstance(path, str) and path + } + for rel in list(selected): + if rel.startswith("template-configs/") and rel.endswith(".xml"): + meta = rel[:-4] + ".meta.json" + if meta in working: + selected.add(meta) + + for rel in selected: + if rel not in working: + raise OSError( + f"Successfully pushed baseline path is missing locally: {rel}" + ) + src = os.path.join(agent_dir, *rel.split("/")) + dst = os.path.join(baseline_dir, *rel.split("/")) + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.copy2(src, dst) + + def _warn_minimalbot_non_eval_changes(agent_dir): """Report pending non-evaluation changes the MinimalBot push cannot deploy. @@ -1160,6 +1189,7 @@ def _minimalbot_topic_update_plan( only_globs, *, activate_topics=False, + agent_schema=None, ): """Build guarded updates for scoped, existing MinimalBot topics.""" if not only_globs: @@ -1189,22 +1219,63 @@ def _minimalbot_topic_update_plan( "only; create/delete is not allowed: " + ", ".join(selected_new_or_deleted) ) - component_map = load_component_map(agent_dir) - selected_activation = sorted( - path - for path, entry in component_map.items() - if activate_topics - and matches_only(path, only_globs) - and path.replace("\\", "/").startswith("topics/") - and path.endswith(".mcs.yml") - and isinstance(entry, dict) - and entry.get("componentKind") == "DialogComponent" - and path in working - ) - if activate_topics and not selected_activation: - raise MinimalBotEvaluationError( - "No existing dialog topics matched the requested activation scope." + raw_component_map = load_component_map(agent_dir) + component_map = {} + for raw_path, entry in raw_component_map.items(): + normalized_path = str(raw_path).replace("\\", "/") + if normalized_path in component_map: + raise MinimalBotEvaluationError( + "The component map contains duplicate normalized paths." + ) + component_map[normalized_path] = entry + + selected_activation = [] + if activate_topics: + workday_entries = resolve_workday_dialogs( + agent_dir, + agent_schema or "", + ) + workday_by_path = { + entry["path"]: entry + for entry in workday_entries + } + selected_activation = sorted( + path + for path in workday_by_path + if matches_only(path, only_globs) ) + missing = sorted(set(workday_by_path) - set(selected_activation)) + selected_dialogs = { + str(path).replace("\\", "/") + for path, entry in component_map.items() + if isinstance(path, str) + and isinstance(entry, dict) + and entry.get("componentKind") == "DialogComponent" + and matches_only(path, only_globs) + } + non_workday = sorted(selected_dialogs - set(workday_by_path)) + if missing or non_workday: + details = [] + if missing: + details.append( + f"{len(missing)} mapped Workday topic(s) were omitted" + ) + if non_workday: + details.append( + "the activation scope also matched non-Workday topics: " + + ", ".join(non_workday) + ) + raise MinimalBotEvaluationError( + "Workday activation must target exactly the complete mapped " + "Workday topic set; " + + "; ".join(details) + + "." + ) + selected_changed = [ + path + for path in selected_changed + if path in workday_by_path + ] selected_paths = sorted( set(selected_changed) | set(selected_activation) ) @@ -1336,6 +1407,7 @@ def _minimalbot_push( agent_dir, only_globs, activate_topics=activate_topics, + agent_schema=(config.get("agent") or {}).get("schemaName"), ) except MinimalBotEvaluationError as exc: print(f"ERROR: {exc}") @@ -1361,13 +1433,26 @@ def _minimalbot_push( except MinimalBotEvaluationError as exc: print(f"ERROR: {exc}") sys.exit(1) - update_baseline_scoped(agent_dir, only_globs) + pushed_content_paths = [ + entry["path"] + for entry in topic_updates + if "dialog" in entry + ] + if pushed_content_paths: + update_baseline_paths(agent_dir, pushed_content_paths) if client.signed_in_username: print(f"Signed in as: {client.signed_in_username}") print( f"\n✅ Updated and verified " f"{result['verifiedComponents']} topic component(s)." ) + diagnostics = result.get("blockingDiagnostics") or [] + if diagnostics: + print( + "WARNING: The topics are enabled, but " + f"{len(diagnostics)} dependency diagnostic(s) remain. " + "Continue with Workday connection verification." + ) return _warn_minimalbot_non_eval_changes(agent_dir) diff --git a/solutions/ess-maker-skills/scripts/workday_connect.py b/solutions/ess-maker-skills/scripts/workday_connect.py index 6566d916..7a4ffd6a 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect.py +++ b/solutions/ess-maker-skills/scripts/workday_connect.py @@ -11,6 +11,11 @@ import sys from typing import Any, Callable +from workday_connect_agent import ( + WorkdayConnectAgentError, + verify_agent_binding, + verify_topic_activation, +) from workday_connect_model import ( CONTROLLER_CONTRACT_VERSION, WorkdayConnectModelError, @@ -21,7 +26,6 @@ build_entra_handoff, build_workday_admin_packet, validate_agent_binding_evidence, - validate_connections_evidence, validate_employee_evidence, validate_entra_verification, validate_workday_admin_response, @@ -33,6 +37,7 @@ from workday_connect_runtime import ( WorkdayConnectRuntimeError, run_runtime_operation, + verify_physical_connections, ) from workday_connect_store import ( WorkdayConnectPlanChangedError, @@ -49,9 +54,7 @@ def _json_object(value: str, label: str) -> dict[str, Any]: try: document = json.loads(value) except json.JSONDecodeError as exc: - raise WorkdayConnectStoreError( - f"{label} must be valid JSON: {exc}" - ) from exc + raise WorkdayConnectStoreError(f"{label} must be valid JSON: {exc}") from exc if not isinstance(document, dict): raise WorkdayConnectStoreError(f"{label} must be a JSON object.") return document @@ -105,9 +108,15 @@ def build_parser() -> argparse.ArgumentParser: runtime_approve = subparsers.add_parser("runtime-approve") runtime_approve.add_argument("--plan-json", required=True) record_connections = subparsers.add_parser("record-connections") - record_connections.add_argument("--evidence-json", required=True) + record_connections.add_argument("--workday-connection-id") + record_connections.add_argument("--dataverse-connection-id") + record_connections.add_argument( + "--evidence-json", + help=argparse.SUPPRESS, + ) + subparsers.add_parser("record-topic-activation") record_binding = subparsers.add_parser("record-agent-binding") - record_binding.add_argument("--evidence-json", required=True) + record_binding.add_argument("--evidence-json", help=argparse.SUPPRESS) record_validation = subparsers.add_parser("record-validation") record_validation.add_argument("--evidence-json", required=True) @@ -164,9 +173,8 @@ def _record_entra( "exact-application-discovered", evidence={ "outcome": "verified", - "applicationDisplayName": result["evidence"][ - "applicationDisplayName" - ], + "tenantId": result["evidence"]["tenantId"], + "applicationDisplayName": result["evidence"]["applicationDisplayName"], }, ) store.complete_action( @@ -178,7 +186,12 @@ def _record_entra( }, ) store.set_phase_status("entra", "complete") - return {"verified": True, "status": store.status()} + _, reused = store.restore_workday_foundation() + return { + "verified": True, + "tenantFoundationReused": reused, + "status": store.status(), + } def _workday_admin_packet( @@ -207,7 +220,11 @@ def _record_workday_admin( evidence={"outcome": "verified", **result["evidence"]}, ) store.set_phase_status("workday-admin", "complete") - return {"verified": True, "status": store.status()} + store.capture_tenant_foundation() + return { + "verified": True, + "status": store.status(), + } def _runtime_plan( @@ -261,18 +278,25 @@ def _record_connections( args: argparse.Namespace, store: WorkdayConnectStore, ) -> dict[str, Any]: - evidence = validate_connections_evidence( - _json_object(args.evidence_json, "connection evidence") + if getattr(args, "evidence_json", None) is not None: + raise WorkdayConnectStoreError( + "Manual connection evidence is no longer accepted. Run " + "record-connections without --evidence-json so the controller " + "can verify the live connections." + ) + evidence = verify_physical_connections( + store.load(), + workday_connection_id=args.workday_connection_id, + dataverse_connection_id=args.dataverse_connection_id, ) store.complete_action( "connections", "physical-connections-verified", evidence={ "outcome": "verified", - "workdayConnected": evidence["workdayConnectionConnected"], - "dataverseConnected": evidence[ - "dataverseConnectionConnected" - ], + "source": "live-power-platform-discovery", + "makerUsername": evidence["makerUsername"], + "connections": evidence["connections"], }, ) store.set_phase_status("connections", "complete") @@ -283,8 +307,16 @@ def _record_agent_binding( args: argparse.Namespace, store: WorkdayConnectStore, ) -> dict[str, Any]: + if getattr(args, "evidence_json", None) is not None: + raise WorkdayConnectStoreError( + "Manual agent-binding evidence is no longer accepted. Run " + "record-agent-binding without --evidence-json so the controller " + "can verify the live agent." + ) + state = store.load() evidence = validate_agent_binding_evidence( - _json_object(args.evidence_json, "agent binding evidence") + state, + verify_agent_binding(store.workspace_root, state), ) store.complete_action( "runtime", @@ -292,6 +324,7 @@ def _record_agent_binding( evidence={ "outcome": "verified", "checkpoint": "WD-REST-002", + "result": evidence["checkpoints"]["WD-REST-002"], }, ) store.complete_action( @@ -300,6 +333,7 @@ def _record_agent_binding( evidence={ "outcome": "verified", "checkpoint": "WD-CONN-013", + "result": evidence["checkpoints"]["WD-CONN-013"], }, ) store.complete_action( @@ -307,14 +341,92 @@ def _record_agent_binding( "flow-attachment-confirmed", evidence={ "outcome": "verified", - "makerConfirmed": evidence["flowAttachmentConfirmed"], - "workdayTopicsActivated": evidence["workdayTopicsActivated"], + "environmentId": evidence["environmentId"], + "botId": evidence["botId"], + "makerUsername": evidence["makerUsername"], + "blockingDiagnostics": evidence["workdayTopics"]["blockingDiagnostics"], + }, + ) + store.complete_action( + "runtime", + "workday-topics-activated", + evidence={ + "outcome": "verified", + "expected": evidence["workdayTopics"]["expected"], + "verified": evidence["workdayTopics"]["verified"], + "active": evidence["workdayTopics"]["active"], }, ) store.set_phase_status("runtime", "complete") return {"verified": True, "status": store.status()} +def _record_topic_activation( + _args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + evidence = verify_topic_activation( + store.workspace_root, + store.load(), + ) + topics = evidence["workdayTopics"] + store.complete_action( + "runtime", + "workday-topics-activated", + evidence={ + "outcome": "verified", + "environmentId": evidence["environmentId"], + "botId": evidence["botId"], + "makerUsername": evidence["makerUsername"], + "expected": topics["expected"], + "verified": topics["verified"], + "active": topics["active"], + "blockingDiagnostics": topics["blockingDiagnostics"], + }, + ) + diagnostics = topics["blockingDiagnostics"] + if diagnostics: + missing_flows = [ + diagnostic + for diagnostic in diagnostics + if diagnostic.get("referenceType") == "CloudFlow" + and diagnostic.get("errorCode") == "NotFound" + ] + if missing_flows: + error_type = "NativeFlowRegistrationBlocked" + message = ( + "The installed Workday agent is missing " + f"{len(missing_flows)} required flow registration(s). All " + "Workday topics are enabled, but final connection verification " + "cannot complete until those registrations are available. " + f"{len(diagnostics)} related diagnostic(s) were reported." + ) + else: + error_type = "NativeTopicDiagnosticBlocked" + message = ( + "All Workday topics are enabled, but " + f"{len(diagnostics)} topic configuration error(s) remain. " + "Resolve those errors before final connection verification." + ) + store.set_phase_status( + "runtime", + "blocked", + blocker={ + "operation": "record-agent-binding", + "errorType": error_type, + "message": message, + }, + ) + else: + store.set_phase_status("runtime", "active") + return { + "verified": True, + "flowHealth": "blocked" if diagnostics else "ready", + "blockingDiagnostics": diagnostics, + "status": store.status(), + } + + def _record_validation( args: argparse.Namespace, store: WorkdayConnectStore, @@ -360,11 +472,28 @@ def _preflight( "runtime-apply": _runtime_apply, "runtime-approve": _runtime_approve, "record-connections": _record_connections, + "record-topic-activation": _record_topic_activation, "record-agent-binding": _record_agent_binding, "record-validation": _record_validation, "preflight": _preflight, } +_COMMAND_PHASES = { + "preflight": "preflight", + "set-workday-tenant": "entra", + "entra-handoff": "entra", + "record-entra": "entra", + "workday-admin-packet": "workday-admin", + "record-workday-admin": "workday-admin", + "record-connections": "connections", + "runtime-plan": "runtime", + "runtime-approve": "runtime", + "runtime-apply": "runtime", + "record-topic-activation": "runtime", + "record-agent-binding": "runtime", + "record-validation": "employee-validation", +} + def main() -> None: parser = build_parser() @@ -378,12 +507,31 @@ def main() -> None: except ( OSError, WorkdayConnectModelError, + WorkdayConnectAgentError, WorkdayConnectContractError, WorkdayConnectPlanChangedError, WorkdayConnectPreflightError, WorkdayConnectRuntimeError, WorkdayConnectStoreError, ) as exc: + phase_id = _COMMAND_PHASES.get(args.command) + if phase_id: + try: + store.set_phase_status( + phase_id, + "blocked", + blocker={ + "operation": args.command, + "errorType": type(exc).__name__, + "message": str(exc), + }, + ) + except ( + OSError, + WorkdayConnectModelError, + WorkdayConnectStoreError, + ): + pass print( ERROR_MARKER + json.dumps( diff --git a/solutions/ess-maker-skills/scripts/workday_connect_agent.py b/solutions/ess-maker-skills/scripts/workday_connect_agent.py new file mode 100644 index 00000000..baa87fd9 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_connect_agent.py @@ -0,0 +1,344 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Live native-agent verification for Workday lifecycle completion.""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys +import tempfile +from typing import Any, Callable, Mapping + +from minimalbot_evaluation import ( + MinimalBotEvaluationClient, + MinimalBotEvaluationError, + resolve_workday_dialogs, +) + + +class WorkdayConnectAgentError(RuntimeError): + """Raised when native Workday agent readiness cannot be proven.""" + + +def _read_json(path: Path) -> dict[str, Any]: + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise WorkdayConnectAgentError( + f"Required workspace state could not be read: {path}: {exc}" + ) from exc + if not isinstance(document, dict): + raise WorkdayConnectAgentError( + f"Required workspace state must contain an object: {path}" + ) + return document + + +def _required_text( + values: Mapping[str, Any], + key: str, + label: str, +) -> str: + value = str(values.get(key) or "").strip() + if not value: + raise WorkdayConnectAgentError(f"{label} is missing.") + return value + + +def _active_agent(config: Mapping[str, Any]) -> dict[str, Any]: + active_slug = str(config.get("activeAgent") or "").strip() + agents = config.get("agents") + if isinstance(agents, list) and active_slug: + matches = [ + dict(agent) + for agent in agents + if isinstance(agent, Mapping) + and str(agent.get("slug") or "") == active_slug + ] + if len(matches) == 1: + return matches[0] + agent = config.get("agent") + if isinstance(agent, Mapping) and str(agent.get("slug") or "") == active_slug: + return dict(agent) + raise WorkdayConnectAgentError( + "The active agent could not be resolved from foundation setup state." + ) + + +def _agent_folder( + workspace_root: Path, + agent: Mapping[str, Any], +) -> Path: + configured = str(agent.get("folder") or "").strip() + if configured: + candidate = Path(configured) + if not candidate.is_absolute(): + candidate = workspace_root / candidate + else: + candidate = ( + workspace_root + / "workspace" + / "agents" + / _required_text(agent, "slug", "Active agent slug") + ) + candidate = candidate.resolve() + agents_root = (workspace_root / "workspace" / "agents").resolve() + try: + candidate.relative_to(agents_root) + except ValueError as exc: + raise WorkdayConnectAgentError( + "The active agent folder is outside workspace/agents." + ) from exc + if not candidate.is_dir(): + raise WorkdayConnectAgentError( + f"The active agent workspace folder does not exist: {candidate}" + ) + return candidate + + +def run_flightcheck_checkpoint( + workspace_root: Path, + state: Mapping[str, Any], + checkpoint_id: str, + *, + preferred_username: str, + runner: Callable[..., subprocess.CompletedProcess] = subprocess.run, +) -> str: + """Run one checkpoint and require an explicit Passed result row.""" + scope = state.get("scope") or {} + agent = scope.get("agent") or {} + command = [ + sys.executable, + str(workspace_root / "scripts" / "flightcheck" / "cli.py"), + "--checkpoint", + checkpoint_id, + "--connect-config", + str(workspace_root / ".local" / "connect" / "workday-da" / "config.json"), + "--agent-slug", + _required_text(agent, "slug", "Active agent slug"), + "--environment-id", + _required_text(scope, "environmentId", "Environment ID"), + "--environment-url", + _required_text(scope, "dataverseUrl", "Dataverse URL"), + "--preferred-username", + preferred_username, + "--quiet-auth", + "--no-open", + "--no-telemetry", + ] + with tempfile.TemporaryDirectory(prefix="workday-checkpoint-") as output: + command.extend(["--output", output]) + try: + completed = runner( + command, + cwd=workspace_root, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=300, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise WorkdayConnectAgentError( + f"FlightCheck {checkpoint_id} did not finish within five minutes." + ) from exc + results_path = Path(output) / "results.json" + try: + report = json.loads(results_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + detail = (completed.stderr or completed.stdout or "").strip()[:500] + raise WorkdayConnectAgentError( + f"FlightCheck {checkpoint_id} produced no readable result" + + (f": {detail}" if detail else ".") + ) from exc + results = report.get("results") + if not isinstance(results, list): + raise WorkdayConnectAgentError( + f"FlightCheck {checkpoint_id} returned an invalid result document." + ) + matching = [ + result + for result in results + if isinstance(result, dict) and result.get("checkpoint_id") == checkpoint_id + ] + statuses = {str(result.get("status") or "") for result in matching} + if completed.returncode != 0 or not matching or statuses != {"Passed"}: + details = "; ".join( + str(result.get("result") or result.get("status") or "") + for result in matching + ) + raise WorkdayConnectAgentError( + f"FlightCheck {checkpoint_id} did not pass" + + (f": {details}" if details else ".") + ) + return "Passed" + + +def verify_agent_binding( + workspace_root: Path, + state: Mapping[str, Any], + *, + checkpoint_verifier: Callable[..., str] = run_flightcheck_checkpoint, + client_factory: Callable[ + [dict[str, Any]], MinimalBotEvaluationClient + ] = MinimalBotEvaluationClient.from_config, +) -> dict[str, Any]: + """Verify final Workday agent binding from live checkpoints and topics.""" + context = _agent_verification_context(workspace_root, state) + checkpoints = { + checkpoint_id: checkpoint_verifier( + workspace_root, + state, + checkpoint_id, + preferred_username=context["makerUsername"], + ) + for checkpoint_id in ("WD-REST-002", "WD-CONN-013") + } + evidence = _verify_workday_topics( + context, + client_factory=client_factory, + require_clean_diagnostics=True, + ) + evidence["checkpoints"] = checkpoints + return evidence + + +def _agent_verification_context( + workspace_root: Path, + state: Mapping[str, Any], +) -> dict[str, Any]: + foundation = _read_json(workspace_root / ".local" / "config.json") + scope = state.get("scope") or {} + recorded_agent = scope.get("agent") or {} + active_agent = _active_agent(foundation) + environment_id = _required_text( + scope, + "environmentId", + "Recorded environment ID", + ) + foundation_environment = _required_text( + foundation, + "environmentId", + "Foundation environment ID", + ) + if foundation_environment.casefold() != environment_id.casefold(): + raise WorkdayConnectAgentError( + "Foundation setup and Workday state target different environments." + ) + recorded_slug = _required_text( + recorded_agent, + "slug", + "Recorded agent slug", + ) + if ( + _required_text(active_agent, "slug", "Active agent slug").casefold() + != recorded_slug.casefold() + ): + raise WorkdayConnectAgentError( + "Foundation setup and Workday state target different agents." + ) + bot_id = _required_text(recorded_agent, "botId", "Recorded agent bot ID") + if ( + _required_text(active_agent, "botId", "Active agent bot ID").casefold() + != bot_id.casefold() + ): + raise WorkdayConnectAgentError( + "Foundation setup and Workday state target different agents." + ) + agent_schema = _required_text( + active_agent, + "schemaName", + "Active agent schema name", + ) + if ( + _required_text( + recorded_agent, + "schemaName", + "Recorded agent schema name", + ).casefold() + != agent_schema.casefold() + ): + raise WorkdayConnectAgentError( + "Foundation setup and Workday state contain different agent schemas." + ) + maker = _required_text( + (state.get("operators") or {}).get("powerPlatformMaker") or {}, + "username", + "Power Platform maker account", + ) + return { + "foundation": foundation, + "environmentId": environment_id, + "botId": bot_id, + "makerUsername": maker, + "agentSchema": agent_schema, + "agentFolder": _agent_folder(workspace_root, active_agent), + } + + +def _verify_workday_topics( + context: Mapping[str, Any], + *, + client_factory: Callable[[dict[str, Any]], MinimalBotEvaluationClient], + require_clean_diagnostics: bool, +) -> dict[str, Any]: + topics = resolve_workday_dialogs( + context["agentFolder"], + str(context["agentSchema"]), + ) + expectations = [ + { + **topic, + "state": "Active", + "status": "Active", + "requireCleanDiagnostics": require_clean_diagnostics, + } + for topic in topics + ] + try: + client = client_factory(dict(context["foundation"])) + client.authenticate( + preferred_username=str(context["makerUsername"]), + ) + verification = client.verify_dialog_components(expectations) + except MinimalBotEvaluationError as exc: + customer_safe = ( + str(exc) + .replace("MinimalBot dialog", "Copilot Studio topic") + .replace("MinimalBot", "Copilot Studio") + .replace("component map", "topic inventory") + ) + raise WorkdayConnectAgentError(customer_safe) from exc + + return { + "environmentId": context["environmentId"], + "botId": context["botId"], + "makerUsername": str(client.signed_in_username or context["makerUsername"]), + "checkpoints": {}, + "workdayTopics": { + "expected": len(topics), + "verified": verification["verifiedComponents"], + "active": verification["activeComponents"], + "blockingDiagnostics": verification["blockingDiagnostics"], + }, + } + + +def verify_topic_activation( + workspace_root: Path, + state: Mapping[str, Any], + *, + client_factory: Callable[ + [dict[str, Any]], MinimalBotEvaluationClient + ] = MinimalBotEvaluationClient.from_config, +) -> dict[str, Any]: + """Verify live topic activation without treating dependency health as state.""" + return _verify_workday_topics( + _agent_verification_context(workspace_root, state), + client_factory=client_factory, + require_clean_diagnostics=False, + ) diff --git a/solutions/ess-maker-skills/scripts/workday_connect_contracts.py b/solutions/ess-maker-skills/scripts/workday_connect_contracts.py index 33229b64..518ac0a2 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_contracts.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_contracts.py @@ -5,6 +5,7 @@ from __future__ import annotations +from datetime import datetime from typing import Any, Mapping from urllib.parse import urlparse @@ -17,6 +18,14 @@ WORKDAY_CONNECTOR_APP_ID = "4e4707ca-5f53-46a6-a819-f7765446e6ff" GRAPH_DELEGATED_PERMISSIONS = ("openid", "profile", "User.Read") +WORKDAY_AUTHENTICATION_POLICY_OUTCOMES = { + "existing-active-policy", + "reviewed-policy-activated", +} +WORKDAY_NETWORK_READINESS_OUTCOMES = { + "confirmed-hosts-allowed", + "no-customer-firewall-change-required", +} class WorkdayConnectContractError(WorkdayConnectModelError): @@ -53,18 +62,14 @@ def _candidate(candidate: Any) -> dict[str, Any]: candidate, "displayName", "Entra app display name" ), "appId": _required_text(candidate, "appId", "Entra app ID"), - "objectId": _required_text( - candidate, "objectId", "Entra app object ID" - ), + "objectId": _required_text(candidate, "objectId", "Entra app object ID"), "servicePrincipalId": _required_text( candidate, "servicePrincipalId", "Entra service principal ID", ), "identifierUris": [ - str(value).strip() - for value in identifier_uris - if str(value).strip() + str(value).strip() for value in identifier_uris if str(value).strip() ], } @@ -92,18 +97,14 @@ def build_entra_handoff( """Build one exact Entra administrator handoff after discovery.""" _require_preflight(state) if not isinstance(discovery, Mapping): - raise WorkdayConnectContractError( - "Entra discovery must contain a JSON object." - ) + raise WorkdayConnectContractError("Entra discovery must contain a JSON object.") scope = state.get("scope") or {} tenant = _required_text(scope, "workdayTenant", "Workday tenant") entity_id = workday_saml_entity_id(tenant) entra_tenant_id = _required_text( scope, "entraTenantId", "Microsoft Entra tenant ID" ) - candidates = [ - _candidate(value) for value in (discovery.get("applications") or []) - ] + candidates = [_candidate(value) for value in (discovery.get("applications") or [])] matches = [ value for value in candidates @@ -138,6 +139,52 @@ def build_entra_handoff( } ) app_id_uri = f"api://{app['appId']}" if app else None + foundation = state.get("tenantFoundation") + foundation_scope = ( + foundation.get("scope") if isinstance(foundation, Mapping) else {} + ) + foundation_identifiers = ( + foundation.get("identifiers") if isinstance(foundation, Mapping) else {} + ) + reusable = bool( + app + and isinstance(foundation_scope, Mapping) + and isinstance(foundation_identifiers, Mapping) + and str(foundation_scope.get("entraTenantId") or "").casefold() + == entra_tenant_id.casefold() + and str(foundation_scope.get("workdayTenant") or "").casefold() + == tenant.casefold() + and str(foundation_identifiers.get("entraAppId") or "").casefold() + == app["appId"].casefold() + ) + if app is None: + actions = [ + "Instantiate the Workday gallery application in the selected " + "Microsoft Entra tenant", + "Rerun exact application discovery after Entra assigns the " + "application and service-principal identifiers", + ] + elif reusable: + actions = [ + "Reread the exact Workday application and service principal " + "through Microsoft Graph", + "Reuse the stored tenant configuration when every required " + "setting still verifies; involve an administrator only for " + "missing or changed settings", + ] + else: + actions = [ + "Reuse the exact Workday SAML application", + "Configure SAML mode, the signing certificate, and the exact " + f"Service Provider ID {entity_id}", + "Expose the user_impersonation scope at the Entra application ID " + "URI and pre-authorize the Workday connector", + "Add openid, profile, and User.Read delegated permissions", + "Grant administrator consent", + "Configure enterprise-application user assignment", + "Map NameID to the attribute that equals the Workday User Name", + "Set the SAML signing option to Sign SAML response and assertion", + ] return { "phase": "entra", "scope": { @@ -155,19 +202,93 @@ def build_entra_handoff( "graphDelegated": list(GRAPH_DELEGATED_PERMISSIONS), "scope": "user_impersonation", }, - "actions": [ - ( - "Reuse the exact Workday SAML application" - if app - else "Instantiate the Workday gallery application" - ), - "Configure SAML mode, the signing certificate, and the exact " - f"Service Provider ID {entity_id}", - "Expose the user_impersonation scope at the Entra application ID " - "URI and pre-authorize the Workday connector", - "Add openid, profile, and User.Read delegated permissions", - "Grant administrator consent and verify the final configuration", - ], + "foundationReuse": { + "eligible": reusable, + }, + "requiresRediscovery": app is None, + "actions": actions, + } + + +_ENTRA_CHECKS = { + "samlMode", + "signingCertificate", + "connectorPreauthorized", + "graphDelegatedPermissions", + "adminConsent", + "userAssignment", + "nameId", + "samlSigningOption", +} +_GRAPH_ONLY_ENTRA_CHECKS = _ENTRA_CHECKS - { + "nameId", + "samlSigningOption", +} + + +def _normalize_entra_check(name: str, value: Any) -> dict[str, Any]: + if not isinstance(value, Mapping): + raise WorkdayConnectContractError( + f"Entra verification check '{name}' must contain evidence." + ) + unexpected = sorted(set(value) - {"outcome", "provenance"}) + if unexpected: + raise WorkdayConnectContractError( + f"Entra verification check '{name}' contains unsupported fields: " + + ", ".join(unexpected) + ) + outcome = str(value.get("outcome") or "").strip().casefold() + if outcome not in {"verified", "confirmed"}: + raise WorkdayConnectContractError( + f"Entra verification check '{name}' is incomplete." + ) + provenance = str(value.get("provenance") or "").strip() + if not provenance: + raise WorkdayConnectContractError( + f"Entra verification check '{name}' lacks provenance." + ) + normalized_provenance = provenance.casefold() + if name in _GRAPH_ONLY_ENTRA_CHECKS and normalized_provenance != "microsoft-graph": + raise WorkdayConnectContractError( + f"Entra verification check '{name}' must be proven by Microsoft Graph." + ) + if name in _GRAPH_ONLY_ENTRA_CHECKS and outcome != "verified": + raise WorkdayConnectContractError( + f"Entra verification check '{name}' must have outcome 'verified'." + ) + if ( + name == "samlSigningOption" + and normalized_provenance != "administrator-attestation" + ): + raise WorkdayConnectContractError( + "Entra verification check 'samlSigningOption' must be confirmed " + "by administrator attestation." + ) + if name == "samlSigningOption" and outcome != "confirmed": + raise WorkdayConnectContractError( + "Entra verification check 'samlSigningOption' must have outcome " + "'confirmed'." + ) + if name == "nameId" and normalized_provenance not in { + "microsoft-graph", + "administrator-attestation", + }: + raise WorkdayConnectContractError( + "Entra verification check 'nameId' must be proven by Microsoft " + "Graph or administrator attestation." + ) + if name == "nameId": + expected_outcome = ( + "verified" if normalized_provenance == "microsoft-graph" else "confirmed" + ) + if outcome != expected_outcome: + raise WorkdayConnectContractError( + "Entra verification check 'nameId' has an outcome that does " + "not match its provenance." + ) + return { + "outcome": outcome, + "provenance": provenance, } @@ -182,12 +303,25 @@ def validate_entra_verification( ) application = _candidate(verification.get("application")) scope = state.get("scope") or {} + expected_tenant_id = _required_text( + scope, + "entraTenantId", + "Microsoft Entra tenant ID", + ) + observed_tenant_id = _required_text( + verification, + "tenantId", + "Verified Microsoft Entra tenant ID", + ) + if observed_tenant_id.casefold() != expected_tenant_id.casefold(): + raise WorkdayConnectContractError( + "The verified Microsoft Entra tenant does not match the tenant " + "recorded during Workday preflight." + ) tenant = _required_text(scope, "workdayTenant", "Workday tenant") expected_entity_id = workday_saml_entity_id(tenant) expected_app_uri = f"api://{application['appId']}" - observed_uris = { - _normalized_uri(value) for value in application["identifierUris"] - } + observed_uris = {_normalized_uri(value) for value in application["identifierUris"]} missing_uris = [ value for value in (expected_entity_id, expected_app_uri) @@ -198,40 +332,44 @@ def validate_entra_verification( "The verified Entra application is missing required identifier " "URIs: " + ", ".join(missing_uris) ) - required_checks = { - "samlMode", - "signingCertificate", - "connectorPreauthorized", - "graphDelegatedPermissions", - "adminConsent", - "userAssignmentAndNameId", - } checks = verification.get("checks") if not isinstance(checks, Mapping): raise WorkdayConnectContractError( "Entra verification checks must contain an object." ) - failed_checks = sorted( - check for check in required_checks if checks.get(check) is not True - ) - if failed_checks: + supplied_checks = dict(checks) + unexpected_checks = sorted(supplied_checks.keys() - _ENTRA_CHECKS) + if unexpected_checks: raise WorkdayConnectContractError( - "Entra verification is incomplete: " + ", ".join(failed_checks) + "Entra verification contains unsupported checks: " + + ", ".join(unexpected_checks) ) + missing_checks = sorted(_ENTRA_CHECKS - supplied_checks.keys()) + if missing_checks: + raise WorkdayConnectContractError( + "Entra verification is incomplete: " + ", ".join(missing_checks) + ) + normalized_checks = { + name: _normalize_entra_check(name, supplied_checks[name]) + for name in sorted(_ENTRA_CHECKS) + } scope_guid = _required_text( verification, "scopeGuid", "Entra user_impersonation scope ID", ) certificate = verification.get("certificate") - if certificate is not None and not isinstance(certificate, Mapping): + if not isinstance(certificate, Mapping): raise WorkdayConnectContractError( "Entra certificate metadata must contain an object." ) safe_certificate = { - key: certificate[key] + key: _required_text( + certificate, + key, + f"Entra signing certificate {key}", + ) for key in ("thumbprint", "validFrom", "validTo") - if isinstance(certificate, Mapping) and certificate.get(key) } return { "identifiers": { @@ -241,11 +379,12 @@ def validate_entra_verification( "entraAppIdUri": expected_app_uri, "workdaySamlEntityId": expected_entity_id, "scopeGuid": scope_guid, - "signingCertificate": safe_certificate or None, + "signingCertificate": safe_certificate, }, "evidence": { + "tenantId": observed_tenant_id, "applicationDisplayName": application["displayName"], - "checks": {key: True for key in sorted(required_checks)}, + "checks": normalized_checks, }, } @@ -256,7 +395,6 @@ def build_workday_admin_packet( """Build one compact handoff packet for the Workday administrator.""" scope = state.get("scope") or {} identifiers = state.get("identifiers") or {} - endpoints = state.get("endpoints") or {} tenant = _required_text(scope, "workdayTenant", "Workday tenant") expected_entity_id = workday_saml_entity_id(tenant) entity_id = _required_text( @@ -289,22 +427,60 @@ def build_workday_admin_packet( "referenceValues": { "serviceProviderId": entity_id, "entraApplicationIdUri": entra_app_id_uri, - "oauthTokenUrl": endpoints.get("oauthTokenUrl"), + }, + "identityProviderQuestion": { + "question": ( + "Which sign-in provider does the enabled Workday SAML row " + "appear to use?" + ), + "options": [ + ( + "Microsoft Entra ID - the Issuer often contains " + "login.microsoftonline.com or sts.windows.net" + ), + "Okta - the Issuer often contains okta.com", + ( + "Ping Identity - the Issuer often contains pingone.com, " + "pingidentity.com, or an organization-specific Ping host" + ), + "Another sign-in provider", + "No enabled SAML row", + "I'm not sure", + ], + }, + "certificateSelectionQuestion": { + "question": ( + "Which certificate is selected on the enabled Microsoft " + "Entra SAML row in Workday?" + ), + "options": [ + "The new certificate created from the Entra Base64 file", + "A different existing Workday certificate", + "No certificate is selected", + "I'm not sure", + ], }, "actions": [ - "Confirm the existing enabled SAML identity-provider row belongs " - "to this tenant before changing it", - "Upload the active Entra SAML signing certificate", + "Identify which sign-in provider the enabled Workday SAML row " + "uses before changing it", + "Create a Workday X.509 Public Key from the active Entra SAML " + "signing certificate, select it on the Microsoft Entra SAML row, " + "and compare its validity dates", f"Set the Workday Service Provider ID to {entity_id}", "Enable OAuth 2.0 Clients and SAML in Tenant Setup - Security", - "Register the signed-in employee API client with the required " - "functional areas and Include Workday Owned Scope", + "Register the signed-in employee API client with Client Grant " + "Type SAML Bearer, the required functional areas, and Include " + "Workday Owned Scope", "Verify an active authentication policy allows SAML for the " "intended employee population", + "Confirm the returned Workday REST and SOAP hosts are reachable " + "or approved by the organization network policy", ], "responseForm": { "required": [ + "activeIdentityProviderIssuer", "enabledServiceProviderId", + "certificateName", "certificateValidFrom", "certificateValidTo", "oauthClientId", @@ -312,6 +488,7 @@ def build_workday_admin_packet( "restBaseUrl", "soapBaseUrl", "authenticationPolicyOutcome", + "networkReadinessOutcome", ], "note": ( "Return configuration evidence only. Do not paste passwords, " @@ -325,11 +502,40 @@ def build_workday_admin_packet( def _https_url(value: Any, label: str) -> str: text = str(value or "").strip().rstrip("/") parsed = urlparse(text) - if parsed.scheme != "https" or not parsed.netloc: + if ( + parsed.scheme.casefold() != "https" + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): raise WorkdayConnectContractError(f"{label} must be an HTTPS URL.") return text +def _require_endpoint_path( + url: str, + expected_path: str, + label: str, +) -> None: + observed_path = urlparse(url).path.rstrip("/") + if observed_path.casefold() != expected_path.casefold(): + raise WorkdayConnectContractError( + f"{label} must end exactly at {expected_path}." + ) + + +def _date_only(value: str, label: str) -> str: + normalized = value.strip().replace("Z", "+00:00") + try: + return datetime.fromisoformat(normalized).date().isoformat() + except ValueError as exc: + raise WorkdayConnectContractError( + f"{label} must be an ISO-8601 date or timestamp." + ) from exc + + def validate_workday_admin_response( state: Mapping[str, Any], response: Mapping[str, Any], @@ -338,6 +544,25 @@ def validate_workday_admin_response( raise WorkdayConnectContractError( "Workday administrator response must contain a JSON object." ) + allowed = { + "activeIdentityProviderIssuer", + "enabledServiceProviderId", + "certificateName", + "certificateValidFrom", + "certificateValidTo", + "oauthClientId", + "oauthTokenUrl", + "restBaseUrl", + "soapBaseUrl", + "authenticationPolicyOutcome", + "networkReadinessOutcome", + } + unexpected = sorted(set(response) - allowed) + if unexpected: + raise WorkdayConnectContractError( + "Workday administrator response contains unsupported fields: " + + ", ".join(unexpected) + ) scope = state.get("scope") or {} tenant = _required_text(scope, "workdayTenant", "Workday tenant") expected_entity_id = workday_saml_entity_id(tenant) @@ -346,9 +571,7 @@ def validate_workday_admin_response( "enabledServiceProviderId", "Enabled Workday Service Provider ID", ) - if _normalized_uri(observed_entity_id) != _normalized_uri( - expected_entity_id - ): + if _normalized_uri(observed_entity_id) != _normalized_uri(expected_entity_id): raise WorkdayConnectContractError( "The enabled Workday Service Provider ID does not match the " "selected Workday tenant." @@ -357,28 +580,87 @@ def validate_workday_admin_response( response.get("oauthTokenUrl"), "Workday OAuth token URL", ) + _require_endpoint_path( + oauth_token_url, + f"/ccx/oauth2/{tenant}/token", + "Workday OAuth token URL", + ) rest_base_url = _https_url( response.get("restBaseUrl"), "Workday REST base URL", ) - if not rest_base_url.casefold().endswith("/ccx/api"): - raise WorkdayConnectContractError( - "Workday REST base URL must end exactly at /ccx/api." - ) + _require_endpoint_path( + rest_base_url, + "/ccx/api", + "Workday REST base URL", + ) soap_base_url = _https_url( response.get("soapBaseUrl"), "Workday SOAP base URL", ) + _require_endpoint_path( + soap_base_url, + f"/ccx/service/{tenant}", + "Workday SOAP base URL", + ) required = { + "activeIdentityProviderIssuer", + "certificateName", "certificateValidFrom", "certificateValidTo", "oauthClientId", "authenticationPolicyOutcome", + "networkReadinessOutcome", } - values = { - key: _required_text(response, key, key) - for key in required - } + values = {key: _required_text(response, key, key) for key in required} + if ( + values["authenticationPolicyOutcome"] + not in WORKDAY_AUTHENTICATION_POLICY_OUTCOMES + ): + raise WorkdayConnectContractError( + "authenticationPolicyOutcome must confirm either an existing " + "active employee SAML policy or an activated reviewed change." + ) + if values["networkReadinessOutcome"] not in WORKDAY_NETWORK_READINESS_OUTCOMES: + raise WorkdayConnectContractError( + "networkReadinessOutcome must confirm the Workday hosts are " + "allowed or that no customer firewall change is required." + ) + signing_certificate = (state.get("identifiers") or {}).get("signingCertificate") + if not isinstance(signing_certificate, Mapping): + raise WorkdayConnectContractError( + "Verified Entra signing certificate metadata is required before " + "recording Workday administrator evidence." + ) + workday_valid_from = _date_only( + values["certificateValidFrom"], + "Workday certificate Valid From", + ) + workday_valid_to = _date_only( + values["certificateValidTo"], + "Workday certificate Valid To", + ) + entra_valid_from = _date_only( + _required_text( + signing_certificate, + "validFrom", + "Entra signing certificate Valid From", + ), + "Entra signing certificate Valid From", + ) + entra_valid_to = _date_only( + _required_text( + signing_certificate, + "validTo", + "Entra signing certificate Valid To", + ), + "Entra signing certificate Valid To", + ) + if workday_valid_from != entra_valid_from or workday_valid_to != entra_valid_to: + raise WorkdayConnectContractError( + "The Workday X.509 certificate validity dates do not match the " + "verified Entra signing certificate." + ) return { "identifiers": { "workdaySamlEntityId": expected_entity_id, @@ -390,70 +672,132 @@ def validate_workday_admin_response( "soapBaseUrl": soap_base_url, }, "evidence": { + "activeIdentityProviderIssuer": values["activeIdentityProviderIssuer"], "serviceProviderId": expected_entity_id, - "certificateValidFrom": values["certificateValidFrom"], - "certificateValidTo": values["certificateValidTo"], - "authenticationPolicyOutcome": values[ - "authenticationPolicyOutcome" - ], + "certificateName": values["certificateName"], + "certificateValidFrom": workday_valid_from, + "certificateValidTo": workday_valid_to, + "authenticationPolicyOutcome": values["authenticationPolicyOutcome"], + "networkReadinessOutcome": values["networkReadinessOutcome"], }, } -def validate_connections_evidence( +def validate_agent_binding_evidence( + state: Mapping[str, Any], evidence: Mapping[str, Any], ) -> dict[str, Any]: if not isinstance(evidence, Mapping): raise WorkdayConnectContractError( - "Connection evidence must contain a JSON object." + "Agent binding evidence must contain a JSON object." ) - required_true = ( - "workdayConnectionConnected", - "dataverseConnectionConnected", - ) - unexpected = sorted(set(evidence) - set(required_true)) + allowed = { + "environmentId", + "botId", + "makerUsername", + "checkpoints", + "workdayTopics", + } + unexpected = sorted(set(evidence) - allowed) if unexpected: raise WorkdayConnectContractError( - "Connection evidence contains unsupported fields: " + "Agent binding evidence contains unsupported fields: " + ", ".join(unexpected) ) - missing = sorted( - key for key in required_true if evidence.get(key) is not True + scope = state.get("scope") or {} + agent = scope.get("agent") or {} + expected_environment = _required_text( + scope, + "environmentId", + "Workday environment ID", + ) + expected_bot = _required_text(agent, "botId", "Workday agent bot ID") + observed_environment = _required_text( + evidence, + "environmentId", + "Verified environment ID", + ) + observed_bot = _required_text( + evidence, + "botId", + "Verified agent bot ID", ) - if missing: + if observed_environment.casefold() != expected_environment.casefold(): raise WorkdayConnectContractError( - "Connection evidence is incomplete: " + ", ".join(missing) + "Agent binding verification targeted a different environment." ) - return {key: True for key in required_true} - - -def validate_agent_binding_evidence( - evidence: Mapping[str, Any], -) -> dict[str, Any]: - if not isinstance(evidence, Mapping): + if observed_bot.casefold() != expected_bot.casefold(): raise WorkdayConnectContractError( - "Agent binding evidence must contain a JSON object." + "Agent binding verification targeted a different agent." ) - required_true = ( - "userContextRedirectPassed", - "parameterSharingPassed", - "flowAttachmentConfirmed", - "workdayTopicsActivated", + expected_maker = _required_text( + (state.get("operators") or {}).get("powerPlatformMaker") or {}, + "username", + "Recorded Power Platform maker", ) - unexpected = sorted(set(evidence) - set(required_true)) - if unexpected: + observed_maker = _required_text( + evidence, + "makerUsername", + "Verified Power Platform maker", + ) + if observed_maker.casefold() != expected_maker.casefold(): raise WorkdayConnectContractError( - "Agent binding evidence contains unsupported fields: " - + ", ".join(unexpected) + "Agent binding verification used a different Power Platform maker." ) - missing = sorted( - key for key in required_true if evidence.get(key) is not True + checkpoints = evidence.get("checkpoints") + if not isinstance(checkpoints, Mapping): + raise WorkdayConnectContractError( + "Agent binding evidence must contain checkpoint results." + ) + required_checkpoints = {"WD-REST-002", "WD-CONN-013"} + failed_checkpoints = sorted( + checkpoint + for checkpoint in required_checkpoints + if checkpoints.get(checkpoint) != "Passed" ) - if missing: + if failed_checkpoints: + raise WorkdayConnectContractError( + "Agent binding verification did not pass: " + ", ".join(failed_checkpoints) + ) + topics = evidence.get("workdayTopics") + if not isinstance(topics, Mapping): raise WorkdayConnectContractError( - "Agent binding evidence is incomplete: " + ", ".join(missing) + "Agent binding evidence must contain Workday topic verification." ) - return {key: True for key in required_true} + expected_count = topics.get("expected") + verified_count = topics.get("verified") + active_count = topics.get("active") + diagnostics = topics.get("blockingDiagnostics") + if ( + not isinstance(expected_count, int) + or isinstance(expected_count, bool) + or expected_count <= 0 + or not isinstance(verified_count, int) + or isinstance(verified_count, bool) + or not isinstance(active_count, int) + or isinstance(active_count, bool) + or verified_count != expected_count + or active_count != expected_count + or diagnostics != [] + ): + raise WorkdayConnectContractError( + "Every mapped Workday topic must be active, verified, and free " + "of blocking diagnostics." + ) + return { + "environmentId": observed_environment, + "botId": observed_bot, + "makerUsername": observed_maker, + "checkpoints": { + checkpoint: "Passed" for checkpoint in sorted(required_checkpoints) + }, + "workdayTopics": { + "expected": expected_count, + "verified": verified_count, + "active": active_count, + "blockingDiagnostics": [], + }, + } def validate_employee_evidence( @@ -470,10 +814,7 @@ def validate_employee_evidence( "Employee validation evidence contains unsupported fields: " + ", ".join(unexpected) ) - result = { - key: _required_text(evidence, key, key) - for key in allowed - } + result = {key: _required_text(evidence, key, key) for key in allowed} if result["outcome"].casefold() not in {"passed", "verified"}: raise WorkdayConnectContractError( "Employee validation outcome must be passed or verified." diff --git a/solutions/ess-maker-skills/scripts/workday_connect_model.py b/solutions/ess-maker-skills/scripts/workday_connect_model.py index 715cb997..49f1d527 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_model.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_model.py @@ -15,7 +15,7 @@ from typing import Any, Mapping -STATE_SCHEMA_VERSION = 3 +STATE_SCHEMA_VERSION = 5 CONTROLLER_CONTRACT_VERSION = 1 CATALOG_PATH = Path(__file__).with_name("workday_connect_catalog.json") @@ -44,46 +44,85 @@ class PhaseStatus(str, Enum): class PhaseDefinition: identifier: Phase title: str - short_title: str + what_happens: tuple[str, ...] prerequisite: Phase | None PHASE_DEFINITIONS = ( - PhaseDefinition(Phase.PREFLIGHT, "Preflight", "Preflight", None), PhaseDefinition( - Phase.ENTRA, - "Microsoft Entra", - "Entra", - Phase.PREFLIGHT, + identifier=Phase.PREFLIGHT, + title="Preflight", + what_happens=( + "Confirm the selected ESS HR agent, Power Platform environment, " + "and maker account.", + "Verify Dataverse is available in the selected environment.", + "Install or verify the supported Workday package.", + ), + prerequisite=None, ), PhaseDefinition( - Phase.WORKDAY_ADMIN, - "Workday administrator", - "Workday", - Phase.ENTRA, + identifier=Phase.ENTRA, + title="Microsoft Entra", + what_happens=( + "Find the exact Workday enterprise application in the selected " + "Microsoft Entra tenant.", + "Guide an Entra administrator through the required SAML, " + "permission, consent, assignment, and employee sign-in settings.", + "Verify the application and signing-certificate configuration.", + ), + prerequisite=Phase.PREFLIGHT, ), PhaseDefinition( - Phase.CONNECTIONS, - "Connections", - "Connections", - Phase.WORKDAY_ADMIN, + identifier=Phase.WORKDAY_ADMIN, + title="Workday administrator", + what_happens=( + "Identify the existing Workday sign-in provider without replacing " + "another federation.", + "Configure certificate trust, OAuth, the employee API client, and " + "the employee authentication policy.", + "Validate the non-secret connection values needed by Power " + "Platform.", + ), + prerequisite=Phase.ENTRA, ), PhaseDefinition( - Phase.RUNTIME, - "Runtime configuration", - "Runtime", - Phase.CONNECTIONS, + identifier=Phase.CONNECTIONS, + title="Connections", + what_happens=( + "Find or guide creation of the Workday and Microsoft Dataverse " + "connections in the selected environment.", + "Use the verified Workday resource URL, token URL, and OAuth " + "client ID.", + "Verify both connections are live before runtime configuration.", + ), + prerequisite=Phase.WORKDAY_ADMIN, ), PhaseDefinition( - Phase.EMPLOYEE_VALIDATION, - "Employee validation", - "Validate", - Phase.RUNTIME, + identifier=Phase.RUNTIME, + title="Runtime configuration", + what_happens=( + "Preview and approve the exact Workday runtime changes.", + "Bind connections, activate required flows, configure permissions " + "and employee context, and enable the Workday topics.", + "Reread the agent and preserve any remaining blocker for safe " + "resume.", + ), + prerequisite=Phase.CONNECTIONS, + ), + PhaseDefinition( + identifier=Phase.EMPLOYEE_VALIDATION, + title="Employee validation", + what_happens=( + "Publish the configured agent.", + "Run a real Workday scenario as a signed-in non-maker employee.", + "Confirm employee context and Workday data work without an " + "unexpected repeated sign-in.", + ), + prerequisite=Phase.RUNTIME, ), ) PHASE_BY_ID = { - definition.identifier.value: definition - for definition in PHASE_DEFINITIONS + definition.identifier.value: definition for definition in PHASE_DEFINITIONS } PHASE_REQUIRED_ACTIONS = { @@ -94,9 +133,7 @@ class PhaseDefinition: "administrator-configuration-verified", } ), - Phase.WORKDAY_ADMIN.value: frozenset( - {"administrator-response-validated"} - ), + Phase.WORKDAY_ADMIN.value: frozenset({"administrator-response-validated"}), Phase.CONNECTIONS.value: frozenset({"physical-connections-verified"}), Phase.RUNTIME.value: frozenset( { @@ -106,10 +143,30 @@ class PhaseDefinition: "user-context-v2-configured", "agent-parameter-sharing-verified", "flow-attachment-confirmed", + "workday-topics-activated", } ), Phase.EMPLOYEE_VALIDATION.value: frozenset({"signed-in-scenario"}), } +TENANT_FOUNDATION_REQUIRED_IDENTIFIER_KEYS = frozenset( + { + "entraAppId", + "entraAppObjectId", + "entraServicePrincipalId", + "entraAppIdUri", + "workdaySamlEntityId", + "scopeGuid", + "signingCertificate", + "oauthClientId", + } +) +TENANT_FOUNDATION_REQUIRED_ENDPOINT_KEYS = frozenset( + { + "oauthTokenUrl", + "restBaseUrl", + "soapBaseUrl", + } +) LEGACY_PHASE_ROWS = { Phase.PREFLIGHT: ("DA1.1",), @@ -265,6 +322,7 @@ def default_state() -> dict[str, Any]: "identifiers": {}, "endpoints": {}, "operators": {}, + "tenantFoundation": None, "phases": { definition.identifier.value: default_phase_state() for definition in PHASE_DEFINITIONS @@ -276,9 +334,7 @@ def default_state() -> dict[str, Any]: def _validate_phase_state(phase_id: str, value: Any) -> None: if not isinstance(value, dict): - raise WorkdayConnectModelError( - f"Phase '{phase_id}' state must be an object." - ) + raise WorkdayConnectModelError(f"Phase '{phase_id}' state must be an object.") required = { "status", "completedActions", @@ -297,6 +353,19 @@ def _validate_phase_state(phase_id: str, value: Any) -> None: raise WorkdayConnectModelError( f"Phase '{phase_id}' has invalid status '{value['status']}'." ) + blocker = value["blocker"] + if value["status"] == PhaseStatus.BLOCKED.value: + if not isinstance(blocker, dict) or any( + not str(blocker.get(key) or "").strip() + for key in ("operation", "errorType", "message") + ): + raise WorkdayConnectModelError( + f"Phase '{phase_id}' must include a complete blocker." + ) + elif blocker is not None: + raise WorkdayConnectModelError( + f"Phase '{phase_id}' can contain a blocker only while blocked." + ) if not isinstance(value["completedActions"], list) or any( not isinstance(action, str) or not action for action in value["completedActions"] @@ -336,13 +405,9 @@ def _validate_phase_state(phase_id: str, value: Any) -> None: if missing_actions or missing_evidence: details = [] if missing_actions: - details.append( - "actions=" + ", ".join(missing_actions) - ) + details.append("actions=" + ", ".join(missing_actions)) if missing_evidence: - details.append( - "evidence=" + ", ".join(missing_evidence) - ) + details.append("evidence=" + ", ".join(missing_evidence)) raise WorkdayConnectModelError( f"Phase '{phase_id}' cannot be complete without required " + " and ".join(details) @@ -350,6 +415,110 @@ def _validate_phase_state(phase_id: str, value: Any) -> None: ) +def _validate_tenant_foundation(value: Any) -> None: + if value is None: + return + if not isinstance(value, dict): + raise WorkdayConnectModelError( + "Workday tenantFoundation must be an object or null." + ) + required = { + "scope", + "identifiers", + "endpoints", + "phases", + "capturedAt", + } + missing = sorted(required - value.keys()) + if missing: + raise WorkdayConnectModelError( + "Workday tenantFoundation is missing: " + ", ".join(missing) + ) + for field in ("scope", "identifiers", "endpoints", "phases"): + if not isinstance(value[field], dict): + raise WorkdayConnectModelError( + f"Workday tenantFoundation.{field} must be an object." + ) + for key in ("entraTenantId", "workdayTenant"): + if not str(value["scope"].get(key) or "").strip(): + raise WorkdayConnectModelError( + f"Workday tenantFoundation.scope.{key} is required." + ) + missing_identifiers = sorted( + key + for key in TENANT_FOUNDATION_REQUIRED_IDENTIFIER_KEYS + if value["identifiers"].get(key) is None or value["identifiers"].get(key) == "" + ) + if missing_identifiers: + raise WorkdayConnectModelError( + "Workday tenantFoundation identifiers are missing: " + + ", ".join(missing_identifiers) + ) + certificate = value["identifiers"].get("signingCertificate") + if not isinstance(certificate, dict) or any( + not str(certificate.get(key) or "").strip() + for key in ("thumbprint", "validFrom", "validTo") + ): + raise WorkdayConnectModelError( + "Workday tenantFoundation signingCertificate must contain " + "thumbprint, validFrom, and validTo." + ) + missing_endpoints = sorted( + key + for key in TENANT_FOUNDATION_REQUIRED_ENDPOINT_KEYS + if not str(value["endpoints"].get(key) or "").strip() + ) + if missing_endpoints: + raise WorkdayConnectModelError( + "Workday tenantFoundation endpoints are missing: " + + ", ".join(missing_endpoints) + ) + if set(value["phases"]) != { + Phase.ENTRA.value, + Phase.WORKDAY_ADMIN.value, + }: + raise WorkdayConnectModelError( + "Workday tenantFoundation phases must contain exactly Entra and " + "Workday administrator evidence." + ) + for phase_id, snapshot in value["phases"].items(): + if not isinstance(snapshot, dict): + raise WorkdayConnectModelError( + f"Workday tenantFoundation phase '{phase_id}' must be an object." + ) + actions = snapshot.get("completedActions") + evidence = snapshot.get("evidence") + if not isinstance(actions, list) or any( + not isinstance(action, str) or not action for action in actions + ): + raise WorkdayConnectModelError( + f"Workday tenantFoundation phase '{phase_id}' actions are invalid." + ) + if not isinstance(evidence, list) or any( + not isinstance(record, dict) + or not isinstance(record.get("action"), str) + or not record.get("action") + for record in evidence + ): + raise WorkdayConnectModelError( + f"Workday tenantFoundation phase '{phase_id}' evidence is invalid." + ) + required_actions = PHASE_REQUIRED_ACTIONS[phase_id] + if not required_actions <= set(actions): + raise WorkdayConnectModelError( + f"Workday tenantFoundation phase '{phase_id}' is incomplete." + ) + evidence_actions = {str(record.get("action") or "") for record in evidence} + if not required_actions <= evidence_actions: + raise WorkdayConnectModelError( + f"Workday tenantFoundation phase '{phase_id}' lacks evidence." + ) + if not isinstance(value["capturedAt"], str) or not value["capturedAt"]: + raise WorkdayConnectModelError( + "Workday tenantFoundation.capturedAt is required." + ) + + def validate_state(state: Any) -> dict[str, Any]: if not isinstance(state, dict): raise WorkdayConnectModelError( @@ -370,6 +539,7 @@ def validate_state(state: Any) -> dict[str, Any]: raise WorkdayConnectModelError( f"Workday connect state '{field}' must be an object." ) + _validate_tenant_foundation(state.get("tenantFoundation")) phases = state["phases"] if set(phases) != set(PHASE_BY_ID): raise WorkdayConnectModelError( @@ -393,8 +563,7 @@ def validate_state(state: Any) -> dict[str, Any]: expected_status = ( "ready" if all( - phase["status"] == PhaseStatus.COMPLETE.value - for phase in phases.values() + phase["status"] == PhaseStatus.COMPLETE.value for phase in phases.values() ) else "in-progress" ) @@ -408,26 +577,43 @@ def validate_state(state: Any) -> dict[str, Any]: def next_phase_id(state: Mapping[str, Any]) -> str | None: phases = state["phases"] for definition in PHASE_DEFINITIONS: - if ( - phases[definition.identifier.value]["status"] - != PhaseStatus.COMPLETE.value - ): + if phases[definition.identifier.value]["status"] != PhaseStatus.COMPLETE.value: return definition.identifier.value return None def progress_text(state: Mapping[str, Any]) -> str: - markers = { - PhaseStatus.PENDING.value: "", - PhaseStatus.ACTIVE.value: "→", - PhaseStatus.BLOCKED.value: "!", - PhaseStatus.COMPLETE.value: "✓", + labels = { + PhaseStatus.PENDING.value: "Pending", + PhaseStatus.ACTIVE.value: "In progress", + PhaseStatus.BLOCKED.value: "Needs attention", + PhaseStatus.COMPLETE.value: "Complete", } - parts = [] - for definition in PHASE_DEFINITIONS: + next_phase = next_phase_id(state) + rows = [ + "### Workday connection progress", + "", + "| # | Phase | Status |", + "|---:|---|---|", + ] + for index, definition in enumerate(PHASE_DEFINITIONS, start=1): status = state["phases"][definition.identifier.value]["status"] - marker = markers[status] - parts.append( - f"{definition.short_title}{f' {marker}' if marker else ''}" - ) - return "Progress: " + " · ".join(parts) + label = labels[status] + if status == PhaseStatus.PENDING.value and ( + definition.identifier.value == next_phase + ): + label = "Next" + rows.append(f"| {index} | {definition.title} | {label} |") + return "\n".join(rows) + + +def next_phase_summary(state: Mapping[str, Any]) -> dict[str, Any] | None: + phase_id = next_phase_id(state) + if phase_id is None: + return None + definition = PHASE_BY_ID[phase_id] + return { + "id": phase_id, + "title": definition.title, + "whatHappens": list(definition.what_happens), + } diff --git a/solutions/ess-maker-skills/scripts/workday_connect_preflight.py b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py index d77071c0..e72b5488 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_preflight.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py @@ -236,6 +236,10 @@ def _pac_dataverse_url( return str(identity.get("OrgUrl") or "").strip() +def _normalize_dataverse_url(value: str | None) -> str: + return str(value or "").strip().rstrip("/").casefold() + + def resolve_target( workspace_root: Path, *, @@ -265,26 +269,78 @@ def resolve_target( _require_materialized_workspace(setup_state, agent) foundation_ring = str(foundation.get("ring") or "prod").casefold() - environment_id = str( - foundation.get("environmentId") - or (setup_state.get("environment") or {}).get("id") - or "" + foundation_environment_id = str( + foundation.get("environmentId") or "" + ).strip() + setup_environment_id = str( + (setup_state.get("environment") or {}).get("id") or "" + ).strip() + if ( + foundation_environment_id + and setup_environment_id + and foundation_environment_id.casefold() + != setup_environment_id.casefold() + ): + raise WorkdayConnectPreflightError( + "Foundation and setup state record different Power Platform " + "environment IDs. Refresh setup before continuing." + ) + environment_id = foundation_environment_id or setup_environment_id + if not environment_id: + raise WorkdayConnectPreflightError( + "The setup state does not contain an exact Power Platform " + "environment ID." + ) + + foundation_url = str( + foundation.get("dataverseEndpoint") or "" + ).strip() + inventory_url = _cached_dataverse_url( + workspace_root, + environment_id=environment_id, + ring=foundation_ring, + ) + state_scope = state.get("scope") or {} + stored_environment_id = str( + state_scope.get("environmentId") or "" ).strip() - exact_url = ( - str(foundation.get("dataverseEndpoint") or "").strip() - or str(state.get("scope", {}).get("dataverseUrl") or "").strip() - or str(dataverse_url or "").strip() - or _cached_dataverse_url( - workspace_root, - environment_id=environment_id, - ring=foundation_ring, + stored_url = ( + str(state_scope.get("dataverseUrl") or "").strip() + if stored_environment_id.casefold() == environment_id.casefold() + else "" + ) + supplied_url = str(dataverse_url or "").strip() + + authoritative_urls = [ + value for value in (foundation_url, inventory_url) if value + ] + if len( + {_normalize_dataverse_url(value) for value in authoritative_urls} + ) > 1: + raise WorkdayConnectPreflightError( + "Foundation setup and environment inventory disagree on the " + "Dataverse URL for the recorded environment ID. Refresh setup " + "before continuing." ) - or _pac_dataverse_url( + exact_url = foundation_url or inventory_url or stored_url + if supplied_url: + if exact_url and ( + _normalize_dataverse_url(supplied_url) + != _normalize_dataverse_url(exact_url) + ): + raise WorkdayConnectPreflightError( + "The supplied Dataverse URL does not match the URL recorded " + "for the setup environment ID." + ) + if not exact_url: + exact_url = supplied_url + if not exact_url: + exact_url = _pac_dataverse_url( environment_id, pac_resolver=pac_resolver, runner=pac_runner, ) - ).rstrip("/") + exact_url = exact_url.rstrip("/") if not exact_url: raise WorkdayConnectPreflightError( "The setup environment does not have a resolved Dataverse URL. " diff --git a/solutions/ess-maker-skills/scripts/workday_connect_runtime.py b/solutions/ess-maker-skills/scripts/workday_connect_runtime.py index ab96232a..62b16538 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_runtime.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_runtime.py @@ -75,16 +75,14 @@ def _select_connection( and _connected(value) and ( not explicit_id - or str(value.get("name") or "").casefold() - == explicit_id.casefold() + or str(value.get("name") or "").casefold() == explicit_id.casefold() ) ] if len(matches) != 1: safe = sorted( { str( - (value.get("properties") or {}).get("displayName") - or connector_name + (value.get("properties") or {}).get("displayName") or connector_name ) for value in matches } @@ -122,8 +120,7 @@ def _list_connections( ) except subprocess.TimeoutExpired as exc: raise WorkdayConnectRuntimeError( - "PAC did not finish listing environment connections within " - "2 minutes." + "PAC did not finish listing environment connections within 2 minutes." ) from exc if result.returncode != 0: raise WorkdayConnectRuntimeError( @@ -156,9 +153,7 @@ def _single_rows( if observed == name.casefold(): grouped[name].append(row) invalid = { - name: len(matches) - for name, matches in grouped.items() - if len(matches) != 1 + name: len(matches) for name, matches in grouped.items() if len(matches) != 1 } if invalid: raise WorkdayConnectRuntimeError( @@ -176,8 +171,7 @@ def _runtime_references( query: Callable[..., list[dict[str, Any]]], ) -> dict[str, dict[str, Any]]: filters = " or ".join( - "connectionreferencelogicalname eq " - f"'{_odata_literal(name)}'" + f"connectionreferencelogicalname eq '{_odata_literal(name)}'" for name in logical_names ) rows = query( @@ -204,9 +198,7 @@ def _runtime_flows( *, query: Callable[..., list[dict[str, Any]]], ) -> dict[str, dict[str, Any]]: - filters = " or ".join( - f"name eq '{_odata_literal(name)}'" for name in flow_names - ) + filters = " or ".join(f"name eq '{_odata_literal(name)}'" for name in flow_names) rows = query( environment_url, token, @@ -218,8 +210,7 @@ def _runtime_flows( outside_package = [ name for name, row in flows.items() - if str(row.get("workflowid") or "").casefold() - not in allowed_workflow_ids + if str(row.get("workflowid") or "").casefold() not in allowed_workflow_ids ] if outside_package: raise WorkdayConnectRuntimeError( @@ -297,9 +288,7 @@ def _runtime_discovery_context( scope = state.get("scope") or {} operators = state.get("operators") or {} agent = scope.get("agent") or {} - package_flavor = _required_text( - scope, "packageFlavor", "Workday package flavor" - ) + package_flavor = _required_text(scope, "packageFlavor", "Workday package flavor") active_catalog = catalog or load_catalog() package = (active_catalog.get("packages") or {}).get(package_flavor) if not isinstance(package, Mapping): @@ -349,8 +338,7 @@ def _build_runtime_discovery( logical_names[0]: { "connectionId": str(workday.get("name") or ""), "displayName": str( - (workday.get("properties") or {}).get("displayName") - or "Workday" + (workday.get("properties") or {}).get("displayName") or "Workday" ), "connector": references_catalog["workday"]["connectorName"], }, @@ -417,9 +405,7 @@ def _build_runtime_discovery( "plan": {**plan, "planHash": plan_hash(plan)}, "approvalSummary": { "environmentUrl": context["environmentUrl"], - "agentName": str( - context["agent"].get("name") or "ESS HR agent" - ), + "agentName": str(context["agent"].get("name") or "ESS HR agent"), "connections": [ value["displayName"] for value in target_connections.values() ], @@ -444,35 +430,20 @@ def discover_runtime_plan( ) -> dict[str, Any]: """Discover exact runtime targets and return a stable approval plan.""" context = _runtime_discovery_context(state, catalog) - pac = pac_resolver() - pac_auth( - pac, - ring=context["pacRing"], - environment_url=context["environmentUrl"], - preferred_username=context["maker"], - runner=runner, - ) - connections = _list_connections( - pac, - context["environmentUrl"], + workday, dataverse = _discover_physical_connections( + context, + workday_connection_id=workday_connection_id, + dataverse_connection_id=dataverse_connection_id, + pac_resolver=pac_resolver, + pac_auth=pac_auth, runner=runner, ) - references_catalog = context["referencesCatalog"] - workday = _select_connection( - connections, - references_catalog["workday"]["connectorName"], - explicit_id=workday_connection_id, - ) - dataverse = _select_connection( - connections, - references_catalog["dataverse"]["connectorName"], - explicit_id=dataverse_connection_id, - ) active_token = token or token_provider( context["environmentUrl"], preferred_username=context["maker"], ) + references_catalog = context["referencesCatalog"] logical_names = [ references_catalog["workday"]["logicalName"], references_catalog["dataverse"]["logicalName"], @@ -509,6 +480,82 @@ def discover_runtime_plan( ) +def _discover_physical_connections( + context: Mapping[str, Any], + *, + workday_connection_id: str | None, + dataverse_connection_id: str | None, + pac_resolver: Callable[[], Path], + pac_auth: Callable[..., Any], + runner: Callable[..., subprocess.CompletedProcess], +) -> tuple[dict[str, Any], dict[str, Any]]: + pac = pac_resolver() + pac_auth( + pac, + ring=context["pacRing"], + environment_url=context["environmentUrl"], + preferred_username=context["maker"], + runner=runner, + ) + connections = _list_connections( + pac, + context["environmentUrl"], + runner=runner, + ) + references_catalog = context["referencesCatalog"] + workday = _select_connection( + connections, + references_catalog["workday"]["connectorName"], + explicit_id=workday_connection_id, + ) + dataverse = _select_connection( + connections, + references_catalog["dataverse"]["connectorName"], + explicit_id=dataverse_connection_id, + ) + return workday, dataverse + + +def verify_physical_connections( + state: Mapping[str, Any], + *, + workday_connection_id: str | None = None, + dataverse_connection_id: str | None = None, + catalog: Mapping[str, Any] | None = None, + pac_resolver: Callable[[], Path] = resolve_pac_executable, + pac_auth: Callable[..., Any] = ensure_pac_auth, + runner: Callable[..., subprocess.CompletedProcess] = _default_runner, +) -> dict[str, Any]: + """Verify selected Workday and Dataverse connections from live state.""" + context = _runtime_discovery_context(state, catalog) + workday, dataverse = _discover_physical_connections( + context, + workday_connection_id=workday_connection_id, + dataverse_connection_id=dataverse_connection_id, + pac_resolver=pac_resolver, + pac_auth=pac_auth, + runner=runner, + ) + return { + "makerUsername": context["maker"], + "connections": [ + { + "connector": context["referencesCatalog"]["workday"]["connectorName"], + "displayName": str( + (workday.get("properties") or {}).get("displayName") or "Workday" + ), + }, + { + "connector": context["referencesCatalog"]["dataverse"]["connectorName"], + "displayName": str( + (dataverse.get("properties") or {}).get("displayName") + or "Microsoft Dataverse" + ), + }, + ], + } + + def run_runtime_operation( state: Mapping[str, Any], *, @@ -521,15 +568,13 @@ def run_runtime_operation( identity_provider: Callable[..., dict[str, str]] = require_identity, query: Callable[..., list[dict[str, Any]]] = query_all, updater: Callable[..., bool] = update_record, - authorization_runner: Callable[ - ..., subprocess.CompletedProcess - ] = _default_runner, + authorization_runner: Callable[..., subprocess.CompletedProcess] = _default_runner, stage_recorder: Callable[[str, Mapping[str, Any]], Any] | None = None, **discovery_dependencies: Any, ) -> dict[str, Any]: """Run runtime preview or apply while reusing one Dataverse token.""" phases = state.get("phases") or {} - if apply and (phases.get("connections") or {}).get("status") != "complete": + if (phases.get("connections") or {}).get("status") != "complete": raise WorkdayConnectRuntimeError( "Complete the Workday connection sign-ins before runtime wiring." ) @@ -643,10 +688,7 @@ def _run_authorization( "authorization records." ) elif "[fail]" in normalized: - evidence = ( - "The authorization script emitted an explicit [FAIL] " - "result." - ) + evidence = "The authorization script emitted an explicit [FAIL] result." else: evidence = ( f"The authorization script exited with code " @@ -696,8 +738,7 @@ def _apply_connection_binding_stage( environment_url = plan["scope"]["dataverseUrl"] targets = plan["connectionBindings"] bindings = { - logical_name: target["connectionId"] - for logical_name, target in targets.items() + logical_name: target["connectionId"] for logical_name, target in targets.items() } references = _runtime_references( environment_url, @@ -736,12 +777,10 @@ def _apply_connection_binding_stage( ] if wrong: raise WorkdayConnectRuntimeError( - "Connection-reference verification failed: " - + ", ".join(sorted(wrong)) + "Connection-reference verification failed: " + ", ".join(sorted(wrong)) ) return { - logical_name: target["displayName"] - for logical_name, target in targets.items() + logical_name: target["displayName"] for logical_name, target in targets.items() } @@ -797,8 +836,7 @@ def _apply_flow_activation_stage( ] if inactive: raise WorkdayConnectRuntimeError( - "Runtime flow verification failed: " - + ", ".join(sorted(inactive)) + "Runtime flow verification failed: " + ", ".join(sorted(inactive)) ) return flow_names @@ -809,9 +847,7 @@ def apply_runtime_plan( token: str, query: Callable[..., list[dict[str, Any]]] = query_all, updater: Callable[..., bool] = update_record, - authorization_runner: Callable[ - ..., subprocess.CompletedProcess - ] = _default_runner, + authorization_runner: Callable[..., subprocess.CompletedProcess] = _default_runner, stage_recorder: Callable[[str, Mapping[str, Any]], Any] | None = None, ) -> dict[str, Any]: """Apply and verify ordered idempotent stages with one Dataverse token.""" diff --git a/solutions/ess-maker-skills/scripts/workday_connect_store.py b/solutions/ess-maker-skills/scripts/workday_connect_store.py index 7ab9a70f..a3a22767 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_store.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_store.py @@ -20,9 +20,13 @@ PHASE_BY_ID, PHASE_DEFINITIONS, PHASE_REQUIRED_ACTIONS, + STATE_SCHEMA_VERSION, + TENANT_FOUNDATION_REQUIRED_ENDPOINT_KEYS, + TENANT_FOUNDATION_REQUIRED_IDENTIFIER_KEYS, PhaseStatus, WorkdayConnectModelError, default_state, + next_phase_summary, plan_hash, progress_text, utc_now, @@ -43,6 +47,31 @@ "vertical", } _ENTRA_SCOPE_KEYS = {"entraTenantId", "workdayTenant"} +_ENTRA_IDENTIFIER_KEYS = { + "entraAppId", + "entraAppObjectId", + "entraServicePrincipalId", + "entraAppIdUri", + "workdaySamlEntityId", + "scopeGuid", + "signingCertificate", +} +_WORKDAY_IDENTIFIER_KEYS = {"oauthClientId"} +_FOUNDATION_SCOPE_KEYS = ("entraTenantId", "workdayTenant") +_FOUNDATION_ENTRA_IDENTIFIER_KEYS = ( + "entraAppId", + "entraAppObjectId", + "entraServicePrincipalId", + "entraAppIdUri", + "workdaySamlEntityId", + "scopeGuid", + "signingCertificate", +) +_OPERATOR_PHASES = { + "powerPlatformMaker": "preflight", + "entraAdmin": "entra", + "workdayAdmin": "workday-admin", +} class WorkdayConnectStoreError(RuntimeError): @@ -141,9 +170,7 @@ def _legacy_phase_status( rows: tuple[str, ...], ) -> str: values = [ - setup_status.get(row) - for row in rows - if isinstance(setup_status.get(row), dict) + setup_status.get(row) for row in rows if isinstance(setup_status.get(row), dict) ] statuses = {str(value.get("state") or "pending") for value in values} if values and len(values) == len(rows) and statuses == {"done"}: @@ -260,6 +287,8 @@ def migrate_legacy_state(document: Mapping[str, Any]) -> dict[str, Any]: phase_state["evidence"] = _legacy_evidence(setup_status, rows) if phase_state["status"] == PhaseStatus.COMPLETE.value: for action in PHASE_REQUIRED_ACTIONS[phase.value]: + if phase.value == "runtime" and action == "workday-topics-activated": + continue if action not in phase_state["completedActions"]: phase_state["completedActions"].append(action) phase_state["evidence"].append( @@ -273,11 +302,21 @@ def migrate_legacy_state(document: Mapping[str, Any]) -> dict[str, Any]: if phase_state["status"] != PhaseStatus.PENDING.value: phase_state["updatedAt"] = utc_now() + runtime = state["phases"]["runtime"] + if ( + runtime["status"] == PhaseStatus.COMPLETE.value + and "workday-topics-activated" not in runtime["completedActions"] + ): + runtime["status"] = PhaseStatus.ACTIVE.value + runtime["updatedAt"] = utc_now() + _reset_phase(state["phases"]["employee-validation"]) + if all( phase["status"] == PhaseStatus.COMPLETE.value for phase in state["phases"].values() ): state["status"] = "ready" + state["tenantFoundation"] = _tenant_foundation_from_state(state) state["migration"] = { "source": ( "workday-da-state-v1" @@ -304,6 +343,98 @@ def _reset_phase(phase: dict[str, Any]) -> None: ) +def _tenant_foundation_from_state( + state: Mapping[str, Any], +) -> dict[str, Any] | None: + phases = state.get("phases") or {} + entra = phases.get("entra") or {} + workday = phases.get("workday-admin") or {} + if ( + entra.get("status") != PhaseStatus.COMPLETE.value + or workday.get("status") != PhaseStatus.COMPLETE.value + ): + return None + scope = state.get("scope") or {} + if any(not str(scope.get(key) or "").strip() for key in _FOUNDATION_SCOPE_KEYS): + return None + identifiers = state.get("identifiers") or {} + endpoints = state.get("endpoints") or {} + if any( + identifiers.get(key) is None or identifiers.get(key) == "" + for key in TENANT_FOUNDATION_REQUIRED_IDENTIFIER_KEYS + ): + return None + if any( + not str(endpoints.get(key) or "").strip() + for key in TENANT_FOUNDATION_REQUIRED_ENDPOINT_KEYS + ): + return None + return { + "scope": {key: copy.deepcopy(scope[key]) for key in _FOUNDATION_SCOPE_KEYS}, + "identifiers": copy.deepcopy(dict(identifiers)), + "endpoints": copy.deepcopy(dict(endpoints)), + "phases": { + phase_id: { + "completedActions": copy.deepcopy(phases[phase_id]["completedActions"]), + "evidence": copy.deepcopy(phases[phase_id]["evidence"]), + } + for phase_id in ("entra", "workday-admin") + }, + "capturedAt": utc_now(), + } + + +def _normalized_foundation_value(value: Any) -> Any: + if isinstance(value, str): + return value.strip().casefold() + if isinstance(value, Mapping): + return { + str(key): _normalized_foundation_value(item) + for key, item in sorted(value.items()) + } + if isinstance(value, list): + return [_normalized_foundation_value(item) for item in value] + return value + + +def _certificate_identity(value: Any) -> tuple[str, str, str] | None: + if not isinstance(value, Mapping): + return None + thumbprint = str(value.get("thumbprint") or "").replace(" ", "").strip().casefold() + valid_from = str(value.get("validFrom") or "").strip()[:10] + valid_to = str(value.get("validTo") or "").strip()[:10] + if not thumbprint or not valid_from or not valid_to: + return None + return thumbprint, valid_from, valid_to + + +def _foundation_matches_current_entra( + state: Mapping[str, Any], + foundation: Mapping[str, Any], +) -> bool: + scope = state.get("scope") or {} + foundation_scope = foundation.get("scope") or {} + for key in _FOUNDATION_SCOPE_KEYS: + if _normalized_foundation_value(scope.get(key)) != ( + _normalized_foundation_value(foundation_scope.get(key)) + ): + return False + identifiers = state.get("identifiers") or {} + foundation_identifiers = foundation.get("identifiers") or {} + for key in _FOUNDATION_ENTRA_IDENTIFIER_KEYS: + if key == "signingCertificate": + if _certificate_identity(identifiers.get(key)) != ( + _certificate_identity(foundation_identifiers.get(key)) + ): + return False + continue + if _normalized_foundation_value(identifiers.get(key)) != ( + _normalized_foundation_value(foundation_identifiers.get(key)) + ): + return False + return True + + def _invalidate_from_phase( state: dict[str, Any], phase_id: str, @@ -324,9 +455,44 @@ def _scope_invalidation_phase(changed_keys: set[str]) -> str: return "preflight" -def upgrade_v2_state(document: Mapping[str, Any]) -> dict[str, Any]: +def _section_invalidation_phase( + section: str, + changed_keys: set[str], +) -> str | None: + if not changed_keys: + return None + if section == "scope": + return _scope_invalidation_phase(changed_keys) + if section == "identifiers": + if changed_keys & _ENTRA_IDENTIFIER_KEYS: + return "entra" + if changed_keys <= _WORKDAY_IDENTIFIER_KEYS: + return "workday-admin" + return "entra" + if section == "endpoints": + return "workday-admin" + if section == "operators": + phases = {_OPERATOR_PHASES.get(key, "preflight") for key in changed_keys} + return min( + phases, + key=lambda phase_id: next( + index + for index, definition in enumerate(PHASE_DEFINITIONS) + if definition.identifier.value == phase_id + ), + ) + return None + + +def _upgrade_structured_state( + document: Mapping[str, Any], + *, + source_version: int, +) -> dict[str, Any]: state = copy.deepcopy(dict(document)) - state["schemaVersion"] = 3 + state["schemaVersion"] = STATE_SCHEMA_VERSION + if "tenantFoundation" not in state: + state["tenantFoundation"] = _tenant_foundation_from_state(state) first_incomplete: str | None = None for definition in PHASE_DEFINITIONS: phase_id = definition.identifier.value @@ -362,13 +528,25 @@ def upgrade_v2_state(document: Mapping[str, Any]) -> dict[str, Any]: else "in-progress" ) state["migration"] = { - "source": "workday-connect-state-v2", + "source": f"workday-connect-state-v{source_version}", "migratedAt": utc_now(), } state["updatedAt"] = utc_now() return validate_state(state) +def upgrade_v2_state(document: Mapping[str, Any]) -> dict[str, Any]: + return _upgrade_structured_state(document, source_version=2) + + +def upgrade_v3_state(document: Mapping[str, Any]) -> dict[str, Any]: + return _upgrade_structured_state(document, source_version=3) + + +def upgrade_v4_state(document: Mapping[str, Any]) -> dict[str, Any]: + return _upgrade_structured_state(document, source_version=4) + + class WorkdayConnectStore: """Own the single durable Workday connect state file.""" @@ -381,7 +559,7 @@ def __init__( self.workspace_root = workspace_root.resolve() self.config_path = self.workspace_root / CONFIG_PATH self.lock_path = self.config_path.with_name("state.lock") - self.backup_path = self.config_path.with_name("config.pre-v3.json") + self.backup_path = self.config_path.with_name("config.pre-v5.json") self.lock_timeout = lock_timeout def initialize(self) -> dict[str, Any]: @@ -391,37 +569,48 @@ def initialize(self) -> dict[str, Any]: state = default_state() _atomic_write_json(self.config_path, state) return state - if existing.get("schemaVersion") == 3: + if existing.get("schemaVersion") == STATE_SCHEMA_VERSION: return validate_state(existing) if not self.backup_path.exists(): self.backup_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(self.config_path, self.backup_path) - state = ( - upgrade_v2_state(existing) - if existing.get("schemaVersion") == 2 - else migrate_legacy_state(existing) - ) + source_version = existing.get("schemaVersion") + if source_version == 4: + state = upgrade_v4_state(existing) + elif source_version == 3: + state = upgrade_v3_state(existing) + elif source_version == 2: + state = upgrade_v2_state(existing) + else: + state = migrate_legacy_state(existing) _atomic_write_json(self.config_path, state) return state def load(self) -> dict[str, Any]: if not self.config_path.exists(): return self.initialize() - return validate_state(_read_json(self.config_path)) + current = _read_json(self.config_path) + if current.get("schemaVersion") != STATE_SCHEMA_VERSION: + return self.initialize() + return validate_state(current) def _mutate(self, mutation) -> dict[str, Any]: with _file_lock(self.lock_path, self.lock_timeout): current = _read_json(self.config_path) if not current: current = default_state() - elif current.get("schemaVersion") != 3: + elif current.get("schemaVersion") != STATE_SCHEMA_VERSION: if not self.backup_path.exists(): shutil.copy2(self.config_path, self.backup_path) - current = ( - upgrade_v2_state(current) - if current.get("schemaVersion") == 2 - else migrate_legacy_state(current) - ) + source_version = current.get("schemaVersion") + if source_version == 4: + current = upgrade_v4_state(current) + elif source_version == 3: + current = upgrade_v3_state(current) + elif source_version == 2: + current = upgrade_v2_state(current) + else: + current = migrate_legacy_state(current) state = copy.deepcopy(validate_state(current)) mutation(state) state["status"] = ( @@ -453,19 +642,82 @@ def merge_section( def mutation(state: dict[str, Any]) -> None: changed_keys = { - key - for key, value in values.items() - if state[section].get(key) != value + key for key, value in values.items() if state[section].get(key) != value } - if section == "scope" and changed_keys: - _invalidate_from_phase( - state, - _scope_invalidation_phase(changed_keys), - ) + invalidation_phase = _section_invalidation_phase( + section, + changed_keys, + ) + if invalidation_phase: + _invalidate_from_phase(state, invalidation_phase) state[section].update(dict(values)) return self._mutate(mutation) + def capture_tenant_foundation(self) -> dict[str, Any]: + """Persist reusable Entra and Workday tenant configuration evidence.""" + + def mutation(state: dict[str, Any]) -> None: + foundation = _tenant_foundation_from_state(state) + if foundation is None: + raise WorkdayConnectStoreError( + "Complete Entra and Workday administrator verification " + "before capturing reusable tenant configuration." + ) + state["tenantFoundation"] = foundation + + return self._mutate(mutation) + + def restore_workday_foundation(self) -> tuple[dict[str, Any], bool]: + """Reuse Workday administrator evidence after a fresh Entra reread.""" + reused = False + + def mutation(state: dict[str, Any]) -> None: + nonlocal reused + foundation = state.get("tenantFoundation") + if not isinstance(foundation, Mapping): + return + if ( + state["phases"]["preflight"]["status"] != PhaseStatus.COMPLETE.value + or state["phases"]["entra"]["status"] != PhaseStatus.COMPLETE.value + ): + return + if not _foundation_matches_current_entra(state, foundation): + return + foundation_identifiers = foundation.get("identifiers") or {} + for key in _WORKDAY_IDENTIFIER_KEYS: + if foundation_identifiers.get(key) is not None: + state["identifiers"][key] = copy.deepcopy( + foundation_identifiers[key] + ) + state["endpoints"].update( + copy.deepcopy(dict(foundation.get("endpoints") or {})) + ) + snapshot = (foundation.get("phases") or {}).get("workday-admin") + if not isinstance(snapshot, Mapping): + return + phase = state["phases"]["workday-admin"] + _reset_phase(phase) + phase["status"] = PhaseStatus.COMPLETE.value + phase["completedActions"] = copy.deepcopy( + list(snapshot.get("completedActions") or []) + ) + phase["evidence"] = copy.deepcopy(list(snapshot.get("evidence") or [])) + phase["evidence"].append( + { + "action": "tenant-foundation-reused", + "outcome": "verified", + "provenance": "stored-tenant-foundation", + "foundationCapturedAt": foundation["capturedAt"], + "capturedAt": utc_now(), + } + ) + phase["updatedAt"] = utc_now() + reused = True + + state = self._mutate(mutation) + return state, reused + def set_phase_status( self, phase_id: str, @@ -476,9 +728,7 @@ def set_phase_status( if phase_id not in PHASE_BY_ID: raise WorkdayConnectStoreError(f"Unknown Workday phase: {phase_id}.") if status not in {value.value for value in PhaseStatus}: - raise WorkdayConnectStoreError( - f"Unknown Workday phase status: {status}." - ) + raise WorkdayConnectStoreError(f"Unknown Workday phase status: {status}.") def mutation(state: dict[str, Any]) -> None: definition = PHASE_BY_ID[phase_id] @@ -494,12 +744,9 @@ def mutation(state: dict[str, Any]) -> None: required = PHASE_REQUIRED_ACTIONS[phase_id] completed = set(phase["completedActions"]) evidence_actions = { - str(record.get("action") or "") - for record in phase["evidence"] + str(record.get("action") or "") for record in phase["evidence"] } - missing = sorted( - (required - completed) | (required - evidence_actions) - ) + missing = sorted((required - completed) | (required - evidence_actions)) if missing: raise WorkdayConnectStoreError( f"Phase '{phase_id}' is missing required verified " @@ -526,6 +773,16 @@ def complete_action( ) def mutation(state: dict[str, Any]) -> None: + prerequisite = PHASE_BY_ID[phase_id].prerequisite + if ( + prerequisite is not None + and state["phases"][prerequisite.value]["status"] + != PhaseStatus.COMPLETE.value + ): + raise WorkdayConnectStoreError( + f"Complete '{prerequisite.value}' before recording " + f"'{phase_id}' evidence." + ) phase = state["phases"][phase_id] if action not in phase["completedActions"]: phase["completedActions"].append(action) @@ -542,7 +799,10 @@ def mutation(state: dict[str, Any]) -> None: "capturedAt": utc_now(), } ) - if phase["status"] == PhaseStatus.PENDING.value: + if phase["status"] in { + PhaseStatus.PENDING.value, + PhaseStatus.BLOCKED.value, + }: phase["status"] = PhaseStatus.ACTIVE.value phase["blocker"] = None phase["updatedAt"] = utc_now() @@ -566,6 +826,15 @@ def approve_plan( approved_hash = plan_hash(plan) def mutation(state: dict[str, Any]) -> None: + prerequisite = PHASE_BY_ID[phase_id].prerequisite + if ( + prerequisite is not None + and state["phases"][prerequisite.value]["status"] + != PhaseStatus.COMPLETE.value + ): + raise WorkdayConnectStoreError( + f"Complete '{prerequisite.value}' before approving '{phase_id}'." + ) phase = state["phases"][phase_id] phase["approvedPlan"] = dict(plan) phase["approvedPlanHash"] = approved_hash @@ -607,10 +876,7 @@ def status(self) -> dict[str, Any]: "status": phase["status"], } ) - if ( - next_phase is None - and phase["status"] != PhaseStatus.COMPLETE.value - ): + if next_phase is None and phase["status"] != PhaseStatus.COMPLETE.value: next_phase = definition.identifier.value return { "schemaVersion": 1, @@ -618,6 +884,7 @@ def status(self) -> dict[str, Any]: "status": state["status"], "phases": phases, "nextPhaseId": next_phase, + "nextPhaseSummary": next_phase_summary(state), "progressText": progress_text(state), "blocker": ( state["phases"][next_phase]["blocker"] diff --git a/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md index 928130a3..ad0dc7b0 100644 --- a/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md +++ b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md @@ -57,11 +57,12 @@ reads a contract, runs the checkpoints it names, and renders results. | `mutates` | boolean | no (default `false`) | `true` if completing this phase changes the live agent (edits a file, pushes a change). Drives the role gate below. | | `requiredRole` | string | required when `mutates` is `true` | Human-readable role name passed to `permission-gate.md` as `REQUIRED_ROLE` before the phase's action runs. | | `gateMode` | string | no (default `"attested"`) | `"programmatic"` or `"attested"` — passed to `permission-gate.md` as `GATE_MODE`. Use `"programmatic"` only when `roleQuery` names a real, working query. | -| `roleQuery` | array of strings | required when `gateMode` is `"programmatic"` | The exact command(s) `permission-gate.md` runs as `ROLE_QUERY`, and the role name(s) that count as a pass. Copy an existing, already-proven query rather than inventing a new one (e.g. the Dataverse security-role check `src/skills/setup/workday/install-workday-extension-pack.md` section P5.0 uses for "Environment Maker"). | +| `roleQuery` | array of strings | required when `gateMode` is `"programmatic"` | The exact command(s) `permission-gate.md` runs as `ROLE_QUERY`. Reuse a checked-in, tested provider query rather than inventing an unverified permission check. | | `roleQueryPassNames` | array of strings | required when `gateMode` is `"programmatic"` | Role names in the query's result that count as holding `requiredRole` (include the role itself and any role that supersedes it, e.g. `System Administrator`). | | `actionDoc` | string (path) | required when `mutates` is `true` | Path to a provider-owned markdown fragment containing the bespoke steps needed to make the phase's checkpoint(s) pass (e.g. editing a topic file and pushing it). The runner reads and follows this file; it contains its own Message blocks and is written by the provider, not the runner. | | `rollbackLabel` | string | no | Passed to `scripts/checkpoint.py` before a mutating action runs, so the operator has a named restore point. | -| `rollbackPushGlob` | string | no | Required with `rollbackLabel` when the action pushes a local file. The runner restores only this path from the named checkpoint and uses the same exact `push.py --only` glob when publishing the rollback. | +| `rollbackPushGlob` | string | no | Static path used when the action always pushes the same local file. The runner restores only this path from the named checkpoint and uses the same exact `push.py --only` value when publishing the rollback. | +| `rollbackPushGlobFromAction` | boolean | no | Set to `true` when the action resolves the pushed path dynamically. The action must return `ACTION_ROLLBACK_PUSH_GLOB`; the runner validates and persists it before checkpoint verification. Do not combine this with `rollbackPushGlob`. | ### Evolving a phase's checkpoint list @@ -134,6 +135,8 @@ acknowledgement is complete. Partial or unavailable evidence leaves the phase - `phases.{id}.actionApplied` — mutating phases only; `true` once the action doc has been followed at least once (so a resume doesn't re-apply an idempotent-unsafe action; it re-verifies instead). +- `phases.{id}.rollbackPushGlob` — exact action-resolved local path persisted + when the contract uses `rollbackPushGlobFromAction: true`. - `phases.{id}.checkpointAcknowledgements` — map keyed by checkpoint ID for accepted `Manual`/`Warning` results. Each value records the acknowledged status and UTC `acknowledgedAt`. A resume may reuse the acknowledgement only diff --git a/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md index 913f13e2..4c23943d 100644 --- a/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md +++ b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md @@ -176,7 +176,15 @@ its own Message blocks and tool calls and must return an explicit `ACTION_RESULT`: - **`"applied"`** — the mutation was observed to complete successfully. Set - `phases.{id}.actionApplied = true` and write the state file immediately. + `phases.{id}.actionApplied = true`. If the phase has + `rollbackPushGlobFromAction: true`, require the action to return + `ACTION_ROLLBACK_PUSH_GLOB` as one normalized relative path beneath + `topics/`, with no `..` segment and no wildcard characters; persist it as + `phases.{id}.rollbackPushGlob`. If the path is missing or unsafe, set the + phase to `blocked`, write `actionApplied = true` immediately, and stop for + manual attention; the live mutation may already have happened and must not + be repeated without a known exact rollback scope. With a valid path, write + the state file immediately. - **`"cancelled"`** — the user declined before mutation. Keep `actionApplied = false`, leave the phase `in-progress`, write the state file, and stop. Do not run the phase checkpoints. @@ -218,13 +226,16 @@ Aggregate the phase's outcome using the phase's `completionStatuses` - **Any checkpoint `Failed`/`Error`:** set `phases.{id}.status = "blocked"`, record `checkpointResults`. Write the state file. - If this phase has `actionApplied: true`, `rollbackLabel`, and - `rollbackPushGlob`, restore and publish the exact pre-action state: + If this phase has `actionApplied: true` and `rollbackLabel`, resolve + `{ROLLBACK_PUSH_GLOB}` from `phases.{id}.rollbackPushGlob` when + `rollbackPushGlobFromAction: true`; otherwise use the contract's + `rollbackPushGlob`. Stop with manual attention if the required value is + missing. Restore and publish the exact pre-action state: ``` - python scripts/checkpoint.py --revert-reason "{rollbackLabel}" --only "{rollbackPushGlob}" - python scripts/push.py --only "{rollbackPushGlob}" --dry-run - python scripts/push.py --only "{rollbackPushGlob}" --yes + python scripts/checkpoint.py --revert-reason "{rollbackLabel}" --only "{ROLLBACK_PUSH_GLOB}" + python scripts/push.py --only "{ROLLBACK_PUSH_GLOB}" --dry-run + python scripts/push.py --only "{ROLLBACK_PUSH_GLOB}" --yes ``` If all three commands succeed, set `actionApplied = false`, keep the phase diff --git a/solutions/ess-maker-skills/src/skills/connect/step1.md b/solutions/ess-maker-skills/src/skills/connect/step1.md index 3b90d9c7..4d13c005 100644 --- a/solutions/ess-maker-skills/src/skills/connect/step1.md +++ b/solutions/ess-maker-skills/src/skills/connect/step1.md @@ -11,11 +11,15 @@ Build a list of connected integrations (if any): - **ServiceNow** — connected if `.local/connect/servicenow/steps.md` exists and all items are checked. -- **Workday** — connected only if - `.local/connect/workday/agents/{active-agent-slug}/lifecycle.json` exists, - its `agentSlug` exactly matches the active agent, and every phase is `done`. - Shared provider setup state is not agent connection state and must not make - a sibling or newly selected agent appear connected. +- **Workday** — connected if either: + - `.local/connect/workday/agents/{active-agent-slug}/lifecycle.json` exists, + its `agentSlug` exactly matches the active agent, and every phase is + `done`; or + - `.local/connect/workday-da/config.json` has `schemaVersion: 5`, + `status: "ready"`, and `scope.agent.slug` and `scope.agent.botId` exactly + match the active native agent. + Shared provider state without an exact active-agent match must not make a + sibling or newly selected agent appear connected. --- diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/actions/activate-workday-topics.md b/solutions/ess-maker-skills/src/skills/connect/workday/actions/activate-workday-topics.md index 1203fd2c..d2d96341 100644 --- a/solutions/ess-maker-skills/src/skills/connect/workday/actions/activate-workday-topics.md +++ b/solutions/ess-maker-skills/src/skills/connect/workday/actions/activate-workday-topics.md @@ -21,9 +21,12 @@ Select every entry that satisfies all of these conditions: - the mapped file exists beneath the selected agent directory. Reject unsafe paths, duplicate component IDs, missing schema names, or an empty -result. Do not use a handwritten filename list. For the current reviewed ESS -HR template this resolves all 21 Workday dialog topics from `agent.yml`, -including business topics and supporting system topics. +result. `push.py --activate` independently enforces the same exact mapped set +and rejects omitted Workday topics or any selected non-Workday dialog; these +instructions are not the only safety boundary. Do not use a handwritten +filename list. For the current reviewed ESS HR template this resolves all 21 +Workday dialog topics from `agent.yml`, including business topics and +supporting system topics. Sort the mapped paths and build `{WORKDAY_TOPIC_ARGS}` as one exact `--only "{path}"` argument per selected topic. @@ -45,8 +48,8 @@ differs or any non-Workday topic appears. **Message:** The Workday connections and shared parameters are ready. I found -**{WORKDAY_TOPIC_COUNT}** Workday topics from the installed agent definition. -I can now enable that exact set without changing their dialog content. +**{WORKDAY_TOPIC_COUNT}** Workday topics included with this agent. I can now +enable all of them without changing their configured behavior. **End message.** @@ -81,8 +84,23 @@ python scripts/push.py {WORKDAY_TOPIC_ARGS} --activate --yes --preferred-usernam `push.py` sends a full `BotComponentUpdate` for each selected topic through the native MinimalBot components endpoint, preserves the dialog body, sets both -`state` and `status` to `Active`, and rereads every component. Continue only -when the command reports that all `{WORKDAY_TOPIC_COUNT}` topics were verified. +`state` and `status` to `Active`, and rereads every component. Continue when the +command reports that all `{WORKDAY_TOPIC_COUNT}` topics were verified Active. +The command may also report dependency diagnostics such as +`CloudFlow NotFound`; preserve those diagnostics for the separate native-flow +registration check, but do not treat them as an activation failure. + +Record the live activation evidence: + +```powershell +python scripts/workday_connect.py record-topic-activation +``` + +This controller command resolves the same complete mapped Workday topic set, +authenticates as the recorded maker, and rereads `state` and `status` for every +topic. It accepts no manual boolean evidence. If dependency diagnostics remain, +the command records activation as complete and leaves the runtime phase blocked +on native flow registration. Set `ACTION_RESULT = "applied"` and `WORKDAY_TOPICS_ACTIVATED = true`. On any error or count mismatch, stop and diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md b/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md index 1361aab7..084695a0 100644 --- a/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md +++ b/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md @@ -12,19 +12,16 @@ Every **Message** block is the exact text to show the user. Copy it verbatim. ## A.0 — Role gate (Environment Maker) The lifecycle runner already applied `permission-gate.md` before reading this -file — see `src/skills/connect/shared/lifecycle-runner.md` section L.4a, which -uses `GATE_MODE = "programmatic"` with the same Dataverse security-role query -`src/skills/setup/workday/install-workday-extension-pack.md` section P5.0 -uses for this exact role. This file starts from a passed gate; it does not -re-check it. +file using the exact programmatic Dataverse security-role query and accepted +role names declared for this phase in `contract.json`. This file starts from a +passed gate; it does not re-check it. ## A.1 — Explain what's about to change **Message:** -I'll wire your agent's **User Context** topic to call Workday on every -conversation. Without this, Workday topics respond with "This feature isn't -available yet." +I'll connect your agent's **User Context** setup to Workday so it can identify +the signed-in employee when a Workday request begins. **End message.** @@ -122,7 +119,9 @@ If it fails, stop and report the failure; do not return an applied result. ## A.5 — Return -Return `ACTION_RESULT` to the lifecycle runner. It re-runs `WD-REST-002` for -`AGENT_SLUG` only after an `"applied"` result and decides whether to advance -or use the named restore point — this file does not re-run the checkpoint -itself. +Return `ACTION_RESULT` to the lifecycle runner. With an `"applied"` result, +also return the exact `{USER_CONTEXT_TOPIC_PATH}` as +`ACTION_ROLLBACK_PUSH_GLOB`. Do not return a wildcard or directory. The runner +re-runs `WD-REST-002` for `AGENT_SLUG` only after an `"applied"` result and +decides whether to advance or use the named restore point — this file does not +re-run the checkpoint itself. diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/contract.json b/solutions/ess-maker-skills/src/skills/connect/workday/contract.json index a5b92924..cede46f8 100644 --- a/solutions/ess-maker-skills/src/skills/connect/workday/contract.json +++ b/solutions/ess-maker-skills/src/skills/connect/workday/contract.json @@ -30,7 +30,7 @@ "roleQueryPassNames": ["Environment Maker", "System Customizer", "System Administrator"], "actionDoc": "src/skills/connect/workday/actions/wire-user-context-redirect.md", "rollbackLabel": "Add User Context redirect to Workday", - "rollbackPushGlob": "topics/user-context-setup.mcs.yml" + "rollbackPushGlobFromAction": true }, { "id": "validation", diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md index 07d5391e..cd8ca798 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md @@ -1,10 +1,12 @@ # Connect Workday to the ESS HR agent -This skill is a thin conversational client for -`scripts/workday_connect.py`. The controller and -`.local/connect/workday-da/config.json` own lifecycle state. Do not create, -copy, update, or infer status from a Markdown checklist. +Guide the customer through one resumable Workday connection lifecycle. Use +`scripts/workday_connect.py` and +`.local/connect/workday-da/config.json` internally; do not create, copy, +update, or infer status from a Markdown checklist. +Use `shared/config-schema.md` as the internal state and migration reference; +it is not a customer-executed phase and must not be shown as an extra step. ## Safety contract @@ -26,17 +28,39 @@ copy, update, or infer status from a Markdown checklist. - Never diagnose a permission problem from a guess. Show the API, CLI, or checked-in script evidence that produced the diagnosis. +## Customer-facing language contract + +Commands, file paths, JSON payloads, state keys, plan hashes, checkpoint IDs, +schema names, component maps, API names, and implementation terms in this skill +are internal execution instructions. Never show or narrate them unless the +customer explicitly asks for technical diagnostics. + +Customer-facing messages must describe only: + +- the customer-visible target and current phase; +- who needs to perform a portal or Workday action; +- the exact portal navigation and field value needed for that action; +- whether a supported change or verification succeeded; +- the concise remediation needed when it did not. + +Translate internal outcomes into plain language. For example, say **all +Workday topics are enabled**, not that component `state` and `status` are +`Active`; say **the installed Workday agent is missing required flow +registrations**, not `CloudFlow NotFound`, `MinimalBot`, component-map, or +native-definition terminology. Never paste raw command output or internal +identifiers into a customer message. + ## Capability contract Describe each action according to who actually performs it: | Phase | What the skill can do | What remains a user or administrator action | | --- | --- | --- | -| Preflight | Verify the selected agent, environment, account, and package; install the reviewed package through PAC when needed | Complete Microsoft sign-in and choose an environment when no exact URL is known | +| Preflight | Verify the selected agent, environment, account, and supported Workday package; install the package when needed | Complete Microsoft sign-in and choose an environment when no exact URL is known | | Microsoft Entra | Discover exact applications, validate roles, generate one administrator handoff, reread Graph, and record verified evidence | Create or change the Entra application in the portal | | Workday administrator | Generate the handoff, validate returned non-secret values, derive endpoints, and record evidence | Change SAML, OAuth, API-client, certificate, or authentication-policy settings in Workday | -| Connections | Discover connected physical connections, verify agent parameter sharing, and record the maker's flow-attachment confirmation | Create connector connections, complete connector OAuth, connect flows to the agent, and enable parameter sharing in Copilot Studio | -| Runtime | After approval, bind reviewed solution connection references, activate reviewed package flows, configure delegated authorization, redirect an empty User Context scaffold, and activate the complete mapped Workday topic set after connection sharing | Resolve custom topic content or a package without a reviewed runtime catalog | +| Connections | Record the reviewed physical-connection readiness evidence | Create connector connections and complete connector OAuth | +| Runtime | After approval, bind the Workday connections, activate the package flows, configure required runtime permissions, connect employee context routing, enable every Workday topic included with the agent, and verify the result | Connect flows to the agent and enable parameter sharing in Copilot Studio when those settings require maker interaction | | Employee validation | Record safe validation evidence and retain the current blocker | Publish the agent, sign in as an employee, and run the real employee scenario | Never say "I changed," "I configured," "I enabled," or "I updated" for a @@ -44,25 +68,76 @@ manual action. Say what the administrator or maker must do, then say what the skill can verify or record afterward. Claim an automated change only after its command succeeded and the target was reread. +## Tenant foundation and deployment scope + +Treat the Entra and Workday configuration as a reusable tenant foundation. +Treat package installation, physical connections, runtime flow wiring, native +topics, publishing, and employee validation as environment-and-agent-specific +deployment work. + +- A different Power Platform environment, ESS HR agent, or maker account must + not by itself require the Entra or Workday administrators to repeat setup. +- When the Entra tenant, Workday tenant, and exact Entra application still + match stored foundation evidence, reread the Entra configuration. If it is + healthy, reuse the stored Workday administrator evidence and continue at + Connections. +- Involve an administrator only when foundation evidence is absent, the + tenant/application identity changed, the Entra reread finds drift, the + signing certificate changed or is unhealthy, or a later connection/runtime + test proves the stored Workday configuration no longer works. +- Never reuse foundation evidence across a different Entra tenant, Workday + tenant, SAML Service Provider ID, Entra application, or signing certificate. +- Preserve the full administrator guide even on the reuse path, but show only + the affected remediation step instead of making the user repeat healthy + configuration. + ## Start or resume +Before running status, show this readiness briefing on every invocation. A +resumed setup must still make its remaining administrator dependencies clear. + +> Here's who may be needed to connect Workday to your ESS HR agent: +> +> | Phase | Responsibility | Who is needed | +> | --- | --- | --- | +> | Preflight | Verify the ESS HR agent and environment, and install or verify the supported Workday package | Power Platform Environment Maker with package installation access | +> | Microsoft Entra | Configure the Workday enterprise application, SAML, API permission, consent, assignment, and NameID | Application Administrator or Cloud Application Administrator; a consent-capable administrator when required | +> | Workday administrator | Configure tenant SAML and certificate trust, OAuth and the API client, functional-area access, endpoints, and the employee authentication policy | Workday Administrator | +> | Connections | Create the Workday OAuthUser and Dataverse connections and complete connector sign-in | Power Platform Environment Maker | +> | Runtime configuration | Connect the installed Workday components, activate the required flows, configure runtime permissions and connection sharing, and enable all Workday topics | Power Platform Environment Maker; Dataverse System Administrator access for runtime authorization | +> | Employee validation | Publish the agent and validate a real signed-in employee scenario | Environment Maker and Workday test employee; Workday Administrator or network administrator if remediation is needed | +> +> I'll automate checks and supported changes where reliable APIs are available. +> For Workday or portal-only settings, I'll provide the responsible +> administrator with the exact steps and wait for verified evidence. The +> environment isn't ready until the signed-in Workday scenario succeeds. + Run: ```powershell python scripts/workday_connect.py status ``` -Show only the returned `progressText`, current blocker when present, and the -next phase. Do not render internal action IDs, hashes, or the full JSON state. +After the readiness briefing: + +1. Render the returned `progressText` as Markdown. It is the visible six-row + roadmap and must not be collapsed into a one-line phase list. +2. Render the returned `nextPhaseSummary` in this form: + + ```markdown + ### Next phase: {title} + + What happens in this phase: + + - {whatHappens item 1} + - {whatHappens item 2} + - {whatHappens item 3} + ``` -The six customer-facing phases are: +3. Show the current blocker afterward when one is present. -1. Preflight -2. Microsoft Entra -3. Workday administrator -4. Connections -5. Runtime configuration -6. Employee validation +Do not render internal action IDs, hashes, or the full JSON state. Do not +replace the phase explanation with only `Next phase: {title}`. Dispatch from `nextPhaseId`: diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md index b94546f8..33b766d4 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md @@ -7,45 +7,94 @@ The skill does not create physical connector connections or complete connector OAuth. The maker performs those actions; the skill discovers and verifies the result. -Ask the maker to create or confirm exactly two connected Power Platform -connections in the selected environment: +Do not begin with a yes/no question asking whether both connections are +already connected. First explain that this phase needs exactly two Power +Platform connections and run live discovery: -- Workday OAuthUser, using the Workday SAML resource URL, token URL, and - Workday OAuth client ID from controller state; -- Microsoft Dataverse, using the selected maker account. +```powershell +python scripts/workday_connect.py record-connections +``` -Explain that Workday connector OAuth is another credential store and may open -its own sign-in. Do not ask the maker to paste connection IDs. +If both required connections are already live, continue without asking the +maker to recreate or reconfirm them. -Run runtime discovery: +If the Workday connection is missing or disconnected, read the already +validated values from the Workday state and show them with these +customer-facing labels: + +- **Microsoft Entra resource URL:** the Workday SAML Service Provider ID, + `http://www.workday.com/{workdayTenant}`. Do not use the Entra application + ID URI beginning with `api://`. +- **Workday OAuth token URL:** the exact Token Endpoint copied from + **View API Client** in Workday. +- **Client ID:** the Workday OAuth client ID copied from **View API Client**, + not the Microsoft Entra application ID. + +These values were collected during the Workday administrator phase. Do not ask +the maker or administrator to provide them again. If any value is missing, +return to the affected Workday administrator step rather than guessing. + +Then give the maker this creation process: + +1. Open the Power Apps maker portal and select the exact Power Platform + environment being configured. +2. Open **Connections**, select **New connection**, and choose **Workday**. +3. Select **Microsoft Entra ID Integrated** authentication. +4. Enter the three displayed values in the matching connection fields. +5. Select **Create** and complete the Workday sign-in window. This connector + uses a separate credential store, so an additional sign-in prompt is + expected even when Microsoft or PAC authentication already succeeded. +6. Return to **Connections** and confirm that the Workday connection shows + **Connected**. + +Do not request or collect a Workday password, client secret, access token, +refresh token, or cookie. The maker completes authentication in the connector +sign-in window. + +If the Microsoft Dataverse connection is missing or disconnected: + +1. In the same environment's **Connections** page, select **New connection**. +2. Choose **Microsoft Dataverse**. +3. Create or repair the connection using the selected maker account. +4. Confirm that it shows **Connected**. + +Reuse a healthy existing connection when one already exists. Do not create +duplicates merely to satisfy the phase, and do not ask the maker to paste +connection IDs. + +After the maker creates or repairs the missing connection, verify both live +connections again: ```powershell -python scripts/workday_connect.py runtime-plan +python scripts/workday_connect.py record-connections ``` -The command uses PAC to discover connected physical connections and one shared -Dataverse session to discover the installed connection references, reviewed -flows, selected agent, and User Context V2 topics. +The command discovers connected physical connections in the selected +environment and records the result only after both required connections are +live. - If exactly one connection exists for each connector, discovery is deterministic. - If more than one exists, show safe display names and ask which connection to - use. Resolve the selected display name to its ID internally, then rerun with + use. Resolve the selected display name to its ID internally, then rerun + `record-connections` with `--workday-connection-id` and/or `--dataverse-connection-id`; never ask the maker to paste or repeat an ID. - If none exists or a connection is not connected, leave the phase waiting and show the exact missing connector. -After successful discovery of both physical connections, run: + +Do not construct or pass manual connection evidence. + +## Runtime approval and apply + +Run runtime discovery: ```powershell -python scripts/workday_connect.py record-connections --evidence-json '{...}' +python scripts/workday_connect.py runtime-plan ``` -Set the Workday and Dataverse connected booleans only from observed evidence. -This completes the physical-connections phase so the controller can activate -the reviewed flows before Copilot Studio attaches them. - -## Runtime approval and apply +This discovers the installed connection references, supported package flows, +selected agent, and employee-context topics. For a package with a reviewed runtime flow catalog, the controller performs the following writes after exact-plan approval. These are real automated changes, @@ -95,12 +144,18 @@ checkpoint, scoped dry-run, approval, and push pattern in recorded Power Platform maker as `--preferred-username` so the native push cannot silently reuse another cached account. This scoped push changes only the setup redirect; Workday topics remain inactive until connection sharing is -complete. Run `WD-REST-002` after the scoped push and require it to pass. +complete. Run `WD-REST-002` after the scoped push and require it to pass: -If a Workday system topic shows `CloudFlow ... not found`, repair the missing -agent-level flow registration in Copilot Studio. Open the broken **Call an -action** node, select the exact existing flow, preserve its current -input/output mappings, and save the topic: +```powershell +python scripts/flightcheck/cli.py --checkpoint WD-REST-002 --connect-config ".local/connect/workday-da/config.json" --agent-slug "{AGENT_SLUG}" --preferred-username "{POWER_PLATFORM_MAKER}" +``` + +If a Workday system topic shows `CloudFlow ... not found`, stop the runtime +phase. The reviewed topic IDs already match the installed Dataverse workflows, +so this diagnostic means the native agent is missing its cloud-flow definition +registration. Connection-reference binding, flow activation, delegated +authorization, connection sign-in, and parameter sharing do not create that +native definition. - **Workday System Get User Context V2** -> **ESS Workday Runtime** - **Workday System Get REST Execution** -> @@ -108,41 +163,63 @@ input/output mappings, and save the topic: - **Workday System Get CommonExecution** -> **ESS Workday Runtime References** and **ESS Workday Runtime** -Do not recreate or clone the flows. Their IDs in the reviewed topics already -match the installed Dataverse flows; reselecting them registers the missing -native flow contract. +Do not recreate, clone, reselect, or rewrite these flows as an automated +workaround. Record the missing registration as a package or native-agent import +blocker and leave runtime incomplete. The supported installation/import path +must materialize the native flow definitions before final agent binding can +pass. Topic activation itself is independent of dependency health. Then open the agent connection settings. Connect **ESS Workday Runtime REST -Execution** and **ESS Workday Runtime**. **ESS Workday Runtime References** is -`EmbeddedOnly`, so it is not expected in the user-facing connection list. For -every agent connection used by the two visible flows, enable **Allow permission -to share parameters**. This prevents each employee from receiving an -unexpected first-use connection prompt. +Execution** and any other Workday flow shown there. The reviewed native agent +contract marks **ESS Workday Runtime** and **ESS Workday Runtime References** as +`EmbeddedOnly`; embedded flows are not expected to require a maker-selected +user connection. For every Workday connection the page does expose, enable +**Allow permission to share parameters**. This prevents each employee from +receiving an unexpected first-use connection prompt. + +Internal execution note—never show this implementation detail to the customer: +`connectionType: EmbeddedOnly` is separate from the Dataverse +`delegatedauthorization` records created earlier. The former controls the +agent-facing connection contract; the latter grants the Cosmos-backed agent +principal access to the reviewed Dataverse workflows. Do not skip or scope the +authorization stage solely from `connectionType`. + +Run the existing FlightCheck and require `WD-CONN-013` to pass: + +```powershell +python scripts/flightcheck/cli.py --checkpoint WD-CONN-013 --connect-config ".local/connect/workday-da/config.json" --agent-slug "{AGENT_SLUG}" --preferred-username "{POWER_PLATFORM_MAKER}" +``` -Run the existing FlightCheck and require `WD-CONN-013` to pass. Then run +Then run internally: `src/skills/connect/workday/actions/activate-workday-topics.md`. That action derives the complete Workday dialog list from the selected agent's `.component-map.json`, previews the exact scope, and uses the native components endpoint to set both `state` and `status` to `Active` for every Workday topic. Do not activate only the two User Context setup topics. -The current -helpers do not independently read the Copilot Studio flow-to-agent attachment, -so retain the maker's explicit confirmation after the broken action nodes are -resolved and do not describe it as automatically verified. - Then run: ```powershell -python scripts/workday_connect.py record-agent-binding --evidence-json '{...}' +python scripts/workday_connect.py record-topic-activation +``` + +This command proves that every Workday topic included with the agent is +enabled. A missing required flow registration remains a separate runtime +blocker and does not change the activation result. + +After native flow registration is healthy, run: + +```powershell +python scripts/workday_connect.py record-agent-binding ``` -Set `userContextRedirectPassed` only from `WD-REST-002`, -`parameterSharingPassed` only from the FlightCheck result, and -`flowAttachmentConfirmed` only after the maker has saved the repaired action -nodes without a missing-flow diagnostic. Set `workdayTopicsActivated` only -when `activate-workday-topics.md` reports that every selected Workday topic was -reread as Active. This completes the runtime phase. +This command reruns `WD-REST-002` and `WD-CONN-013` with the recorded Workday +state, signs in to the native components endpoint as the recorded maker, +derives the complete Workday topic set from `.component-map.json`, and rereads +every mapped topic. It completes the runtime phase only when every checkpoint +passes, every Workday topic is Active, and no topic contains an error +diagnostic such as `CloudFlow NotFound`. Do not construct or pass manual +boolean evidence. If runtime discovery reports that the selected package has no reviewed flow catalog, record a manual handoff. Do not claim that connection references, diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md index c577a1b3..aa567dec 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md @@ -16,31 +16,111 @@ Show the packet once as a single ordered task list. Do not split it into repeated confirmations or rerun manual-only FlightChecks that merely repeat the same instructions. -The Workday administrator must: - -1. Identify the currently enabled SAML identity-provider row and confirm its - Service Provider ID. Stop if it belongs to another federation. -2. Upload the active Entra SAML signing certificate and compare its validity - dates with Workday. -3. Set the exact Service Provider ID to - `http://www.workday.com/{workdayTenant}`. This is not the - `api://{entraAppId}` Application ID URI. -4. Enable OAuth 2.0 Clients and SAML in Tenant Setup - Security. -5. Register the signed-in employee API client with Core Payroll, - Organizations and Roles, Staffing, Time Off and Leave, and Include Workday - Owned Scope. -6. Verify an active authentication policy allows SAML for the intended - employees without replacing existing administrator or network safeguards. +If `record-entra` reports `tenantFoundationReused: true`, do not show this +handoff and do not require the Workday administrator again. Continue at +Connections. Show only the affected Workday remediation step if a later +connection or employee test proves that the stored foundation has drifted. + +When no matching foundation can be reused, confirm that a Workday administrator +is available, then guide them through these steps in order: + +1. **Protect the existing federation.** In Workday, run **Edit Tenant Setup - + Security** and find **SAML Setup**. In **SAML Identity Providers**, locate + the enabled row whose **Used for Environments** value matches the employee + environment being connected. Before asking the administrator to interpret + its **Issuer**, present `identityProviderQuestion` from the packet as one + choice question. The domain examples are recognition clues, not proof. + + Handle the answer as follows: + + - **Microsoft Entra ID** - continue. Ask the administrator to copy the + exact **Issuer** value shown in that Workday row. Do not save the option + label as the issuer. + - **Okta**, **Ping Identity**, or **Another sign-in provider** - stop before + changing the row. Explain that the current row belongs to an existing + sign-in configuration and must not be replaced. Ask the Workday and + identity administrators to decide whether a separate Microsoft Entra row + can be added safely for this environment. + - **No enabled SAML row** - continue with the Microsoft Entra setup below. + After the new row is saved and enabled, ask the administrator to copy its + exact **Issuer** value. + - **I'm not sure** - explain that Microsoft Entra issuers commonly contain + `login.microsoftonline.com` or `sts.windows.net`, Okta issuers commonly + contain `okta.com`, and Ping issuers commonly contain `pingone.com`, + `pingidentity.com`, or an organization-specific Ping host. If the value + is still unclear, stop and ask the identity administrator rather than + guessing. + + After the supported Microsoft Entra row is identified or created, record + its exact Issuer, Service Provider ID, X.509 certificate name, and + certificate validity dates. +2. **Install the Entra signing certificate.** In Entra, open **Enterprise + applications -> the exact Workday application -> Single sign-on -> SAML + Signing Certificate** and download **Certificate (Base64)**. In Workday, run + **Create x509 Public Key**, paste that public certificate, give it a + customer-chosen recognizable name, and save it. Return to the enabled + Microsoft Entra row and select that key in its **X509 Certificate** field. + + Present `certificateSelectionQuestion` from the packet as one choice + question. Never suggest, prefill, or ask the administrator to confirm a + guessed certificate name such as “Microsoft Azure Federated SSO + Certificate.” + + Handle the answer as follows: + + - **The new certificate created from the Entra Base64 file** - continue. + Ask the administrator to copy the exact certificate name displayed by + Workday and its **Valid From** and **Valid To** dates. + - **A different existing Workday certificate** - stop. Do not replace or + reuse it until the Workday and identity administrators confirm it is the + same active Entra signing certificate. + - **No certificate is selected** - ask the administrator to select the new + Workday public key created from the Entra Base64 certificate, then return + to this question. + - **I'm not sure** - direct the administrator to the enabled Microsoft Entra + SAML row's **X509 Certificate** field. If they still cannot identify the + selected key, stop rather than guessing. + + Compare the copied Workday **Valid From** and **Valid To** values with the + active Entra certificate. Never collect the certificate body in chat. +3. **Configure tenant security.** Return to **Edit Tenant Setup - Security**. + Enable **OAuth 2.0 Clients Enabled** and **SAML**. In SAML Setup, set the + exact Service Provider ID to + `http://www.workday.com/{workdayTenant}`. Do not use + `api://{entraAppId}` in that Workday field. +4. **Register the employee API client.** Run **Register API Client**. Set + **Client Grant Type** to **SAML Bearer**. Under **Scope (Functional Areas)**, + select **Core Payroll**, **Organizations and Roles**, **Staffing**, and + **Time Off and Leave**. Set **Include Workday Owned Scope** to **Yes**, then + save. +5. **Capture non-secret connection values.** Open **View API Client** for that + client and record the OAuth client ID and token endpoint. Record the tenant’s + REST base URL ending exactly at `/ccx/api` and its SOAP service base URL. + Do not return a client secret, password, token, cookie, or certificate body. +6. **Verify employee authentication.** Open **Manage Authentication Policies** + for the employee environment. Confirm an active rule allows **SAML** for the + intended employees. Do not replace administrator safeguards, existing + network restrictions, or route this user-delegated setup through an + Integration System User rule. +7. **Confirm network readiness.** Give the REST and SOAP host names from step 5 + to the network administrator when organizational egress filtering applies. + Record either that both hosts are allowed or that no customer-managed + firewall change is required. Do not wait until final employee validation to + discover a known allowlist requirement. Collect one response form containing only: +- exact Issuer value copied from the enabled Microsoft Entra SAML row; - enabled Service Provider ID; +- exact certificate name copied from the enabled row's **X509 Certificate** + field; - certificate Valid From and Valid To dates; - Workday OAuth client ID; - OAuth token URL; - REST base URL ending at `/ccx/api`; - SOAP base URL; -- authentication-policy outcome. +- authentication-policy outcome; +- network-readiness outcome. Never collect a secret, password, token, cookie, certificate body, or private key. Pass the response once: @@ -49,8 +129,36 @@ key. Pass the response once: python scripts/workday_connect.py record-workday-admin --response-json '{...}' ``` +Use these exact outcome values: + +- `authenticationPolicyOutcome`: `existing-active-policy` or + `reviewed-policy-activated`; +- `networkReadinessOutcome`: `confirmed-hosts-allowed` or + `no-customer-firewall-change-required`. + +For example: + +```json +{ + "activeIdentityProviderIssuer": "{exact Issuer value copied from Workday}", + "enabledServiceProviderId": "http://www.workday.com/{workdayTenant}", + "certificateName": "{exact certificate name copied from Workday}", + "certificateValidFrom": "{ISO-8601 date}", + "certificateValidTo": "{ISO-8601 date}", + "oauthClientId": "{non-secret Workday OAuth client ID}", + "oauthTokenUrl": "https://{workday-host}/ccx/oauth2/{tenant}/token", + "restBaseUrl": "https://{workday-host}/ccx/api", + "soapBaseUrl": "https://{workday-host}/ccx/service/{tenant}", + "authenticationPolicyOutcome": "existing-active-policy", + "networkReadinessOutcome": "confirmed-hosts-allowed" +} +``` + The controller validates the Service Provider ID, HTTPS endpoints, and exact -REST base suffix, then records the non-secret identifiers, endpoints, and -evidence atomically. If the administrator is not available, stop here; rerun -`workday-admin-packet` later to regenerate the same handoff without losing -prior progress. +REST base suffix. It also requires the Workday certificate validity dates to +match the verified Entra signing certificate before recording the non-secret +identifiers, endpoints, and evidence atomically. It captures the completed +Entra and Workday phases as a tenant foundation that can be reused for another +environment or ESS HR agent. If the administrator is not available, stop here; +rerun +`workday-admin-packet` later without losing deployment progress. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md index 33242587..c81f8b29 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md @@ -51,22 +51,57 @@ Show the returned target, Service Provider ID, Entra Application ID URI, permissions, and administrator actions once as one handoff. Do not add a separate apply approval: the controller does not perform these portal changes. -## Administrator apply, then skill verify - -Present only these administrator actions: - -- reuse the exact app or instantiate the Workday gallery app; -- configure SAML mode and the signing certificate; -- retain both distinct identifier URIs: - - Workday SAML Service Provider ID: - `http://www.workday.com/{workdayTenant}` - - Entra Application ID URI: `api://{entraAppId}` -- expose `user_impersonation`; -- pre-authorize Workday connector app - `4e4707ca-5f53-46a6-a819-f7765446e6ff`; -- add `openid`, `profile`, and `User.Read`; -- grant administrator consent; -- configure user assignment, NameID, and SAML signing as required. +## Reuse an existing tenant foundation + +If `foundationReuse.eligible` is `true`, do not ask an administrator to repeat +the setup. Reread the exact application and service principal through Microsoft +Graph and verify the settings listed below. If every check passes, call +`record-entra`; it restores the matching Workday administrator phase from +tenant-scoped evidence and the lifecycle continues at Connections. + +If a check fails, show only the affected remediation step from the +administrator guide. Do not present the entire guide as mandatory merely +because the user selected another environment, agent, or maker account. + +If `requiresRediscovery` is `true`, ask an Entra administrator to open +**Microsoft Entra admin center -> Enterprise applications -> New application**, +find the official **Workday** gallery application, and create it in the selected +tenant. Stop after creation and repeat exact application discovery. Entra must +assign the application and service-principal IDs before later settings can be +planned safely. + +## Administrator guide for missing or changed settings + +Use the exact application returned by discovery. Never select another +application by display name alone. + +1. **Configure SAML.** Open **Enterprise applications -> the exact Workday + application -> Single sign-on -> SAML**. Set **Identifier (Entity ID)** to + `http://www.workday.com/{workdayTenant}`. Create or activate the signing + certificate required by the tenant. Under **SAML Signing Certificate -> + Edit**, set **Signing Option** to **Sign SAML response and assertion**. +2. **Keep the two identifiers distinct.** The Workday SAML Service Provider ID + is `http://www.workday.com/{workdayTenant}`. The Entra application ID URI is + `api://{entraAppId}`. Never copy one into the other field. +3. **Expose the connector scope.** Open **App registrations -> the exact + Workday application -> Expose an API**. Set the Application ID URI to + `api://{entraAppId}`, add the `user_impersonation` scope, then add authorized + client application `4e4707ca-5f53-46a6-a819-f7765446e6ff` for that scope. +4. **Add delegated permissions.** Open **App registrations -> the exact + Workday application -> API permissions -> Add a permission -> Microsoft + Graph -> Delegated permissions**. Add `openid`, `profile`, and `User.Read`, + then select **Grant admin consent** using a consent-capable administrator. +5. **Configure assignment.** Open **Enterprise applications -> the exact + Workday application -> Users and groups**. If assignment is required, + assign the intended ESS employee security group; prefer a maintained group + over individual users. +6. **Configure NameID.** Open **Enterprise applications -> the exact Workday + application -> Single sign-on -> Attributes & Claims**. Edit **Unique User + Identifier (Name ID)** so the source attribute equals the Workday User Name + used by the tenant, commonly `user.mail` or `user.userPrincipalName`. + +After each change, reread the setting where Microsoft Graph exposes it. Do not +ask for a broad “everything is done” confirmation. The administrator performs those changes in the Microsoft Entra admin center. After the administrator confirms completion, reread the application and @@ -87,11 +122,50 @@ Pass the Graph reread as: python scripts/workday_connect.py record-entra --verification-json '{...}' ``` -The JSON must contain the exact application and service-principal identity, -both identifier URIs, the `user_impersonation` scope GUID, safe certificate -metadata, and true verification flags for SAML mode, signing certificate, -connector preauthorization, delegated permissions, administrator consent, and -user assignment/NameID. The command validates and completes the phase -atomically. If evidence is incomplete, record one handoff and leave the phase -waiting. Resume by rereading available settings, not by repeating all -instructions. +The JSON must contain the Graph-authenticated `tenantId`, exact application and +service-principal identity, both identifier URIs, the `user_impersonation` +scope GUID, safe certificate metadata, and one evidence object for each check: + +```json +{ + "checks": { + "samlMode": { + "outcome": "verified", + "provenance": "microsoft-graph" + }, + "signingCertificate": { + "outcome": "verified", + "provenance": "microsoft-graph" + }, + "connectorPreauthorized": { + "outcome": "verified", + "provenance": "microsoft-graph" + }, + "graphDelegatedPermissions": { + "outcome": "verified", + "provenance": "microsoft-graph" + }, + "adminConsent": { + "outcome": "verified", + "provenance": "microsoft-graph" + }, + "userAssignment": { + "outcome": "verified", + "provenance": "microsoft-graph" + }, + "nameId": { + "outcome": "verified", + "provenance": "microsoft-graph" + }, + "samlSigningOption": { + "outcome": "confirmed", + "provenance": "administrator-attestation" + } + } +} +``` + +Use administrator attestation only for a portal-only setting that Graph cannot +read. The command rejects a different tenant and incomplete or provenance-free +evidence. Resume by rereading available settings and showing only failed +remediation, not by repeating the full guide. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md index 902a31c1..6ad800d2 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md @@ -11,17 +11,18 @@ The only writable lifecycle state is: writes, and phase transitions. Skills must use `scripts/workday_connect.py`; they must not edit this file directly or create a Markdown state mirror. -## Schema version 3 +## Schema version 5 ```json { - "schemaVersion": 3, + "schemaVersion": 5, "provider": "workday", "status": "in-progress", "scope": {}, "identifiers": {}, "endpoints": {}, "operators": {}, + "tenantFoundation": null, "phases": {}, "migration": null, "updatedAt": "UTC timestamp" @@ -33,6 +34,9 @@ they must not edit this file directly or create a Markdown state mirror. - `identifiers` contains non-secret Entra and Workday identifiers. - `endpoints` contains validated non-secret Workday endpoints. - `operators` contains safe account and tenant provenance. +- `tenantFoundation` contains reusable Entra and Workday administrator + evidence scoped to one exact Entra tenant, Workday tenant, application, + signing certificate, and endpoint set. - `phases` contains exactly the six controller phases. Each phase stores status, completed action keys, optional runtime approval, @@ -53,12 +57,22 @@ These values are independent and must never be aliases: certificate bodies, private keys, or employee data. - Persist account usernames and tenant IDs only as authentication provenance. - Controller-owned runtime mutations must carry an exact plan hash. -- A relevant scope change invalidates the affected phase and all downstream - phase state, evidence, handoffs, and approvals. +- A relevant scope, identifier, endpoint, or operator change invalidates the + owning deployment phase and downstream state, evidence, blockers, and + approvals. It does not delete a previously captured tenant foundation. +- After a deployment reset, the exact Entra application must be reread. When + that evidence still matches `tenantFoundation`, the Workday administrator + phase is restored without asking the administrator to repeat configuration. +- Tenant-foundation evidence is never reused across a different Entra tenant, + Workday tenant, application, SAML Service Provider ID, signing certificate, + or endpoint set. - A phase is complete only after the target has been reread and matching evidence exists for every compact required action. - The provider status becomes `ready` only when all six phases are complete. -Schema-v2 or legacy row-based state is backed up to `config.pre-v3.json` -before one-time migration. Legacy Markdown task files, when present, are -historical snapshots and are never rewritten. +Schema-v2, schema-v3, schema-v4, or legacy row-based state is backed up to +`config.pre-v5.json` before one-time migration. When Entra and Workday +administrator phases were already complete, migration captures their evidence +as the reusable tenant foundation. A previously complete runtime phase is +reopened when it lacks live Workday topic activation evidence. Legacy Markdown +task files, when present, are historical snapshots and are never rewritten. diff --git a/tests/flightcheck/test_cli_single_checkpoint.py b/tests/flightcheck/test_cli_single_checkpoint.py index a41e920d..ba234c61 100644 --- a/tests/flightcheck/test_cli_single_checkpoint.py +++ b/tests/flightcheck/test_cli_single_checkpoint.py @@ -246,14 +246,15 @@ def test_connect_config_only_merges_provider_owned_fields( "url": "https://foundation.example" } - def test_connect_config_flattens_v2_workday_state( - self, tmp_path: Path + @pytest.mark.parametrize("schema_version", [2, 3, 4, 5]) + def test_connect_config_flattens_workday_state( + self, tmp_path: Path, schema_version: int ) -> None: overlay = tmp_path / "provider.json" overlay.write_text( json.dumps( { - "schemaVersion": 2, + "schemaVersion": schema_version, "scope": { "workdayTenant": "acme_impl", "entraTenantId": "tenant-id", diff --git a/tests/scripts/test_flow_authorization.py b/tests/scripts/test_flow_authorization.py index 60d2343a..0e98cca7 100644 --- a/tests/scripts/test_flow_authorization.py +++ b/tests/scripts/test_flow_authorization.py @@ -57,7 +57,9 @@ def test_token_fallback_uses_the_kit_authentication_helper() -> None: assert "get_dataverse_token.py" in script assert script.count("Test-DataverseToken -Resource $Resource -Token $tok") == 2 assert "returned a token that was rejected" in script - assert "auth.authenticate(args.environment.rstrip(\"/\"))" in helper + assert "token = auth.authenticate(" in helper + assert 'args.environment.rstrip("/")' in helper + assert "preferred_username=args.preferred_username" in helper def test_candidate_tokens_are_attached_to_dataverse_requests() -> None: diff --git a/tests/scripts/test_minimalbot_detection.py b/tests/scripts/test_minimalbot_detection.py index 61b7224f..c804a065 100644 --- a/tests/scripts/test_minimalbot_detection.py +++ b/tests/scripts/test_minimalbot_detection.py @@ -268,7 +268,12 @@ def _minimalbot_config(folder: str) -> dict[str, Any]: return { "powerPlatformApiEndpoint": TEST_ENDPOINT, "environmentId": ENV_ID, - "agent": {"botId": BOT_ID, "folder": folder, "releaseLine": "da"}, + "agent": { + "botId": BOT_ID, + "folder": folder, + "releaseLine": "da", + "schemaName": "contoso", + }, } @@ -529,6 +534,41 @@ def _fake_topic_conversion(items): ] +def _write_workday_topics(root, *, changed=False): + baseline = root / ".baseline" / "topics" + working = root / "topics" + baseline.mkdir(parents=True) + working.mkdir(parents=True) + component_map = {} + for index in (1, 2): + path = f"topics/workday-{index}.mcs.yml" + baseline_body = f"kind: AdaptiveDialog\nvalue: before-{index}\n" + working_body = ( + f"kind: AdaptiveDialog\nvalue: after-{index}\n" + if changed and index == 1 + else baseline_body + ) + baseline.joinpath(f"workday-{index}.mcs.yml").write_text( + baseline_body, + encoding="utf-8", + ) + working.joinpath(f"workday-{index}.mcs.yml").write_text( + working_body, + encoding="utf-8", + ) + component_map[path] = { + "componentKind": "DialogComponent", + "componentId": f"workday-{index}", + "schemaName": f"contoso.topic.WorkdayTopic{index}", + "displayName": f"Workday Topic {index}", + } + root.joinpath(".component-map.json").write_text( + json.dumps(component_map), + encoding="utf-8", + ) + return sorted(component_map) + + def test_scoped_minimalbot_topic_dry_run_is_non_mutating( tmp_path, monkeypatch, capsys ): @@ -577,21 +617,88 @@ def test_scoped_minimalbot_topic_push_pins_account_and_updates_baseline( def test_scoped_minimalbot_topic_activation_does_not_require_content_diff( tmp_path, ): - _write_existing_topic_change(tmp_path) - topic = tmp_path / "topics" / "Setusercontext.mcs.yml" - baseline = tmp_path / ".baseline" / "topics" / "Setusercontext.mcs.yml" - baseline.write_text(topic.read_text(encoding="utf-8"), encoding="utf-8") + paths = _write_workday_topics(tmp_path) plan = push._minimalbot_topic_update_plan( str(tmp_path), - ["topics/Setusercontext.mcs.yml"], + paths, + activate_topics=True, + agent_schema="contoso", + ) + + assert len(plan) == 2 + assert all(entry["state"] == "Active" for entry in plan) + assert all(entry["status"] == "Active" for entry in plan) + assert all("requireCleanDiagnostics" not in entry for entry in plan) + assert all("dialog" not in entry for entry in plan) + + +def test_minimalbot_activation_rejects_non_workday_topic(tmp_path): + _write_existing_topic_change(tmp_path) + + with pytest.raises( + mbe.MinimalBotEvaluationError, + match="No mapped Workday dialog topics", + ): + push._minimalbot_topic_update_plan( + str(tmp_path), + ["topics/Setusercontext.mcs.yml"], + activate_topics=True, + agent_schema="contoso", + ) + + +def test_workday_topic_resolution_enforces_reviewed_ess_hr_count(tmp_path): + _write_workday_topics(tmp_path) + component_map_path = tmp_path / ".component-map.json" + component_map = json.loads(component_map_path.read_text(encoding="utf-8")) + for entry in component_map.values(): + entry["schemaName"] = entry["schemaName"].replace( + "contoso", + "gptagent_copilotforemployeeselfservicehr", + ) + component_map_path.write_text( + json.dumps(component_map), + encoding="utf-8", + ) + + with pytest.raises( + mbe.MinimalBotEvaluationError, + match="expected 21", + ): + mbe.resolve_workday_dialogs( + tmp_path, + "gptagent_copilotforemployeeselfservicehr", + ) + + +def test_minimalbot_topic_push_updates_only_pushed_baseline_paths( + tmp_path, + monkeypatch, +): + paths = _write_workday_topics(tmp_path, changed=True) + baseline_workflow = tmp_path / ".baseline" / "workflows" / "flow.json" + working_workflow = tmp_path / "workflows" / "flow.json" + baseline_workflow.parent.mkdir(parents=True) + working_workflow.parent.mkdir(parents=True) + baseline_workflow.write_text('{"state":"before"}', encoding="utf-8") + working_workflow.write_text('{"state":"unpushed"}', encoding="utf-8") + fake = _RecordingClient() + _patch_client(monkeypatch, fake) + monkeypatch.setattr(push, "yaml_to_object_models", _fake_topic_conversion) + + push._minimalbot_push( + _minimalbot_config(str(tmp_path)), + auto_yes=True, + only_globs=["*"], activate_topics=True, + preferred_username="maker@contoso.com", ) - assert len(plan) == 1 - assert plan[0]["state"] == "Active" - assert plan[0]["status"] == "Active" - assert "dialog" not in plan[0] + assert [entry["path"] for entry in fake.topic_updates] == paths + assert baseline_workflow.read_text(encoding="utf-8") == ( + '{"state":"before"}' + ) def test_minimalbot_dialog_update_uses_update_envelope_and_verifies( @@ -733,3 +840,117 @@ def request(_method, _url, *, body, operation): assert updated["dialog"] == dialog assert updated["state"] == "Active" assert updated["status"] == "Active" + + +def test_minimalbot_activation_rejects_blocking_diagnostics(monkeypatch): + client = _mb_client() + client._token = "token" + component = { + "$kind": "DialogComponent", + "id": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + "state": "Active", + "status": "Active", + "dialog": { + "$kind": "AdaptiveDialog", + "diagnostics": [ + { + "$kind": "InvalidReferenceError", + "errorCode": "NotFound", + "errorMessage": "CloudFlow not found", + } + ], + }, + } + monkeypatch.setattr( + client, + "read_components", + lambda: { + "changeToken": "token-1", + "botComponentChanges": [ + { + "$kind": "BotComponentInsert", + "component": component, + } + ], + }, + ) + + with pytest.raises( + mbe.MinimalBotEvaluationError, + match="blocking diagnostics", + ): + client.verify_dialog_components( + [ + { + "componentId": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + "state": "Active", + "status": "Active", + "requireCleanDiagnostics": True, + } + ] + ) + + +def test_minimalbot_activation_reports_diagnostics_without_rejecting( + monkeypatch, +): + client = _mb_client() + client._token = "token" + component = { + "$kind": "DialogComponent", + "id": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + "state": "Active", + "status": "Active", + "dialog": { + "$kind": "AdaptiveDialog", + "diagnostics": [ + { + "$kind": "InvalidReferenceError", + "errorCode": "NotFound", + "errorMessage": "CloudFlow not found", + } + ], + }, + } + monkeypatch.setattr( + client, + "read_components", + lambda: { + "changeToken": "token-1", + "botComponentChanges": [ + { + "$kind": "BotComponentInsert", + "component": component, + } + ], + }, + ) + + result = client.verify_dialog_components( + [ + { + "componentId": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + "state": "Active", + "status": "Active", + } + ] + ) + + assert result["verifiedComponents"] == 1 + assert result["activeComponents"] == 1 + assert result["blockingDiagnostics"] == [ + { + "path": "$.dialog.diagnostics[0]", + "kind": "InvalidReferenceError", + "errorCode": "NotFound", + "message": "CloudFlow not found", + "referenceType": "", + "referenceId": "", + "componentId": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + } + ] diff --git a/tests/scripts/test_workday_connect_agent.py b/tests/scripts/test_workday_connect_agent.py new file mode 100644 index 00000000..39243872 --- /dev/null +++ b/tests/scripts/test_workday_connect_agent.py @@ -0,0 +1,440 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for live native Workday agent completion evidence.""" + +from __future__ import annotations + +import json +from pathlib import Path +import sys +from types import SimpleNamespace + + +def _state(): + from workday_connect_model import default_state + + state = default_state() + state["scope"].update( + { + "environmentId": "environment-id", + "dataverseUrl": "https://example.crm.dynamics.com", + "agent": { + "slug": "ess-hr", + "botId": "bot-id", + "schemaName": "contoso", + }, + } + ) + state["operators"]["powerPlatformMaker"] = {"username": "maker@example.com"} + return state + + +def _write_workspace(root: Path) -> None: + agent = root / "workspace" / "agents" / "ess-hr" + topics = agent / "topics" + topics.mkdir(parents=True) + component_map = {} + for index in (1, 2): + path = f"topics/workday-{index}.mcs.yml" + topics.joinpath(f"workday-{index}.mcs.yml").write_text( + "kind: AdaptiveDialog\n", + encoding="utf-8", + ) + component_map[path] = { + "componentKind": "DialogComponent", + "componentId": f"topic-{index}", + "schemaName": f"contoso.topic.WorkdayTopic{index}", + "displayName": f"Workday Topic {index}", + } + agent.joinpath(".component-map.json").write_text( + json.dumps(component_map), + encoding="utf-8", + ) + local = root / ".local" + local.mkdir() + local.joinpath("config.json").write_text( + json.dumps( + { + "releaseLine": "da", + "environmentId": "environment-id", + "activeAgent": "ess-hr", + "powerPlatformApiEndpoint": ("https://api.test.powerplatform.com"), + "agent": { + "slug": "ess-hr", + "botId": "bot-id", + "schemaName": "contoso", + "folder": str(agent), + "releaseLine": "da", + }, + "agents": [ + { + "slug": "ess-hr", + "botId": "bot-id", + "schemaName": "contoso", + "folder": str(agent), + } + ], + } + ), + encoding="utf-8", + ) + + +class _VerifiedClient: + def __init__(self): + self.signed_in_username = "" + self.expectations = [] + + def authenticate(self, preferred_username=None): + self.signed_in_username = preferred_username + + def verify_dialog_components(self, expectations): + self.expectations = expectations + return { + "verifiedComponents": len(expectations), + "activeComponents": len(expectations), + "blockingDiagnostics": [], + } + + +def test_agent_binding_is_built_from_live_checks_and_components( + tmp_path: Path, +) -> None: + from workday_connect_agent import verify_agent_binding + + _write_workspace(tmp_path) + client = _VerifiedClient() + calls = [] + + def checkpoint(_root, _state, checkpoint_id, *, preferred_username): + calls.append((checkpoint_id, preferred_username)) + return "Passed" + + evidence = verify_agent_binding( + tmp_path, + _state(), + checkpoint_verifier=checkpoint, + client_factory=lambda _config: client, + ) + + assert calls == [ + ("WD-REST-002", "maker@example.com"), + ("WD-CONN-013", "maker@example.com"), + ] + assert evidence["workdayTopics"] == { + "expected": 2, + "verified": 2, + "active": 2, + "blockingDiagnostics": [], + } + assert all( + expectation["requireCleanDiagnostics"] is True + for expectation in client.expectations + ) + + +def test_topic_activation_skips_checkpoints_and_allows_diagnostics( + tmp_path: Path, +) -> None: + from workday_connect_agent import verify_topic_activation + + _write_workspace(tmp_path) + client = _VerifiedClient() + + def verify(expectations): + client.expectations = expectations + return { + "verifiedComponents": len(expectations), + "activeComponents": len(expectations), + "blockingDiagnostics": [ + { + "errorCode": "NotFound", + "errorMessage": "CloudFlow not found", + "referenceType": "CloudFlow", + } + ], + } + + client.verify_dialog_components = verify + + evidence = verify_topic_activation( + tmp_path, + _state(), + client_factory=lambda _config: client, + ) + + assert evidence["checkpoints"] == {} + assert evidence["workdayTopics"]["active"] == 2 + assert evidence["workdayTopics"]["blockingDiagnostics"] + assert all( + expectation["requireCleanDiagnostics"] is False + for expectation in client.expectations + ) + + +def test_agent_binding_rejects_environment_drift(tmp_path: Path) -> None: + import pytest + + from workday_connect_agent import ( + WorkdayConnectAgentError, + verify_agent_binding, + ) + + _write_workspace(tmp_path) + state = _state() + state["scope"]["environmentId"] = "other-environment" + + with pytest.raises(WorkdayConnectAgentError, match="different environments"): + verify_agent_binding( + tmp_path, + state, + checkpoint_verifier=lambda *_args, **_kwargs: "Passed", + client_factory=lambda _config: _VerifiedClient(), + ) + + +def test_controller_persists_phase_blocker( + tmp_path: Path, + monkeypatch, +) -> None: + import pytest + + import workday_connect + from workday_connect_store import WorkdayConnectStore + + store = WorkdayConnectStore(tmp_path) + store.initialize() + for action in ("verify-target", "verify-package"): + store.complete_action( + "preflight", + action, + evidence={"outcome": "verified"}, + ) + store.set_phase_status("preflight", "complete") + monkeypatch.setattr( + sys, + "argv", + [ + "workday_connect.py", + "--root", + str(tmp_path), + "set-workday-tenant", + "--tenant", + "", + ], + ) + + with pytest.raises(SystemExit) as exc: + workday_connect.main() + + assert exc.value.code == 1 + status = WorkdayConnectStore(tmp_path).status() + assert status["nextPhaseId"] == "entra" + assert status["blocker"]["operation"] == "set-workday-tenant" + + +def test_record_connections_uses_live_verification( + tmp_path: Path, + monkeypatch, +) -> None: + import workday_connect + import workday_connect_model as model + from workday_connect_store import WorkdayConnectStore + + store = WorkdayConnectStore(tmp_path) + store.initialize() + for phase_id in ("preflight", "entra", "workday-admin"): + for action in model.PHASE_REQUIRED_ACTIONS[phase_id]: + store.complete_action( + phase_id, + action, + evidence={"outcome": "verified"}, + ) + store.set_phase_status(phase_id, "complete") + monkeypatch.setattr( + workday_connect, + "verify_physical_connections", + lambda *_args, **_kwargs: { + "makerUsername": "maker@example.com", + "connections": [ + { + "connector": "shared_workdaysoap", + "displayName": "Workday", + }, + { + "connector": "shared_commondataserviceforapps", + "displayName": "Dataverse", + }, + ], + }, + ) + + result = workday_connect._record_connections( + SimpleNamespace( + evidence_json=None, + workday_connection_id=None, + dataverse_connection_id=None, + ), + store, + ) + + connections = store.load()["phases"]["connections"] + assert result["verified"] is True + assert connections["status"] == "complete" + assert connections["completedActions"] == ["physical-connections-verified"] + + +def test_record_agent_binding_completes_only_from_verifier_output( + tmp_path: Path, + monkeypatch, +) -> None: + import workday_connect + import workday_connect_model as model + from workday_connect_store import WorkdayConnectStore + + store = WorkdayConnectStore(tmp_path) + store.initialize() + store.merge_section( + "scope", + { + "environmentId": "environment-id", + "agent": { + "slug": "ess-hr", + "botId": "bot-id", + "schemaName": "contoso", + }, + }, + ) + store.merge_section( + "operators", + {"powerPlatformMaker": {"username": "maker@example.com"}}, + ) + for phase_id in ("preflight", "entra", "workday-admin", "connections"): + for action in model.PHASE_REQUIRED_ACTIONS[phase_id]: + store.complete_action( + phase_id, + action, + evidence={"outcome": "verified"}, + ) + store.set_phase_status(phase_id, "complete") + for action in ( + "connection-references-bound", + "runtime-flows-active", + "delegated-authorization-configured", + ): + store.complete_action( + "runtime", + action, + evidence={"outcome": "verified"}, + ) + monkeypatch.setattr( + workday_connect, + "verify_agent_binding", + lambda *_args, **_kwargs: { + "environmentId": "environment-id", + "botId": "bot-id", + "makerUsername": "maker@example.com", + "checkpoints": { + "WD-REST-002": "Passed", + "WD-CONN-013": "Passed", + }, + "workdayTopics": { + "expected": 21, + "verified": 21, + "active": 21, + "blockingDiagnostics": [], + }, + }, + ) + + result = workday_connect._record_agent_binding( + SimpleNamespace(), + store, + ) + + runtime = store.load()["phases"]["runtime"] + assert result["verified"] is True + assert runtime["status"] == "complete" + assert "workday-topics-activated" in runtime["completedActions"] + + +def test_record_topic_activation_preserves_flow_registration_blocker( + tmp_path: Path, + monkeypatch, +) -> None: + import workday_connect + import workday_connect_model as model + from workday_connect_store import WorkdayConnectStore + + store = WorkdayConnectStore(tmp_path) + store.initialize() + for phase_id in ( + "preflight", + "entra", + "workday-admin", + "connections", + ): + for action in model.PHASE_REQUIRED_ACTIONS[phase_id]: + store.complete_action( + phase_id, + action, + evidence={"outcome": "verified"}, + ) + store.set_phase_status(phase_id, "complete") + monkeypatch.setattr( + workday_connect, + "verify_topic_activation", + lambda *_args, **_kwargs: { + "environmentId": "environment-id", + "botId": "bot-id", + "makerUsername": "maker@example.com", + "checkpoints": {}, + "workdayTopics": { + "expected": 21, + "verified": 21, + "active": 21, + "blockingDiagnostics": [ + { + "errorCode": "NotFound", + "errorMessage": "CloudFlow not found", + "referenceType": "CloudFlow", + } + ], + }, + }, + ) + + result = workday_connect._record_topic_activation( + SimpleNamespace(), + store, + ) + + runtime = store.load()["phases"]["runtime"] + assert result["verified"] is True + assert result["flowHealth"] == "blocked" + assert "workday-topics-activated" in runtime["completedActions"] + assert runtime["status"] == "blocked" + assert runtime["blocker"]["errorType"] == "NativeFlowRegistrationBlocked" + assert "missing 1 required flow registration(s)" in (runtime["blocker"]["message"]) + + +def test_record_agent_binding_rejects_manual_boolean_evidence( + tmp_path: Path, +) -> None: + import pytest + + import workday_connect + from workday_connect_store import ( + WorkdayConnectStore, + WorkdayConnectStoreError, + ) + + with pytest.raises( + WorkdayConnectStoreError, + match="Manual agent-binding evidence is no longer accepted", + ): + workday_connect._record_agent_binding( + SimpleNamespace(evidence_json='{"workdayTopicsActivated":true}'), + WorkdayConnectStore(tmp_path), + ) diff --git a/tests/scripts/test_workday_connect_contracts.py b/tests/scripts/test_workday_connect_contracts.py index 0fafd38e..eafd0d2e 100644 --- a/tests/scripts/test_workday_connect_contracts.py +++ b/tests/scripts/test_workday_connect_contracts.py @@ -8,10 +8,7 @@ SCRIPTS = ( - Path(__file__).resolve().parents[2] - / "solutions" - / "ess-maker-skills" - / "scripts" + Path(__file__).resolve().parents[2] / "solutions" / "ess-maker-skills" / "scripts" ) sys.path.insert(0, str(SCRIPTS)) @@ -20,7 +17,6 @@ build_entra_handoff, build_workday_admin_packet, validate_agent_binding_evidence, - validate_connections_evidence, validate_employee_evidence, validate_entra_verification, validate_workday_admin_response, @@ -40,6 +36,43 @@ def _state(): return state +def _entra_checks(): + return { + "samlMode": { + "outcome": "verified", + "provenance": "microsoft-graph", + }, + "signingCertificate": { + "outcome": "verified", + "provenance": "microsoft-graph", + }, + "connectorPreauthorized": { + "outcome": "verified", + "provenance": "microsoft-graph", + }, + "graphDelegatedPermissions": { + "outcome": "verified", + "provenance": "microsoft-graph", + }, + "adminConsent": { + "outcome": "verified", + "provenance": "microsoft-graph", + }, + "userAssignment": { + "outcome": "verified", + "provenance": "microsoft-graph", + }, + "nameId": { + "outcome": "verified", + "provenance": "microsoft-graph", + }, + "samlSigningOption": { + "outcome": "confirmed", + "provenance": "administrator-attestation", + }, + } + + def test_entra_handoff_selects_only_exact_service_provider_id(): handoff = build_entra_handoff( _state(), @@ -50,18 +83,14 @@ def test_entra_handoff_selects_only_exact_service_provider_id(): "appId": "11111111-1111-1111-1111-111111111111", "objectId": "22222222-2222-2222-2222-222222222222", "servicePrincipalId": "33333333-3333-3333-3333-333333333333", - "identifierUris": [ - "http://www.workday.com/contoso_impl_old" - ], + "identifierUris": ["http://www.workday.com/contoso_impl_old"], }, { "displayName": "Workday exact", "appId": "44444444-4444-4444-4444-444444444444", "objectId": "55555555-5555-5555-5555-555555555555", "servicePrincipalId": "66666666-6666-6666-6666-666666666666", - "identifierUris": [ - "http://www.workday.com/contoso_impl/" - ], + "identifierUris": ["http://www.workday.com/contoso_impl/"], }, ] }, @@ -74,8 +103,9 @@ def test_entra_handoff_selects_only_exact_service_provider_id(): assert handoff["identifiers"]["entraAppIdUri"] == ( "api://44444444-4444-4444-4444-444444444444" ) - assert handoff["identifiers"]["workdaySamlEntityId"] != ( - handoff["identifiers"]["entraAppIdUri"] + assert ( + handoff["identifiers"]["workdaySamlEntityId"] + != (handoff["identifiers"]["entraAppIdUri"]) ) assert "planHash" not in handoff @@ -93,6 +123,50 @@ def test_entra_handoff_can_request_explicit_creation(): assert handoff["target"]["mode"] == "create" assert handoff["identifiers"]["entraAppIdUri"] is None + assert handoff["requiresRediscovery"] is True + + +def test_entra_handoff_reuses_matching_tenant_foundation(): + state = _state() + app_id = "44444444-4444-4444-4444-444444444444" + state["tenantFoundation"] = { + "scope": { + "entraTenantId": state["scope"]["entraTenantId"], + "workdayTenant": state["scope"]["workdayTenant"], + }, + "identifiers": {"entraAppId": app_id}, + "endpoints": {}, + "phases": { + "entra": { + "completedActions": [], + "evidence": [], + }, + "workday-admin": { + "completedActions": [], + "evidence": [], + }, + }, + "capturedAt": "2026-09-26T00:00:00Z", + } + + handoff = build_entra_handoff( + state, + { + "applications": [ + { + "displayName": "Workday exact", + "appId": app_id, + "objectId": "55555555-5555-5555-5555-555555555555", + "servicePrincipalId": ("66666666-6666-6666-6666-666666666666"), + "identifierUris": ["http://www.workday.com/contoso_impl"], + } + ] + }, + ) + + assert handoff["foundationReuse"]["eligible"] is True + assert set(handoff["foundationReuse"]) == {"eligible"} + assert "Reread" in handoff["actions"][0] def test_entra_verification_requires_all_expected_graph_evidence(): @@ -100,32 +174,169 @@ def test_entra_verification_requires_all_expected_graph_evidence(): result = validate_entra_verification( _state(), { + "tenantId": "00000000-0000-0000-0000-000000000000", "application": { "displayName": "Workday exact", "appId": app_id, "objectId": "55555555-5555-5555-5555-555555555555", - "servicePrincipalId": ( - "66666666-6666-6666-6666-666666666666" - ), + "servicePrincipalId": ("66666666-6666-6666-6666-666666666666"), "identifierUris": [ "http://www.workday.com/contoso_impl", f"api://{app_id}", ], }, "scopeGuid": "77777777-7777-7777-7777-777777777777", - "checks": { - "samlMode": True, - "signingCertificate": True, - "connectorPreauthorized": True, - "graphDelegatedPermissions": True, - "adminConsent": True, - "userAssignmentAndNameId": True, + "certificate": { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", }, + "checks": _entra_checks(), }, ) assert result["identifiers"]["entraAppId"] == app_id assert result["identifiers"]["entraAppIdUri"] == f"api://{app_id}" + assert result["evidence"]["checks"]["samlSigningOption"] == { + "outcome": "confirmed", + "provenance": "administrator-attestation", + } + + +def test_entra_verification_rejects_a_different_graph_tenant(): + app_id = "44444444-4444-4444-4444-444444444444" + with pytest.raises( + WorkdayConnectContractError, + match="does not match", + ): + validate_entra_verification( + _state(), + { + "tenantId": "99999999-9999-9999-9999-999999999999", + "application": { + "displayName": "Workday exact", + "appId": app_id, + "objectId": "55555555-5555-5555-5555-555555555555", + "servicePrincipalId": ("66666666-6666-6666-6666-666666666666"), + "identifierUris": [ + "http://www.workday.com/contoso_impl", + f"api://{app_id}", + ], + }, + "scopeGuid": "77777777-7777-7777-7777-777777777777", + "certificate": { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + }, + "checks": _entra_checks(), + }, + ) + + +def test_entra_verification_rejects_provenance_free_checks(): + app_id = "44444444-4444-4444-4444-444444444444" + checks = _entra_checks() + checks["nameId"] = {"outcome": "verified"} + + with pytest.raises( + WorkdayConnectContractError, + match="lacks provenance", + ): + validate_entra_verification( + _state(), + { + "tenantId": "00000000-0000-0000-0000-000000000000", + "application": { + "displayName": "Workday exact", + "appId": app_id, + "objectId": "55555555-5555-5555-5555-555555555555", + "servicePrincipalId": ("66666666-6666-6666-6666-666666666666"), + "identifierUris": [ + "http://www.workday.com/contoso_impl", + f"api://{app_id}", + ], + }, + "scopeGuid": "77777777-7777-7777-7777-777777777777", + "certificate": { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + }, + "checks": checks, + }, + ) + + +def test_entra_verification_rejects_unused_check_fields(): + app_id = "44444444-4444-4444-4444-444444444444" + checks = _entra_checks() + checks["nameId"]["details"] = "not part of the evidence contract" + + with pytest.raises( + WorkdayConnectContractError, + match="unsupported fields", + ): + validate_entra_verification( + _state(), + { + "tenantId": "00000000-0000-0000-0000-000000000000", + "application": { + "displayName": "Workday exact", + "appId": app_id, + "objectId": "55555555-5555-5555-5555-555555555555", + "servicePrincipalId": ("66666666-6666-6666-6666-666666666666"), + "identifierUris": [ + "http://www.workday.com/contoso_impl", + f"api://{app_id}", + ], + }, + "scopeGuid": "77777777-7777-7777-7777-777777777777", + "certificate": { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + }, + "checks": checks, + }, + ) + + +def test_entra_verification_rejects_attestation_for_graph_check(): + app_id = "44444444-4444-4444-4444-444444444444" + checks = _entra_checks() + checks["adminConsent"] = { + "outcome": "confirmed", + "provenance": "administrator-attestation", + } + + with pytest.raises( + WorkdayConnectContractError, + match="must be proven by Microsoft Graph", + ): + validate_entra_verification( + _state(), + { + "tenantId": "00000000-0000-0000-0000-000000000000", + "application": { + "displayName": "Workday exact", + "appId": app_id, + "objectId": "55555555-5555-5555-5555-555555555555", + "servicePrincipalId": ("66666666-6666-6666-6666-666666666666"), + "identifierUris": [ + "http://www.workday.com/contoso_impl", + f"api://{app_id}", + ], + }, + "scopeGuid": "77777777-7777-7777-7777-777777777777", + "certificate": { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + }, + "checks": checks, + }, + ) def test_workday_packet_uses_service_provider_id_not_app_id_uri(): @@ -133,9 +344,7 @@ def test_workday_packet_uses_service_provider_id_not_app_id_uri(): state["identifiers"].update( { "workdaySamlEntityId": "http://www.workday.com/contoso_impl", - "entraAppIdUri": ( - "api://44444444-4444-4444-4444-444444444444" - ), + "entraAppIdUri": ("api://44444444-4444-4444-4444-444444444444"), } ) packet = build_workday_admin_packet(state) @@ -143,9 +352,27 @@ def test_workday_packet_uses_service_provider_id_not_app_id_uri(): assert packet["referenceValues"]["serviceProviderId"] == ( "http://www.workday.com/contoso_impl" ) - assert packet["referenceValues"]["entraApplicationIdUri"].startswith( - "api://" + assert packet["referenceValues"]["entraApplicationIdUri"].startswith("api://") + provider_question = packet["identityProviderQuestion"] + assert "sign-in provider" in provider_question["question"] + assert any("Microsoft Entra ID" in option for option in provider_question["options"]) + assert any("Okta" in option for option in provider_question["options"]) + assert any("Ping Identity" in option for option in provider_question["options"]) + assert "Another sign-in provider" in provider_question["options"] + assert "No enabled SAML row" in provider_question["options"] + assert "I'm not sure" in provider_question["options"] + certificate_question = packet["certificateSelectionQuestion"] + assert "Which certificate is selected" in certificate_question["question"] + assert ( + "The new certificate created from the Entra Base64 file" + in certificate_question["options"] + ) + assert ( + "A different existing Workday certificate" + in certificate_question["options"] ) + assert "No certificate is selected" in certificate_question["options"] + assert "I'm not sure" in certificate_question["options"] assert "client secrets" in packet["responseForm"]["note"] @@ -167,9 +394,7 @@ def test_workday_packet_rejects_tenant_drift(): state["identifiers"].update( { "workdaySamlEntityId": "http://www.workday.com/other", - "entraAppIdUri": ( - "api://44444444-4444-4444-4444-444444444444" - ), + "entraAppIdUri": ("api://44444444-4444-4444-4444-444444444444"), } ) @@ -178,45 +403,155 @@ def test_workday_packet_rejects_tenant_drift(): def test_workday_admin_response_validates_exact_endpoints(): + state = _state() + state["identifiers"]["signingCertificate"] = { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + } result = validate_workday_admin_response( - _state(), + state, { - "enabledServiceProviderId": ( - "http://www.workday.com/contoso_impl" + "activeIdentityProviderIssuer": ( + "https://sts.windows.net/00000000-0000-0000-0000-000000000000/" ), + "enabledServiceProviderId": ("http://www.workday.com/contoso_impl"), + "certificateName": "ESS Workday Entra signing certificate", "certificateValidFrom": "2026-01-01", "certificateValidTo": "2027-01-01", "oauthClientId": "safe-client-id", "oauthTokenUrl": ( - "https://example.workday.com/ccx/oauth2/" - "contoso_impl/token" + "https://example.workday.com/ccx/oauth2/contoso_impl/token" ), "restBaseUrl": "https://example.workday.com/ccx/api", - "soapBaseUrl": ( - "https://example.workday.com/ccx/service/contoso_impl" - ), - "authenticationPolicyOutcome": "verified", + "soapBaseUrl": ("https://example.workday.com/ccx/service/contoso_impl"), + "authenticationPolicyOutcome": "existing-active-policy", + "networkReadinessOutcome": "confirmed-hosts-allowed", }, ) assert result["endpoints"]["restBaseUrl"].endswith("/ccx/api") + assert result["evidence"]["networkReadinessOutcome"] == ("confirmed-hosts-allowed") -def test_connections_and_employee_evidence_are_strict(): - assert validate_connections_evidence( - { - "workdayConnectionConnected": True, - "dataverseConnectionConnected": True, - } - )["workdayConnectionConnected"] is True - assert validate_agent_binding_evidence( +def test_workday_admin_response_rejects_certificate_date_drift(): + state = _state() + state["identifiers"]["signingCertificate"] = { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + } + + with pytest.raises( + WorkdayConnectContractError, + match="do not match", + ): + validate_workday_admin_response( + state, + { + "activeIdentityProviderIssuer": "https://sts.example/", + "enabledServiceProviderId": ("http://www.workday.com/contoso_impl"), + "certificateName": "Wrong certificate", + "certificateValidFrom": "2026-01-01", + "certificateValidTo": "2028-01-01", + "oauthClientId": "safe-client-id", + "oauthTokenUrl": ( + "https://example.workday.com/ccx/oauth2/contoso_impl/token" + ), + "restBaseUrl": "https://example.workday.com/ccx/api", + "soapBaseUrl": ("https://example.workday.com/ccx/service/contoso_impl"), + "authenticationPolicyOutcome": "existing-active-policy", + "networkReadinessOutcome": "confirmed-hosts-allowed", + }, + ) + + +def test_workday_admin_rejects_unused_response_fields(): + state = _state() + state["identifiers"]["signingCertificate"] = { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + } + + with pytest.raises( + WorkdayConnectContractError, + match="unsupported fields", + ): + validate_workday_admin_response( + state, + { + "activeIdentityProviderIssuer": "https://sts.example/", + "enabledServiceProviderId": ("http://www.workday.com/contoso_impl"), + "certificateName": "ESS Workday Entra signing certificate", + "certificateValidFrom": "2026-01-01", + "certificateValidTo": "2027-01-01", + "oauthClientId": "safe-client-id", + "oauthTokenUrl": ( + "https://example.workday.com/ccx/oauth2/contoso_impl/token" + ), + "restBaseUrl": "https://example.workday.com/ccx/api", + "soapBaseUrl": ("https://example.workday.com/ccx/service/contoso_impl"), + "authenticationPolicyOutcome": "existing-active-policy", + "networkReadinessOutcome": "confirmed-hosts-allowed", + "notes": "not part of the evidence contract", + }, + ) + + +def test_agent_binding_and_employee_evidence_are_strict(): + state = _state() + state["scope"].update( { - "userContextRedirectPassed": True, - "parameterSharingPassed": True, - "flowAttachmentConfirmed": True, - "workdayTopicsActivated": True, + "environmentId": "environment-id", + "agent": {"botId": "bot-id"}, } - )["parameterSharingPassed"] is True + ) + state["operators"]["powerPlatformMaker"] = {"username": "maker@example.com"} + assert ( + validate_agent_binding_evidence( + state, + { + "environmentId": "environment-id", + "botId": "bot-id", + "makerUsername": "maker@example.com", + "checkpoints": { + "WD-REST-002": "Passed", + "WD-CONN-013": "Passed", + }, + "workdayTopics": { + "expected": 21, + "verified": 21, + "active": 21, + "blockingDiagnostics": [], + }, + }, + )["workdayTopics"]["active"] + == 21 + ) + + with pytest.raises( + WorkdayConnectContractError, + match="Every mapped Workday topic", + ): + validate_agent_binding_evidence( + state, + { + "environmentId": "environment-id", + "botId": "bot-id", + "makerUsername": "maker@example.com", + "checkpoints": { + "WD-REST-002": "Passed", + "WD-CONN-013": "Passed", + }, + "workdayTopics": { + "expected": 21, + "verified": 21, + "active": 21, + "blockingDiagnostics": [{"errorCode": "NotFound"}], + }, + }, + ) with pytest.raises(WorkdayConnectContractError, match="unsupported fields"): validate_employee_evidence( @@ -228,3 +563,90 @@ def test_connections_and_employee_evidence_are_strict(): "employeeName": "not allowed", } ) + + +def test_entra_check_outcome_must_match_provenance(): + app_id = "44444444-4444-4444-4444-444444444444" + checks = _entra_checks() + checks["nameId"] = { + "outcome": "confirmed", + "provenance": "microsoft-graph", + } + + with pytest.raises( + WorkdayConnectContractError, + match="does not match its provenance", + ): + validate_entra_verification( + _state(), + { + "tenantId": "00000000-0000-0000-0000-000000000000", + "application": { + "displayName": "Workday exact", + "appId": app_id, + "objectId": "object-id", + "servicePrincipalId": "service-principal-id", + "identifierUris": [ + "http://www.workday.com/contoso_impl", + f"api://{app_id}", + ], + }, + "scopeGuid": "scope-guid", + "certificate": { + "thumbprint": "ABC123", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + }, + "checks": checks, + }, + ) + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ( + "oauthTokenUrl", + "https://example.workday.com/ccx/oauth2/other/token", + "OAuth token URL must end exactly", + ), + ( + "restBaseUrl", + "https://example.workday.com/prefix/ccx/api", + "REST base URL must end exactly", + ), + ( + "soapBaseUrl", + "https://example.workday.com/ccx/service/other", + "SOAP base URL must end exactly", + ), + ], +) +def test_workday_admin_rejects_endpoint_path_drift( + field, + value, + message, +): + state = _state() + state["identifiers"]["signingCertificate"] = { + "thumbprint": "ABC123", + "validFrom": "2026-01-01", + "validTo": "2027-01-01", + } + response = { + "activeIdentityProviderIssuer": "https://sts.windows.net/tenant/", + "enabledServiceProviderId": ("http://www.workday.com/contoso_impl"), + "certificateName": "ESS Workday Entra signing certificate", + "certificateValidFrom": "2026-01-01", + "certificateValidTo": "2027-01-01", + "oauthClientId": "safe-client-id", + "oauthTokenUrl": ("https://example.workday.com/ccx/oauth2/contoso_impl/token"), + "restBaseUrl": "https://example.workday.com/ccx/api", + "soapBaseUrl": ("https://example.workday.com/ccx/service/contoso_impl"), + "authenticationPolicyOutcome": "existing-active-policy", + "networkReadinessOutcome": "confirmed-hosts-allowed", + } + response[field] = value + + with pytest.raises(WorkdayConnectContractError, match=message): + validate_workday_admin_response(state, response) diff --git a/tests/scripts/test_workday_connect_model.py b/tests/scripts/test_workday_connect_model.py index a7b9fae0..83367d59 100644 --- a/tests/scripts/test_workday_connect_model.py +++ b/tests/scripts/test_workday_connect_model.py @@ -23,7 +23,8 @@ def test_default_state_has_six_primary_phases() -> None: ] assert model.next_phase_id(state) == "preflight" assert state["status"] == "in-progress" - assert state["schemaVersion"] == 3 + assert state["schemaVersion"] == 5 + assert state["tenantFoundation"] is None def test_workday_saml_entity_id_is_not_the_entra_app_uri() -> None: @@ -54,9 +55,7 @@ def test_plan_hash_is_stable_and_ignores_embedded_hash() -> None: observed = model.plan_hash(plan) assert observed == model.plan_hash({**plan, "planHash": observed}) - assert observed != model.plan_hash( - {**plan, "actions": ["configure-saml"]} - ) + assert observed != model.plan_hash({**plan, "actions": ["configure-saml"]}) def test_sensitive_fields_are_rejected() -> None: @@ -66,7 +65,7 @@ def test_sensitive_fields_are_rejected() -> None: model.reject_sensitive_data({"nested": {"access_token": "secret"}}) -def test_progress_text_is_compact() -> None: +def test_progress_text_is_a_visible_phase_roadmap() -> None: import workday_connect_model as model state = model.default_state() @@ -74,6 +73,50 @@ def test_progress_text_is_compact() -> None: state["phases"]["entra"]["status"] = "active" assert model.progress_text(state) == ( - "Progress: Preflight ✓ · Entra → · Workday · Connections · " - "Runtime · Validate" + "### Workday connection progress\n" + "\n" + "| # | Phase | Status |\n" + "|---:|---|---|\n" + "| 1 | Preflight | Complete |\n" + "| 2 | Microsoft Entra | In progress |\n" + "| 3 | Workday administrator | Pending |\n" + "| 4 | Connections | Pending |\n" + "| 5 | Runtime configuration | Pending |\n" + "| 6 | Employee validation | Pending |" ) + assert model.next_phase_summary(state) == { + "id": "entra", + "title": "Microsoft Entra", + "whatHappens": [ + ( + "Find the exact Workday enterprise application in the selected " + "Microsoft Entra tenant." + ), + ( + "Guide an Entra administrator through the required SAML, " + "permission, consent, assignment, and employee sign-in settings." + ), + "Verify the application and signing-certificate configuration.", + ], + } + + +def test_pending_current_phase_is_marked_next() -> None: + import workday_connect_model as model + + state = model.default_state() + + assert "| 1 | Preflight | Next |" in model.progress_text(state) + + +def test_blocked_phase_requires_complete_blocker_evidence() -> None: + import workday_connect_model as model + + state = model.default_state() + state["phases"]["preflight"]["status"] = "blocked" + + with pytest.raises( + model.WorkdayConnectModelError, + match="complete blocker", + ): + model.validate_state(state) diff --git a/tests/scripts/test_workday_connect_preflight.py b/tests/scripts/test_workday_connect_preflight.py index 618c5f5a..c57f0e55 100644 --- a/tests/scripts/test_workday_connect_preflight.py +++ b/tests/scripts/test_workday_connect_preflight.py @@ -71,37 +71,92 @@ def _write_foundation( ) -def test_resolve_target_prefers_canonical_dataverse_url(tmp_path: Path) -> None: +def test_resolve_target_rejects_url_that_differs_from_setup( + tmp_path: Path, +) -> None: import workday_connect_model as model import workday_connect_preflight as preflight _write_foundation(tmp_path, dataverse_url=ENV_URL) + with pytest.raises( + preflight.WorkdayConnectPreflightError, + match="does not match", + ): + preflight.resolve_target( + tmp_path, + dataverse_url="https://other.crm.dynamics.com", + state=model.default_state(), + ) + + +def test_resolve_target_accepts_exact_url_without_inventory_lookup( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + _write_foundation(tmp_path) + target = preflight.resolve_target( tmp_path, - dataverse_url="https://other.crm.dynamics.com", + dataverse_url=ENV_URL, state=model.default_state(), + pac_resolver=lambda: Path("pac.exe"), + pac_runner=lambda command, **_kwargs: subprocess.CompletedProcess( + command, + 0, + stdout=json.dumps( + { + "EnvironmentId": "agent-environment", + "OrgUrl": ENV_URL, + } + ), + stderr="", + ), ) assert target.dataverse_url == ENV_URL - assert target.environment_id == "agent-environment" - assert target.package_flavor == "runtime" -def test_resolve_target_accepts_exact_url_without_inventory_lookup( +def test_resolve_target_ignores_stale_url_from_different_environment( tmp_path: Path, ) -> None: import workday_connect_model as model import workday_connect_preflight as preflight _write_foundation(tmp_path) + state = model.default_state() + state["scope"].update( + { + "environmentId": "old-environment", + "dataverseUrl": "https://old.crm.dynamics.com", + } + ) + inventory = ( + tmp_path / ".local" / "setup" / "environment-list-prod.json" + ) + inventory.write_text( + json.dumps( + { + "environments": [ + { + "id": "agent-environment", + "url": ENV_URL, + } + ] + } + ), + encoding="utf-8", + ) target = preflight.resolve_target( tmp_path, - dataverse_url=ENV_URL, - state=model.default_state(), + dataverse_url=None, + state=state, ) + assert target.environment_id == "agent-environment" assert target.dataverse_url == ENV_URL @@ -139,6 +194,41 @@ def test_resolve_target_reuses_setup_environment_inventory( assert target.dataverse_url == ENV_URL +def test_resolve_target_rejects_conflicting_setup_urls( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + _write_foundation(tmp_path, dataverse_url=ENV_URL) + inventory = ( + tmp_path / ".local" / "setup" / "environment-list-prod.json" + ) + inventory.write_text( + json.dumps( + { + "environments": [ + { + "id": "agent-environment", + "url": "https://different.crm.dynamics.com", + } + ] + } + ), + encoding="utf-8", + ) + + with pytest.raises( + preflight.WorkdayConnectPreflightError, + match="disagree", + ): + preflight.resolve_target( + tmp_path, + dataverse_url=None, + state=model.default_state(), + ) + + def test_resolve_target_reuses_exact_pac_environment(tmp_path: Path) -> None: import workday_connect_model as model import workday_connect_preflight as preflight diff --git a/tests/scripts/test_workday_connect_runtime.py b/tests/scripts/test_workday_connect_runtime.py index 4d1575ab..ca8e4bbe 100644 --- a/tests/scripts/test_workday_connect_runtime.py +++ b/tests/scripts/test_workday_connect_runtime.py @@ -9,10 +9,7 @@ SCRIPTS = ( - Path(__file__).resolve().parents[2] - / "solutions" - / "ess-maker-skills" - / "scripts" + Path(__file__).resolve().parents[2] / "solutions" / "ess-maker-skills" / "scripts" ) sys.path.insert(0, str(SCRIPTS)) @@ -35,9 +32,7 @@ def _state(): "agent": {"botId": BOT_ID}, } ) - state["operators"]["powerPlatformMaker"] = { - "username": "maker@contoso.com" - } + state["operators"]["powerPlatformMaker"] = {"username": "maker@contoso.com"} state["phases"]["connections"]["status"] = "complete" return state @@ -115,9 +110,7 @@ def _records(): }, "agent-workday": { "connectionreferencelogicalname": ( - "contoso." - "55555555-5555-5555-5555-555555555555." - "shared_workdaysoap" + "contoso.55555555-5555-5555-5555-555555555555.shared_workdaysoap" ), "connectionreferencedisplayname": "ESS HR Workday", "connectionid": "agent-workday-connection", @@ -182,21 +175,19 @@ def test_runtime_plan_uses_one_dataverse_token_and_exact_targets(): result = runtime.run_runtime_operation( _state(), apply=False, - token_provider=lambda url, preferred_username: token_calls.append( - (url, preferred_username) - ) - or "token", + token_provider=lambda url, preferred_username: ( + token_calls.append((url, preferred_username)) or "token" + ), identity_provider=_identity, **_discovery_dependencies(records), ) - assert token_calls == [ - ("https://org.crm.dynamics.com", "maker@contoso.com") - ] + assert token_calls == [("https://org.crm.dynamics.com", "maker@contoso.com")] bindings = result["plan"]["connectionBindings"] - assert { - value["connectionId"] for value in bindings.values() - } == {WORKDAY_CONNECTION, DATAVERSE_CONNECTION} + assert {value["connectionId"] for value in bindings.values()} == { + WORKDAY_CONNECTION, + DATAVERSE_CONNECTION, + } assert result["approvalSummary"]["connections"] == [ "Workday", "Dataverse", @@ -212,6 +203,47 @@ def test_runtime_plan_uses_one_dataverse_token_and_exact_targets(): assert "token" not in json.dumps(result).casefold() +def test_runtime_plan_requires_verified_physical_connections(): + state = _state() + state["phases"]["connections"]["status"] = "pending" + + with pytest.raises( + runtime.WorkdayConnectRuntimeError, + match="connection sign-ins", + ): + runtime.run_runtime_operation( + state, + apply=False, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=_identity, + **_discovery_dependencies(_records()), + ) + + +def test_physical_connection_verification_returns_safe_live_evidence(): + result = runtime.verify_physical_connections( + _state(), + pac_resolver=lambda: Path("pac.exe"), + runner=_pac_runner, + ) + + assert result == { + "makerUsername": "maker@contoso.com", + "connections": [ + { + "connector": "shared_workdaysoap", + "displayName": "Workday", + }, + { + "connector": "shared_commondataserviceforapps", + "displayName": "Dataverse", + }, + ], + } + assert WORKDAY_CONNECTION not in json.dumps(result) + assert DATAVERSE_CONNECTION not in json.dumps(result) + + def test_runtime_apply_verifies_all_mutations(monkeypatch): records = _records() verified_hashes = [] @@ -233,9 +265,7 @@ def updater(_url, _token, entity_set, record_id, data): monkeypatch.setattr(runtime.shutil, "which", lambda _name: "pwsh.exe") def authorization_runner(command, **_kwargs): - assert command[command.index("-PreferredUsername") + 1] == ( - "maker@contoso.com" - ) + assert command[command.index("-PreferredUsername") + 1] == ("maker@contoso.com") assert command[-2] == "-WorkflowId" return SimpleNamespace( returncode=0, @@ -270,10 +300,7 @@ def authorization_runner(command, **_kwargs): ("runtime-flows-active", "verified"), ("delegated-authorization-configured", "verified"), ] - assert all( - value["connectionid"] - for value in records["references"].values() - ) + assert all(value["connectionid"] for value in records["references"].values()) assert all( value["statecode"] == 1 and value["statuscode"] == 2 for value in records["flows"].values() @@ -320,9 +347,7 @@ def updater(_url, _token, entity_set, record_id, data): stdout="[FAIL] denied", stderr="", ), - stage_recorder=lambda action, _evidence: recorded_stages.append( - action - ), + stage_recorder=lambda action, _evidence: recorded_stages.append(action), **_discovery_dependencies(records), ) @@ -330,9 +355,7 @@ def updater(_url, _token, entity_set, record_id, data): "connection-references-bound", "runtime-flows-active", ] - assert all( - value["connectionid"] for value in records["references"].values() - ) + assert all(value["connectionid"] for value in records["references"].values()) assert all( value["statecode"] == 1 and value["statuscode"] == 2 for value in records["flows"].values() @@ -424,9 +447,7 @@ def test_runtime_ambiguity_reports_only_safe_display_names(): { "name": WORKDAY_CONNECTION, "properties": { - "apiId": ( - "/providers/Microsoft.PowerApps/apis/shared_workdaysoap" - ), + "apiId": ("/providers/Microsoft.PowerApps/apis/shared_workdaysoap"), "displayName": "Workday Primary", "statuses": [{"status": "Connected"}], }, @@ -434,9 +455,7 @@ def test_runtime_ambiguity_reports_only_safe_display_names(): { "name": "raw-connection-id-that-must-not-appear", "properties": { - "apiId": ( - "/providers/Microsoft.PowerApps/apis/shared_workdaysoap" - ), + "apiId": ("/providers/Microsoft.PowerApps/apis/shared_workdaysoap"), "displayName": "Workday Secondary", "statuses": [{"status": "Connected"}], }, diff --git a/tests/scripts/test_workday_connect_store.py b/tests/scripts/test_workday_connect_store.py index 6dbef40b..61c2b5bb 100644 --- a/tests/scripts/test_workday_connect_store.py +++ b/tests/scripts/test_workday_connect_store.py @@ -21,7 +21,7 @@ def test_initialize_creates_only_json_state(tmp_path: Path) -> None: store = store_module.WorkdayConnectStore(tmp_path) state = store.initialize() - assert state["schemaVersion"] == 3 + assert state["schemaVersion"] == 5 assert _config_path(tmp_path).exists() assert not (tmp_path / ".local/connect/workday-da/tasks.md").exists() assert not (tmp_path / ".local/setup/workday-da/tasks.md").exists() @@ -61,7 +61,7 @@ def test_migrates_legacy_rows_without_using_app_uri_as_saml_id( ) assert state["migration"]["source"] == "legacy-workday-da-config" assert state["operators"]["entraAdmin"]["username"] == "admin@example.com" - assert path.with_name("config.pre-v3.json").exists() + assert path.with_name("config.pre-v5.json").exists() def test_migration_preserves_existing_tasks_as_snapshot(tmp_path: Path) -> None: @@ -79,10 +79,35 @@ def test_migration_preserves_existing_tasks_as_snapshot(tmp_path: Path) -> None: store_module.WorkdayConnectStore(tmp_path).initialize() assert tasks.read_text(encoding="utf-8") == "legacy setup checklist" - assert connect_tasks.read_text(encoding="utf-8") == ( - "legacy connect checklist" + assert connect_tasks.read_text(encoding="utf-8") == ("legacy connect checklist") + + +def test_legacy_ready_state_reopens_runtime_for_live_topic_proof( + tmp_path: Path, +) -> None: + import workday_connect_store as store_module + + path = _config_path(tmp_path) + path.parent.mkdir(parents=True) + rows = { + row: {"state": "done", "verifiedBy": "legacy"} + for phase_rows in store_module.LEGACY_PHASE_ROWS.values() + for row in phase_rows + } + path.write_text( + json.dumps({"setupStatus": rows}), + encoding="utf-8", ) + state = store_module.WorkdayConnectStore(tmp_path).initialize() + + assert state["status"] == "in-progress" + assert state["phases"]["runtime"]["status"] == "active" + assert ( + "workday-topics-activated" not in state["phases"]["runtime"]["completedActions"] + ) + assert state["phases"]["employee-validation"]["status"] == "pending" + def test_phase_completion_requires_prerequisite(tmp_path: Path) -> None: import workday_connect_store as store_module @@ -113,17 +138,64 @@ def test_complete_action_is_idempotent(tmp_path: Path) -> None: evidence={"outcome": "passed"}, ) - assert state["phases"]["preflight"]["completedActions"] == [ - "verify-target" - ] + assert state["phases"]["preflight"]["completedActions"] == ["verify-target"] assert len(state["phases"]["preflight"]["evidence"]) == 1 +def test_complete_action_reactivates_a_blocked_phase(tmp_path: Path) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + store.set_phase_status( + "preflight", + "blocked", + blocker={ + "operation": "preflight", + "errorType": "TestBlocker", + "message": "Resolve the test blocker.", + }, + ) + + state = store.complete_action( + "preflight", + "verify-target", + evidence={"outcome": "verified"}, + ) + + phase = state["phases"]["preflight"] + assert phase["status"] == "active" + assert phase["blocker"] is None + + +def test_complete_action_requires_completed_prerequisite(tmp_path: Path) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + + with pytest.raises( + store_module.WorkdayConnectStoreError, + match="Complete 'preflight'", + ): + store.complete_action( + "entra", + "exact-application-discovered", + evidence={"outcome": "verified"}, + ) + + def test_approved_plan_rejects_changed_target(tmp_path: Path) -> None: import workday_connect_store as store_module store = store_module.WorkdayConnectStore(tmp_path) store.initialize() + for phase_id in ("preflight", "entra", "workday-admin", "connections"): + _complete_phase( + store, + phase_id, + store_module.PHASE_REQUIRED_ACTIONS[phase_id], + ) plan = { "phase": "runtime", "scope": {"tenantId": "tenant-a", "applicationId": "app-a"}, @@ -140,7 +212,9 @@ def test_approved_plan_rejects_changed_target(tmp_path: Path) -> None: store.verify_plan("runtime", changed, approved_hash) -def test_status_returns_one_progress_line_and_next_phase(tmp_path: Path) -> None: +def test_status_returns_progress_roadmap_and_next_phase_summary( + tmp_path: Path, +) -> None: import workday_connect_store as store_module store = store_module.WorkdayConnectStore(tmp_path) @@ -156,7 +230,10 @@ def test_status_returns_one_progress_line_and_next_phase(tmp_path: Path) -> None status = store.status() assert status["nextPhaseId"] == "entra" - assert status["progressText"].startswith("Progress: Preflight ✓ · Entra") + assert "| 1 | Preflight | Complete |" in status["progressText"] + assert "| 2 | Microsoft Entra | Next |" in status["progressText"] + assert status["nextPhaseSummary"]["title"] == "Microsoft Entra" + assert len(status["nextPhaseSummary"]["whatHappens"]) == 3 assert len(status["phases"]) == 6 @@ -196,6 +273,129 @@ def test_scope_change_invalidates_affected_phases(tmp_path: Path) -> None: assert state["phases"]["entra"]["status"] == "pending" +def _complete_phase(store, phase_id: str, actions: set[str]) -> None: + for action in actions: + store.complete_action( + phase_id, + action, + evidence={"outcome": "verified"}, + ) + store.set_phase_status(phase_id, "complete") + + +def _set_foundation_data(store, *, workday_tenant: str = "contoso") -> None: + store.merge_section( + "identifiers", + { + "entraAppId": "app-id", + "entraAppObjectId": "app-object-id", + "entraServicePrincipalId": "service-principal-id", + "entraAppIdUri": "api://app-id", + "workdaySamlEntityId": (f"http://www.workday.com/{workday_tenant}"), + "scopeGuid": "scope-id", + "signingCertificate": { + "thumbprint": "thumbprint", + "validFrom": "2026-01-01", + "validTo": "2027-01-01", + }, + "oauthClientId": "oauth-client-id", + }, + ) + store.merge_section( + "endpoints", + { + "oauthTokenUrl": "https://example.workday.com/oauth/token", + "restBaseUrl": "https://example.workday.com/ccx/api", + "soapBaseUrl": ( + f"https://example.workday.com/ccx/service/{workday_tenant}" + ), + }, + ) + + +def test_endpoint_change_invalidates_workday_and_downstream_phases( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + for phase_id in ( + "preflight", + "entra", + "workday-admin", + "connections", + "runtime", + "employee-validation", + ): + _complete_phase( + store, + phase_id, + set(model.PHASE_REQUIRED_ACTIONS[phase_id]), + ) + + state = store.merge_section( + "endpoints", + {"restBaseUrl": "https://example.workday.com/ccx/api"}, + ) + + assert state["status"] == "in-progress" + assert state["phases"]["preflight"]["status"] == "complete" + assert state["phases"]["entra"]["status"] == "complete" + assert state["phases"]["workday-admin"]["status"] == "pending" + assert state["phases"]["connections"]["status"] == "pending" + assert state["phases"]["runtime"]["status"] == "pending" + assert state["phases"]["employee-validation"]["status"] == "pending" + + +def test_maker_change_preserves_reusable_tenant_foundation( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + for phase_id in ( + "preflight", + "entra", + "workday-admin", + "connections", + "runtime", + "employee-validation", + ): + _complete_phase( + store, + phase_id, + set(model.PHASE_REQUIRED_ACTIONS[phase_id]), + ) + store.merge_section( + "scope", + { + "entraTenantId": "tenant-id", + "workdayTenant": "contoso", + }, + ) + _set_foundation_data(store) + for phase_id in ("preflight", "entra", "workday-admin"): + _complete_phase( + store, + phase_id, + set(model.PHASE_REQUIRED_ACTIONS[phase_id]), + ) + store.capture_tenant_foundation() + + state = store.merge_section( + "operators", + {"powerPlatformMaker": {"username": "new@example.com"}}, + ) + + assert state["status"] == "in-progress" + assert all(phase["status"] == "pending" for phase in state["phases"].values()) + assert state["tenantFoundation"] is not None + + def test_v2_state_is_downgraded_when_completion_has_no_evidence( tmp_path: Path, ) -> None: @@ -211,6 +411,203 @@ def test_v2_state_is_downgraded_when_completion_has_no_evidence( upgraded = store_module.WorkdayConnectStore(tmp_path).initialize() - assert upgraded["schemaVersion"] == 3 + assert upgraded["schemaVersion"] == 5 assert upgraded["phases"]["preflight"]["status"] == "active" assert upgraded["migration"]["source"] == "workday-connect-state-v2" + + +def test_v3_runtime_completion_is_reopened_for_live_topic_proof( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_store as store_module + + path = _config_path(tmp_path) + path.parent.mkdir(parents=True) + state = model.default_state() + state["schemaVersion"] = 3 + for phase_id in ( + "preflight", + "entra", + "workday-admin", + "connections", + "runtime", + "employee-validation", + ): + actions = set(model.PHASE_REQUIRED_ACTIONS[phase_id]) + actions.discard("workday-topics-activated") + phase = state["phases"][phase_id] + phase["status"] = "complete" + phase["completedActions"] = sorted(actions) + phase["evidence"] = [ + {"action": action, "outcome": "verified"} for action in sorted(actions) + ] + state["status"] = "ready" + path.write_text(json.dumps(state), encoding="utf-8") + + upgraded = store_module.WorkdayConnectStore(tmp_path).initialize() + + assert upgraded["schemaVersion"] == 5 + assert upgraded["status"] == "in-progress" + assert upgraded["phases"]["runtime"]["status"] == "active" + assert upgraded["phases"]["employee-validation"]["status"] == "pending" + assert upgraded["migration"]["source"] == "workday-connect-state-v3" + + +def test_v4_migration_captures_complete_tenant_foundation( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_store as store_module + + path = _config_path(tmp_path) + path.parent.mkdir(parents=True) + state = model.default_state() + state["schemaVersion"] = 4 + state.pop("tenantFoundation") + state["scope"].update( + { + "entraTenantId": "tenant-id", + "workdayTenant": "contoso", + } + ) + state["identifiers"].update( + { + "entraAppId": "app-id", + "entraAppObjectId": "app-object-id", + "entraServicePrincipalId": "service-principal-id", + "entraAppIdUri": "api://app-id", + "workdaySamlEntityId": "http://www.workday.com/contoso", + "scopeGuid": "scope-id", + "signingCertificate": { + "thumbprint": "thumbprint", + "validFrom": "2026-01-01", + "validTo": "2027-01-01", + }, + "oauthClientId": "oauth-client-id", + } + ) + state["endpoints"].update( + { + "oauthTokenUrl": "https://example.workday.com/oauth/token", + "restBaseUrl": "https://example.workday.com/ccx/api", + "soapBaseUrl": "https://example.workday.com/ccx/service/contoso", + } + ) + for phase_id in ("preflight", "entra", "workday-admin"): + actions = sorted(model.PHASE_REQUIRED_ACTIONS[phase_id]) + phase = state["phases"][phase_id] + phase["status"] = "complete" + phase["completedActions"] = actions + phase["evidence"] = [ + {"action": action, "outcome": "verified"} for action in actions + ] + path.write_text(json.dumps(state), encoding="utf-8") + + upgraded = store_module.WorkdayConnectStore(tmp_path).initialize() + + assert upgraded["schemaVersion"] == 5 + assert upgraded["migration"]["source"] == "workday-connect-state-v4" + assert upgraded["tenantFoundation"]["scope"] == { + "entraTenantId": "tenant-id", + "workdayTenant": "contoso", + } + assert path.with_name("config.pre-v5.json").exists() + + +def test_matching_foundation_restores_workday_after_entra_reread( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + store.merge_section( + "scope", + { + "agent": {"slug": "agent-a"}, + "environmentId": "environment-a", + "entraTenantId": "tenant-id", + "workdayTenant": "contoso", + }, + ) + _set_foundation_data(store) + for phase_id in ("preflight", "entra", "workday-admin"): + _complete_phase( + store, + phase_id, + set(model.PHASE_REQUIRED_ACTIONS[phase_id]), + ) + store.capture_tenant_foundation() + + changed = store.merge_section( + "scope", + { + "agent": {"slug": "agent-b"}, + "environmentId": "environment-b", + }, + ) + assert changed["phases"]["workday-admin"]["status"] == "pending" + assert changed["tenantFoundation"] is not None + + store.merge_section( + "identifiers", + { + "signingCertificate": { + "thumbprint": "TH UM BP RI NT", + "validFrom": "2026-01-01T12:00:00+00:00", + "validTo": "2027-01-01T12:00:00+00:00", + } + }, + ) + _complete_phase( + store, + "preflight", + set(model.PHASE_REQUIRED_ACTIONS["preflight"]), + ) + _complete_phase( + store, + "entra", + set(model.PHASE_REQUIRED_ACTIONS["entra"]), + ) + restored, reused = store.restore_workday_foundation() + + assert reused is True + assert restored["phases"]["workday-admin"]["status"] == "complete" + assert restored["phases"]["connections"]["status"] == "pending" + assert any( + evidence["action"] == "tenant-foundation-reused" + for evidence in restored["phases"]["workday-admin"]["evidence"] + ) + + +def test_foundation_is_not_reused_for_a_different_workday_tenant( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + store.merge_section( + "scope", + { + "entraTenantId": "tenant-id", + "workdayTenant": "contoso", + }, + ) + _set_foundation_data(store) + for phase_id in ("preflight", "entra", "workday-admin"): + _complete_phase( + store, + phase_id, + set(model.PHASE_REQUIRED_ACTIONS[phase_id]), + ) + store.capture_tenant_foundation() + + store.merge_section("scope", {"workdayTenant": "fabrikam"}) + restored, reused = store.restore_workday_foundation() + + assert reused is False + assert restored["phases"]["workday-admin"]["status"] == "pending" diff --git a/tests/setup/test_connect_lifecycle.py b/tests/setup/test_connect_lifecycle.py index ca09a366..272173fb 100644 --- a/tests/setup/test_connect_lifecycle.py +++ b/tests/setup/test_connect_lifecycle.py @@ -26,7 +26,8 @@ def test_workday_contract_uses_generic_lifecycle() -> None: wiring = contract["phases"][1] assert wiring["mutates"] is True assert wiring["requiredRole"] == "Environment Maker" - assert wiring["rollbackPushGlob"] == "topics/user-context-setup.mcs.yml" + assert wiring["rollbackPushGlobFromAction"] is True + assert "rollbackPushGlob" not in wiring entry = (_CONNECT / "workday" / "SKILL.md").read_text(encoding="utf-8") assert "connect/shared/lifecycle-runner.md" in entry @@ -57,7 +58,8 @@ def test_cea_workday_routing_is_package_gated() -> None: assert "Passed` + full / legacy result" in route assert "Never start a lifecycle from an\n inconclusive package check" in route assert "connect/workday/SKILL.md" in route - assert "Shared provider setup state is not agent connection state" in route + assert ".local/connect/workday-da/config.json" in route + assert "`scope.agent.slug` and `scope.agent.botId` exactly" in route def test_workday_wiring_uses_installed_identity_and_explicit_result() -> None: @@ -65,10 +67,46 @@ def test_workday_wiring_uses_installed_identity_and_explicit_result() -> None: _CONNECT / "workday" / "actions" / "wire-user-context-redirect.md" ).read_text(encoding="utf-8") - assert ".local/agents/{AGENT_SLUG}/topics/" in action - assert "workspace/agents/{AGENT_SLUG}/topics/" in action + assert "workspace/agents/{AGENT_SLUG}/.component-map.json" in action + assert "workspace/agents/{AGENT_SLUG}/{USER_CONTEXT_TOPIC_PATH}" in action assert 'ACTION_RESULT = "cancelled"' in action assert 'ACTION_RESULT = "applied"' in action + assert "ACTION_ROLLBACK_PUSH_GLOB" in action + assert "install-workday-extension-pack.md" not in action + + +def test_connect_contract_action_docs_and_rollback_scopes_are_valid() -> None: + contracts = sorted(_CONNECT.glob("*/contract.json")) + assert contracts + + for contract_path in contracts: + contract = json.loads(contract_path.read_text(encoding="utf-8")) + for phase in contract["phases"]: + action_doc = phase.get("actionDoc") + if action_doc: + assert (_SOLUTION / action_doc).is_file() + + has_static_scope = "rollbackPushGlob" in phase + has_dynamic_scope = phase.get("rollbackPushGlobFromAction") is True + assert not (has_static_scope and has_dynamic_scope) + if phase.get("rollbackLabel"): + assert has_static_scope or has_dynamic_scope + + +def test_dynamic_rollback_scope_is_persisted_and_reused_exactly() -> None: + runner = (_CONNECT / "shared" / "lifecycle-runner.md").read_text( + encoding="utf-8" + ) + schema = (_CONNECT / "shared" / "lifecycle-contract-schema.md").read_text( + encoding="utf-8" + ) + + assert "ACTION_ROLLBACK_PUSH_GLOB" in runner + assert "phases.{id}.rollbackPushGlob" in runner + assert "no wildcard characters" in runner + assert '--only "{ROLLBACK_PUSH_GLOB}"' in runner + assert "rollbackPushGlobFromAction" in schema + assert "ACTION_ROLLBACK_PUSH_GLOB" in schema def test_declarative_agents_do_not_enter_cea_lifecycle() -> None: diff --git a/tests/setup/test_workday_da_foundation.py b/tests/setup/test_workday_da_foundation.py index 8c5e1e83..e86352db 100644 --- a/tests/setup/test_workday_da_foundation.py +++ b/tests/setup/test_workday_da_foundation.py @@ -27,7 +27,8 @@ def test_lifecycle_has_one_json_state_authority() -> None: assert "scripts/workday_connect.py" in skill assert ".local/connect/workday-da/config.json" in skill assert "must not edit this file directly" in schema - assert '"schemaVersion": 3' in schema + assert '"schemaVersion": 5' in schema + assert '"tenantFoundation": null' in schema assert "Markdown state mirror" in schema assert not (_WORKDAY_DA / "tasks.md").exists() assert not (_WORKDAY_DA / "shared" / "checklist-updater.md").exists() @@ -81,5 +82,19 @@ def test_manual_handoff_is_one_packet_not_row_attestations() -> None: assert "one administrator handoff" in tenant assert "one response form" in tenant assert "repeated confirmations" in tenant + assert "identityProviderQuestion" in tenant + assert "Microsoft Entra ID" in tenant + assert "Okta" in tenant + assert "Ping Identity" in tenant + assert "Another sign-in provider" in tenant + assert "No enabled SAML row" in tenant + assert "I'm not sure" in tenant + assert "Do not save the option\n label as the issuer" in tenant + assert "certificateSelectionQuestion" in tenant + assert "The new certificate created from the Entra Base64 file" in tenant + assert "A different existing Workday certificate" in tenant + assert "No certificate is selected" in tenant + assert "Never suggest, prefill, or ask the administrator to confirm" in tenant + assert "exact certificate name displayed by\n Workday" in tenant assert "CHECKPOINT_RESULT" not in tenant assert "ACK=true" not in tenant diff --git a/tests/setup/test_workday_da_orchestration.py b/tests/setup/test_workday_da_orchestration.py index 2f1f7d8f..ecadf433 100644 --- a/tests/setup/test_workday_da_orchestration.py +++ b/tests/setup/test_workday_da_orchestration.py @@ -6,6 +6,7 @@ import argparse import json from pathlib import Path +import re import pytest @@ -28,13 +29,94 @@ def test_orchestrator_resumes_from_controller_status() -> None: assert "python scripts/workday_connect.py status" in text assert "nextPhaseId" in text - assert "Do not create, copy, update, or infer status from a Markdown" in ( + assert "nextPhaseSummary" in text + assert "What happens in this phase:" in text + assert "must not be collapsed into a one-line phase list" in text + assert "Do not\nreplace the phase explanation with only" in text + assert "do not create, copy, update, or infer status from a Markdown" in ( normalized ) assert "resume the same blocker" in text assert "controller status is `ready`" in text +def test_every_controller_command_is_documented() -> None: + import workday_connect as controller + + guide_text = "\n".join( + path.read_text(encoding="utf-8") + for path in sorted(_WORKDAY_DA.rglob("*.md")) + ) + documented = set( + re.findall(r"workday_connect\.py\s+([a-z][a-z-]*)", guide_text) + ) + + assert documented == set(controller._COMMAND_HANDLERS) + + +def test_every_phase_dispatch_target_exists_and_schema_is_reference_only() -> None: + skill = (_WORKDAY_DA / "SKILL.md").read_text(encoding="utf-8") + dispatch_targets = re.findall(r"-> read `([^`]+\.md)`", skill) + + assert dispatch_targets + assert all((_WORKDAY_DA / target).is_file() for target in dispatch_targets) + assert "Use `shared/config-schema.md` as the internal state" in skill + assert "not a customer-executed phase" in skill + + +def test_customer_messages_exclude_internal_implementation_terms() -> None: + skill = (_WORKDAY_DA / "SKILL.md").read_text(encoding="utf-8") + activation = ( + _REPO_ROOT + / "solutions" + / "ess-maker-skills" + / "src" + / "skills" + / "connect" + / "workday" + / "actions" + / "activate-workday-topics.md" + ).read_text(encoding="utf-8") + redirect = ( + _REPO_ROOT + / "solutions" + / "ess-maker-skills" + / "src" + / "skills" + / "connect" + / "workday" + / "actions" + / "wire-user-context-redirect.md" + ).read_text(encoding="utf-8") + readiness = skill.split("> Here's who may be needed", 1)[1].split( + "\nRun:", + 1, + )[0] + activation_message = activation.split("**Message:**", 1)[1].split( + "**End message.**", + 1, + )[0] + redirect_message = redirect.split("**Message:**", 1)[1].split( + "**End message.**", + 1, + )[0] + customer_text = readiness + activation_message + redirect_message + + for internal_term in ( + "controller", + "component-map", + "MinimalBot", + "plan hash", + "schema", + "checkpoint", + "CloudFlow", + "delegatedauthorization", + ): + assert internal_term.casefold() not in customer_text.casefold() + assert "Customer-facing language contract" in skill + assert "Never show or narrate them" in skill + + def test_preflight_is_one_identity_aware_operation() -> None: text = (_WORKDAY_DA / "install-extension.md").read_text(encoding="utf-8") @@ -46,26 +128,36 @@ def test_preflight_is_one_identity_aware_operation() -> None: def test_connections_are_proven_before_runtime_apply() -> None: - text = (_WORKDAY_DA / "configure-power-platform.md").read_text( - encoding="utf-8" - ) + text = (_WORKDAY_DA / "configure-power-platform.md").read_text(encoding="utf-8") normalized = " ".join(text.split()) - assert text.index("## Connections") < text.index( - "## Runtime approval and apply" - ) + assert text.index("## Connections") < text.index("## Runtime approval and apply") assert "runtime-plan" in text assert "record-connections" in text + assert "record-connections --evidence-json" not in text + assert "manual connection evidence" in text + assert "Do not begin with a yes/no question" in text + assert text.count("workday_connect.py record-connections") == 2 + assert "Power Apps maker portal" in text + assert "Microsoft Entra ID Integrated" in text + assert "**Microsoft Entra resource URL:**" in text + assert "Do not use the Entra application\n ID URI beginning with `api://`" in text + assert "**Workday OAuth token URL:**" in text + assert "**Client ID:**" in text + assert "not the Microsoft Entra application ID" in text + assert "Do not ask the maker or administrator to provide them again" in normalized + assert "Do not request or collect a Workday password" in text + assert "Reuse a healthy existing connection" in text assert "runtime-apply" in text assert "record-agent-binding" in text assert text.index("runtime-apply") < text.index("record-agent-binding") - assert "one shared Dataverse session" in normalized + assert "Do not construct or pass manual\nboolean evidence" in text + assert "--connect-config" in text + assert "one Dataverse token" in normalized assert "delegated" in text assert "User Context V2" in text assert "activate-workday-topics.md" in text - assert text.index("Allow permission") < text.index( - "activate-workday-topics.md" - ) + assert text.index("Allow permission") < text.index("activate-workday-topics.md") def test_workday_topic_activation_uses_complete_mapped_scope() -> None: @@ -94,10 +186,12 @@ def test_workday_topic_activation_uses_complete_mapped_scope() -> None: assert ".component-map.json" in action assert "{AGENT_SCHEMA}.topic.Workday" in action - assert "all 21 Workday dialog topics" in action + assert "all 21 Workday dialog topics" in " ".join(action.split()) assert "--activate --dry-run" in action assert "--activate --yes" in action assert "state` and `status` to `Active`" in action + assert "record-topic-activation" in action + assert "do not treat them as an activation failure" in action assert "--activate" not in redirect agent_dir = ( @@ -116,9 +210,7 @@ def test_workday_topic_activation_uses_complete_mapped_scope() -> None: for path, entry in component_map.items() if isinstance(entry, dict) and entry.get("componentKind") == "DialogComponent" - and str(entry.get("schemaName") or "").split(".")[-1].startswith( - "Workday" - ) + and str(entry.get("schemaName") or "").split(".")[-1].startswith("Workday") and str(entry.get("displayName") or "").startswith("Workday") ] assert len(workday_topics) == 21 @@ -138,18 +230,12 @@ def test_readiness_requires_real_employee_runtime_evidence() -> None: def test_capability_claims_match_controller_surface() -> None: skill = (_WORKDAY_DA / "SKILL.md").read_text(encoding="utf-8") - entra = (_WORKDAY_DA / "provision-entra-app.md").read_text( - encoding="utf-8" - ) - tenant = (_WORKDAY_DA / "configure-tenant.md").read_text( - encoding="utf-8" - ) - power_platform = ( - _WORKDAY_DA / "configure-power-platform.md" - ).read_text(encoding="utf-8") - employee = (_WORKDAY_DA / "verify-connection.md").read_text( + entra = (_WORKDAY_DA / "provision-entra-app.md").read_text(encoding="utf-8") + tenant = (_WORKDAY_DA / "configure-tenant.md").read_text(encoding="utf-8") + power_platform = (_WORKDAY_DA / "configure-power-platform.md").read_text( encoding="utf-8" ) + employee = (_WORKDAY_DA / "verify-connection.md").read_text(encoding="utf-8") normalized = { "skill": " ".join(skill.split()), @@ -160,21 +246,43 @@ def test_capability_claims_match_controller_surface() -> None: } assert "## Capability contract" in skill - assert "Claim an automated change only after" in normalized["skill"] - assert "does not create or modify the Entra application" in ( - normalized["entra"] + assert "## Tenant foundation and deployment scope" in skill + assert ( + "must not by itself require the Entra or Workday administrators" + in (normalized["skill"]) ) + assert "show this readiness briefing on every invocation" in normalized["skill"] + for required_role in ( + "Power Platform Environment Maker", + "Application Administrator or Cloud Application Administrator", + "Workday Administrator", + "Dataverse System Administrator", + "Workday test employee", + ): + assert required_role in skill + assert ( + "isn't ready until the signed-in Workday scenario succeeds" + in (normalized["skill"]) + ) + assert "Claim an automated change only after" in normalized["skill"] + assert "does not create or modify the Entra application" in (normalized["entra"]) assert "record-entra" in normalized["entra"] + assert "foundationReuse.eligible" in entra + assert "Expose an API" in entra + assert "Sign SAML response and assertion" in entra + assert "administrator-attestation" in entra assert "This phase never modifies Workday" in normalized["tenant"] - assert "does not create physical connector connections" in ( - normalized["power_platform"] - ) - assert "do not describe it as automatically verified" in ( - normalized["power_platform"] - ) - assert "These are real automated changes" in ( - normalized["power_platform"] + assert "Create x509 Public Key" in tenant + assert "Client Grant Type" in tenant + assert "Include Workday Owned Scope" in tenant + assert "Confirm network readiness" in tenant + assert ( + "does not create physical connector connections" + in (normalized["power_platform"]) ) + assert "reruns `WD-REST-002` and `WD-CONN-013`" in (normalized["power_platform"]) + assert "no topic contains an error diagnostic" in (normalized["power_platform"]) + assert "These are real automated changes" in (normalized["power_platform"]) assert "The skill cannot publish the agent" in normalized["employee"] @@ -183,9 +291,18 @@ def test_runtime_apply_persists_verified_stages_before_later_failure( tmp_path: Path, ) -> None: import workday_connect as controller + import workday_connect_model as model store = controller.WorkdayConnectStore(tmp_path) store.initialize() + for phase_id in ("preflight", "entra", "workday-admin", "connections"): + for action in model.PHASE_REQUIRED_ACTIONS[phase_id]: + store.complete_action( + phase_id, + action, + evidence={"outcome": "verified"}, + ) + store.set_phase_status(phase_id, "complete") def fail_after_two_stages(_state, **kwargs): recorder = kwargs["stage_recorder"] @@ -222,6 +339,6 @@ def fail_after_two_stages(_state, **kwargs): "connection-references-bound", "runtime-flows-active", ] - assert { - record["action"] for record in phase["evidence"] - } == set(phase["completedActions"]) + assert {record["action"] for record in phase["evidence"]} == set( + phase["completedActions"] + ) From a8351f357b378beb2f90fdb4a795a1240e8429a6 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Sat, 26 Sep 2026 21:03:15 -0700 Subject: [PATCH 15/20] Treat Workday topic diagnostics as advisory --- .../scripts/workday_connect.py | 42 +++---------------- .../scripts/workday_connect_agent.py | 4 +- .../scripts/workday_connect_contracts.py | 11 +++-- .../actions/activate-workday-topics.md | 10 ++--- .../workday-da/configure-power-platform.md | 37 +++++++--------- tests/scripts/test_workday_connect_agent.py | 19 +++++---- .../scripts/test_workday_connect_contracts.py | 41 +++++++++--------- tests/setup/test_workday_da_orchestration.py | 9 +++- 8 files changed, 73 insertions(+), 100 deletions(-) diff --git a/solutions/ess-maker-skills/scripts/workday_connect.py b/solutions/ess-maker-skills/scripts/workday_connect.py index 7a4ffd6a..efacd205 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect.py +++ b/solutions/ess-maker-skills/scripts/workday_connect.py @@ -344,7 +344,9 @@ def _record_agent_binding( "environmentId": evidence["environmentId"], "botId": evidence["botId"], "makerUsername": evidence["makerUsername"], - "blockingDiagnostics": evidence["workdayTopics"]["blockingDiagnostics"], + "observedTopicDiagnostics": ( + evidence["workdayTopics"]["blockingDiagnostics"] + ), }, ) store.complete_action( @@ -385,44 +387,10 @@ def _record_topic_activation( }, ) diagnostics = topics["blockingDiagnostics"] - if diagnostics: - missing_flows = [ - diagnostic - for diagnostic in diagnostics - if diagnostic.get("referenceType") == "CloudFlow" - and diagnostic.get("errorCode") == "NotFound" - ] - if missing_flows: - error_type = "NativeFlowRegistrationBlocked" - message = ( - "The installed Workday agent is missing " - f"{len(missing_flows)} required flow registration(s). All " - "Workday topics are enabled, but final connection verification " - "cannot complete until those registrations are available. " - f"{len(diagnostics)} related diagnostic(s) were reported." - ) - else: - error_type = "NativeTopicDiagnosticBlocked" - message = ( - "All Workday topics are enabled, but " - f"{len(diagnostics)} topic configuration error(s) remain. " - "Resolve those errors before final connection verification." - ) - store.set_phase_status( - "runtime", - "blocked", - blocker={ - "operation": "record-agent-binding", - "errorType": error_type, - "message": message, - }, - ) - else: - store.set_phase_status("runtime", "active") + store.set_phase_status("runtime", "active") return { "verified": True, - "flowHealth": "blocked" if diagnostics else "ready", - "blockingDiagnostics": diagnostics, + "diagnosticsObserved": len(diagnostics), "status": store.status(), } diff --git a/solutions/ess-maker-skills/scripts/workday_connect_agent.py b/solutions/ess-maker-skills/scripts/workday_connect_agent.py index baa87fd9..0dc0a440 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_agent.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_agent.py @@ -187,7 +187,7 @@ def verify_agent_binding( [dict[str, Any]], MinimalBotEvaluationClient ] = MinimalBotEvaluationClient.from_config, ) -> dict[str, Any]: - """Verify final Workday agent binding from live checkpoints and topics.""" + """Verify Workday binding without using topic diagnostics as runtime proof.""" context = _agent_verification_context(workspace_root, state) checkpoints = { checkpoint_id: checkpoint_verifier( @@ -201,7 +201,7 @@ def verify_agent_binding( evidence = _verify_workday_topics( context, client_factory=client_factory, - require_clean_diagnostics=True, + require_clean_diagnostics=False, ) evidence["checkpoints"] = checkpoints return evidence diff --git a/solutions/ess-maker-skills/scripts/workday_connect_contracts.py b/solutions/ess-maker-skills/scripts/workday_connect_contracts.py index 518ac0a2..7f7de560 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_contracts.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_contracts.py @@ -778,11 +778,12 @@ def validate_agent_binding_evidence( or isinstance(active_count, bool) or verified_count != expected_count or active_count != expected_count - or diagnostics != [] + or not isinstance(diagnostics, list) + or any(not isinstance(diagnostic, Mapping) for diagnostic in diagnostics) ): raise WorkdayConnectContractError( - "Every mapped Workday topic must be active, verified, and free " - "of blocking diagnostics." + "Every mapped Workday topic must be active and verified, and " + "reported topic diagnostics must be structured." ) return { "environmentId": observed_environment, @@ -795,7 +796,9 @@ def validate_agent_binding_evidence( "expected": expected_count, "verified": verified_count, "active": active_count, - "blockingDiagnostics": [], + "blockingDiagnostics": [ + dict(diagnostic) for diagnostic in diagnostics + ], }, } diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/actions/activate-workday-topics.md b/solutions/ess-maker-skills/src/skills/connect/workday/actions/activate-workday-topics.md index d2d96341..c32aba82 100644 --- a/solutions/ess-maker-skills/src/skills/connect/workday/actions/activate-workday-topics.md +++ b/solutions/ess-maker-skills/src/skills/connect/workday/actions/activate-workday-topics.md @@ -87,8 +87,8 @@ native MinimalBot components endpoint, preserves the dialog body, sets both `state` and `status` to `Active`, and rereads every component. Continue when the command reports that all `{WORKDAY_TOPIC_COUNT}` topics were verified Active. The command may also report dependency diagnostics such as -`CloudFlow NotFound`; preserve those diagnostics for the separate native-flow -registration check, but do not treat them as an activation failure. +`CloudFlow NotFound`; preserve those diagnostics for support correlation, but +do not treat them as an activation failure or proof of a broken package. Record the live activation evidence: @@ -98,9 +98,9 @@ python scripts/workday_connect.py record-topic-activation This controller command resolves the same complete mapped Workday topic set, authenticates as the recorded maker, and rereads `state` and `status` for every -topic. It accepts no manual boolean evidence. If dependency diagnostics remain, -the command records activation as complete and leaves the runtime phase blocked -on native flow registration. +topic. It accepts no manual boolean evidence. The command records activation as +complete when all mapped topics are Active; topic metadata diagnostics do not +create a runtime blocker by themselves. Set `ACTION_RESULT = "applied"` and `WORKDAY_TOPICS_ACTIVATED = true`. On any error or count mismatch, stop and diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md index 33b766d4..5645b2ec 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md @@ -150,24 +150,13 @@ complete. Run `WD-REST-002` after the scoped push and require it to pass: python scripts/flightcheck/cli.py --checkpoint WD-REST-002 --connect-config ".local/connect/workday-da/config.json" --agent-slug "{AGENT_SLUG}" --preferred-username "{POWER_PLATFORM_MAKER}" ``` -If a Workday system topic shows `CloudFlow ... not found`, stop the runtime -phase. The reviewed topic IDs already match the installed Dataverse workflows, -so this diagnostic means the native agent is missing its cloud-flow definition -registration. Connection-reference binding, flow activation, delegated -authorization, connection sign-in, and parameter sharing do not create that -native definition. - -- **Workday System Get User Context V2** -> **ESS Workday Runtime** -- **Workday System Get REST Execution** -> - **ESS Workday Runtime REST Execution** -- **Workday System Get CommonExecution** -> - **ESS Workday Runtime References** and **ESS Workday Runtime** - -Do not recreate, clone, reselect, or rewrite these flows as an automated -workaround. Record the missing registration as a package or native-agent import -blocker and leave runtime incomplete. The supported installation/import path -must materialize the native flow definitions before final agent binding can -pass. Topic activation itself is independent of dependency health. +Topic metadata can report stale or transient component-reference diagnostics +even when the installed package and runtime flows are functioning. Record those +diagnostics for support, but do not treat them alone as proof of a broken +package, tell the customer to repair the installation, or block this phase. +Continue with the supported live checkpoints, topic-state verification, and +employee scenario. Do not recreate, clone, reselect, or rewrite packaged flows +in response to topic metadata alone. Then open the agent connection settings. Connect **ESS Workday Runtime REST Execution** and any other Workday flow shown there. The reviewed native agent @@ -204,10 +193,10 @@ python scripts/workday_connect.py record-topic-activation ``` This command proves that every Workday topic included with the agent is -enabled. A missing required flow registration remains a separate runtime -blocker and does not change the activation result. +enabled. Topic diagnostics are retained as supporting detail but do not change +the activation result or create a package-repair blocker by themselves. -After native flow registration is healthy, run: +Then run: ```powershell python scripts/workday_connect.py record-agent-binding @@ -217,8 +206,10 @@ This command reruns `WD-REST-002` and `WD-CONN-013` with the recorded Workday state, signs in to the native components endpoint as the recorded maker, derives the complete Workday topic set from `.component-map.json`, and rereads every mapped topic. It completes the runtime phase only when every checkpoint -passes, every Workday topic is Active, and no topic contains an error -diagnostic such as `CloudFlow NotFound`. Do not construct or pass manual +passes and every Workday topic is Active. It retains any topic diagnostics for +support correlation without presenting them as runtime failure evidence. The +signed-in employee scenario remains the functional confirmation that the +Workday runtime works. Do not construct or pass manual boolean evidence. If runtime discovery reports that the selected package has no reviewed flow diff --git a/tests/scripts/test_workday_connect_agent.py b/tests/scripts/test_workday_connect_agent.py index 39243872..bc5f07bb 100644 --- a/tests/scripts/test_workday_connect_agent.py +++ b/tests/scripts/test_workday_connect_agent.py @@ -129,7 +129,7 @@ def checkpoint(_root, _state, checkpoint_id, *, preferred_username): "blockingDiagnostics": [], } assert all( - expectation["requireCleanDiagnostics"] is True + expectation["requireCleanDiagnostics"] is False for expectation in client.expectations ) @@ -343,7 +343,13 @@ def test_record_agent_binding_completes_only_from_verifier_output( "expected": 21, "verified": 21, "active": 21, - "blockingDiagnostics": [], + "blockingDiagnostics": [ + { + "errorCode": "NotFound", + "errorMessage": "CloudFlow not found", + "referenceType": "CloudFlow", + } + ], }, }, ) @@ -359,7 +365,7 @@ def test_record_agent_binding_completes_only_from_verifier_output( assert "workday-topics-activated" in runtime["completedActions"] -def test_record_topic_activation_preserves_flow_registration_blocker( +def test_record_topic_activation_does_not_infer_runtime_failure_from_diagnostics( tmp_path: Path, monkeypatch, ) -> None: @@ -412,11 +418,10 @@ def test_record_topic_activation_preserves_flow_registration_blocker( runtime = store.load()["phases"]["runtime"] assert result["verified"] is True - assert result["flowHealth"] == "blocked" + assert result["diagnosticsObserved"] == 1 assert "workday-topics-activated" in runtime["completedActions"] - assert runtime["status"] == "blocked" - assert runtime["blocker"]["errorType"] == "NativeFlowRegistrationBlocked" - assert "missing 1 required flow registration(s)" in (runtime["blocker"]["message"]) + assert runtime["status"] == "active" + assert runtime["blocker"] is None def test_record_agent_binding_rejects_manual_boolean_evidence( diff --git a/tests/scripts/test_workday_connect_contracts.py b/tests/scripts/test_workday_connect_contracts.py index eafd0d2e..1d07caf4 100644 --- a/tests/scripts/test_workday_connect_contracts.py +++ b/tests/scripts/test_workday_connect_contracts.py @@ -530,28 +530,27 @@ def test_agent_binding_and_employee_evidence_are_strict(): == 21 ) - with pytest.raises( - WorkdayConnectContractError, - match="Every mapped Workday topic", - ): - validate_agent_binding_evidence( - state, - { - "environmentId": "environment-id", - "botId": "bot-id", - "makerUsername": "maker@example.com", - "checkpoints": { - "WD-REST-002": "Passed", - "WD-CONN-013": "Passed", - }, - "workdayTopics": { - "expected": 21, - "verified": 21, - "active": 21, - "blockingDiagnostics": [{"errorCode": "NotFound"}], - }, + diagnostic_evidence = validate_agent_binding_evidence( + state, + { + "environmentId": "environment-id", + "botId": "bot-id", + "makerUsername": "maker@example.com", + "checkpoints": { + "WD-REST-002": "Passed", + "WD-CONN-013": "Passed", }, - ) + "workdayTopics": { + "expected": 21, + "verified": 21, + "active": 21, + "blockingDiagnostics": [{"errorCode": "NotFound"}], + }, + }, + ) + assert diagnostic_evidence["workdayTopics"]["blockingDiagnostics"] == [ + {"errorCode": "NotFound"} + ] with pytest.raises(WorkdayConnectContractError, match="unsupported fields"): validate_employee_evidence( diff --git a/tests/setup/test_workday_da_orchestration.py b/tests/setup/test_workday_da_orchestration.py index ecadf433..7644cc60 100644 --- a/tests/setup/test_workday_da_orchestration.py +++ b/tests/setup/test_workday_da_orchestration.py @@ -281,7 +281,14 @@ def test_capability_claims_match_controller_surface() -> None: in (normalized["power_platform"]) ) assert "reruns `WD-REST-002` and `WD-CONN-013`" in (normalized["power_platform"]) - assert "no topic contains an error diagnostic" in (normalized["power_platform"]) + assert ( + "do not treat them alone as proof of a broken package" + in (normalized["power_platform"]) + ) + assert ( + "signed-in employee scenario remains the functional confirmation" + in (normalized["power_platform"]) + ) assert "These are real automated changes" in (normalized["power_platform"]) assert "The skill cannot publish the agent" in normalized["employee"] From 3f43af088e96567ccf2c1f4b3efc19e935df26b4 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Sat, 26 Sep 2026 21:26:56 -0700 Subject: [PATCH 16/20] Streamline Workday administrator verification --- .../scripts/workday_connect_contracts.py | 213 ++++++++++++++++-- .../src/skills/setup/workday-da/SKILL.md | 14 +- .../setup/workday-da/configure-tenant.md | 70 +++--- .../setup/workday-da/provision-entra-app.md | 20 +- .../scripts/test_workday_connect_contracts.py | 79 ++++++- tests/setup/test_workday_da_foundation.py | 14 +- 6 files changed, 344 insertions(+), 66 deletions(-) diff --git a/solutions/ess-maker-skills/scripts/workday_connect_contracts.py b/solutions/ess-maker-skills/scripts/workday_connect_contracts.py index 7f7de560..a2c6d147 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_contracts.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_contracts.py @@ -412,6 +412,34 @@ def build_workday_admin_packet( "entraAppIdUri", "Entra application ID URI", ) + entra_tenant_id = _required_text( + scope, + "entraTenantId", + "Microsoft Entra tenant ID", + ) + expected_issuer = f"https://sts.windows.net/{entra_tenant_id}/" + signing_certificate = identifiers.get("signingCertificate") + if not isinstance(signing_certificate, Mapping): + raise WorkdayConnectContractError( + "Verified Entra signing certificate metadata is required before " + "building the Workday administrator packet." + ) + certificate_valid_from = _date_only( + _required_text( + signing_certificate, + "validFrom", + "Entra signing certificate Valid From", + ), + "Entra signing certificate Valid From", + ) + certificate_valid_to = _date_only( + _required_text( + signing_certificate, + "validTo", + "Entra signing certificate Valid To", + ), + "Entra signing certificate Valid To", + ) if _normalized_uri(entity_id) == _normalized_uri(entra_app_id_uri): raise WorkdayConnectContractError( "The Workday SAML Service Provider ID and Entra application ID URI " @@ -427,6 +455,9 @@ def build_workday_admin_packet( "referenceValues": { "serviceProviderId": entity_id, "entraApplicationIdUri": entra_app_id_uri, + "expectedIdentityProviderIssuer": expected_issuer, + "certificateValidFrom": certificate_valid_from, + "certificateValidTo": certificate_valid_to, }, "identityProviderQuestion": { "question": ( @@ -460,6 +491,28 @@ def build_workday_admin_packet( "I'm not sure", ], }, + "issuerConfirmationQuestion": { + "question": ( + "Does the Issuer in the enabled Microsoft Entra SAML row " + f"exactly match {expected_issuer}?" + ), + "options": [ + "Yes, it matches exactly", + "No, the displayed Issuer is different", + "I'm not sure", + ], + }, + "certificateValidityQuestion": { + "question": ( + "Do the selected Workday certificate dates exactly match " + f"{certificate_valid_from} through {certificate_valid_to}?" + ), + "options": [ + "Yes, both dates match exactly", + "No, one or both dates are different", + "I'm not sure", + ], + }, "actions": [ "Identify which sign-in provider the enabled Workday SAML row " "uses before changing it", @@ -478,11 +531,10 @@ def build_workday_admin_packet( ], "responseForm": { "required": [ - "activeIdentityProviderIssuer", + "identityProviderOutcome", "enabledServiceProviderId", - "certificateName", - "certificateValidFrom", - "certificateValidTo", + "certificateSelectionOutcome", + "certificateValidityOutcome", "oauthClientId", "oauthTokenUrl", "restBaseUrl", @@ -492,7 +544,8 @@ def build_workday_admin_packet( ], "note": ( "Return configuration evidence only. Do not paste passwords, " - "client secrets, tokens, cookies, or certificate private keys." + "client secrets, tokens, cookies, or certificate private keys. " + "The Workday certificate display name is optional." ), }, } @@ -546,8 +599,11 @@ def validate_workday_admin_response( ) allowed = { "activeIdentityProviderIssuer", + "identityProviderOutcome", "enabledServiceProviderId", "certificateName", + "certificateSelectionOutcome", + "certificateValidityOutcome", "certificateValidFrom", "certificateValidTo", "oauthClientId", @@ -565,6 +621,39 @@ def validate_workday_admin_response( ) scope = state.get("scope") or {} tenant = _required_text(scope, "workdayTenant", "Workday tenant") + entra_tenant_id = _required_text( + scope, + "entraTenantId", + "Microsoft Entra tenant ID", + ) + expected_issuer = f"https://sts.windows.net/{entra_tenant_id}/" + identity_provider_outcome = str( + response.get("identityProviderOutcome") or "" + ).strip() + supplied_identity_provider_issuer = str( + response.get("activeIdentityProviderIssuer") or "" + ).strip() + if identity_provider_outcome: + if identity_provider_outcome != "verified-entra-issuer": + raise WorkdayConnectContractError( + "identityProviderOutcome must confirm that the enabled Workday " + "Issuer exactly matches the verified Microsoft Entra tenant." + ) + if supplied_identity_provider_issuer and ( + _normalized_uri(supplied_identity_provider_issuer) + != _normalized_uri(expected_issuer) + ): + raise WorkdayConnectContractError( + "The supplied Workday Issuer conflicts with the verified " + "Microsoft Entra issuer confirmation." + ) + active_identity_provider_issuer = expected_issuer + else: + active_identity_provider_issuer = _required_text( + response, + "activeIdentityProviderIssuer", + "activeIdentityProviderIssuer", + ) expected_entity_id = workday_saml_entity_id(tenant) observed_entity_id = _required_text( response, @@ -604,10 +693,6 @@ def validate_workday_admin_response( "Workday SOAP base URL", ) required = { - "activeIdentityProviderIssuer", - "certificateName", - "certificateValidFrom", - "certificateValidTo", "oauthClientId", "authenticationPolicyOutcome", "networkReadinessOutcome", @@ -632,14 +717,6 @@ def validate_workday_admin_response( "Verified Entra signing certificate metadata is required before " "recording Workday administrator evidence." ) - workday_valid_from = _date_only( - values["certificateValidFrom"], - "Workday certificate Valid From", - ) - workday_valid_to = _date_only( - values["certificateValidTo"], - "Workday certificate Valid To", - ) entra_valid_from = _date_only( _required_text( signing_certificate, @@ -656,11 +733,98 @@ def validate_workday_admin_response( ), "Entra signing certificate Valid To", ) - if workday_valid_from != entra_valid_from or workday_valid_to != entra_valid_to: + certificate_selection_outcome = str( + response.get("certificateSelectionOutcome") or "" + ).strip() + if certificate_selection_outcome: + if certificate_selection_outcome != "entra-signing-certificate-selected": + raise WorkdayConnectContractError( + "certificateSelectionOutcome must confirm that the Workday " + "row uses the certificate created from the verified Entra " + "signing certificate." + ) + elif not str(response.get("certificateName") or "").strip(): raise WorkdayConnectContractError( - "The Workday X.509 certificate validity dates do not match the " - "verified Entra signing certificate." + "certificateSelectionOutcome is required when no optional Workday " + "certificate display name is supplied." ) + certificate_validity_outcome = str( + response.get("certificateValidityOutcome") or "" + ).strip() + if certificate_validity_outcome: + if certificate_validity_outcome != "matches-verified-entra-certificate": + raise WorkdayConnectContractError( + "certificateValidityOutcome must confirm that both Workday " + "certificate dates exactly match the verified Entra certificate." + ) + workday_valid_from = entra_valid_from + workday_valid_to = entra_valid_to + supplied_valid_from = str( + response.get("certificateValidFrom") or "" + ).strip() + supplied_valid_to = str(response.get("certificateValidTo") or "").strip() + if supplied_valid_from and ( + _date_only( + supplied_valid_from, + "Workday certificate Valid From", + ) + != entra_valid_from + ): + raise WorkdayConnectContractError( + "The supplied Workday certificate Valid From date conflicts " + "with the verified certificate-date confirmation." + ) + if supplied_valid_to and ( + _date_only( + supplied_valid_to, + "Workday certificate Valid To", + ) + != entra_valid_to + ): + raise WorkdayConnectContractError( + "The supplied Workday certificate Valid To date conflicts " + "with the verified certificate-date confirmation." + ) + else: + workday_valid_from = _date_only( + _required_text( + response, + "certificateValidFrom", + "certificateValidFrom", + ), + "Workday certificate Valid From", + ) + workday_valid_to = _date_only( + _required_text( + response, + "certificateValidTo", + "certificateValidTo", + ), + "Workday certificate Valid To", + ) + if ( + workday_valid_from != entra_valid_from + or workday_valid_to != entra_valid_to + ): + raise WorkdayConnectContractError( + "The Workday X.509 certificate validity dates do not match the " + "verified Entra signing certificate." + ) + certificate_name = str(response.get("certificateName") or "").strip() + certificate_evidence = { + "certificateSelectionOutcome": ( + certificate_selection_outcome + or "legacy-certificate-name-and-dates-confirmed" + ), + "certificateValidityOutcome": ( + certificate_validity_outcome + or "legacy-explicit-dates-matched" + ), + "certificateValidFrom": workday_valid_from, + "certificateValidTo": workday_valid_to, + } + if certificate_name: + certificate_evidence["certificateName"] = certificate_name return { "identifiers": { "workdaySamlEntityId": expected_entity_id, @@ -672,11 +836,12 @@ def validate_workday_admin_response( "soapBaseUrl": soap_base_url, }, "evidence": { - "activeIdentityProviderIssuer": values["activeIdentityProviderIssuer"], + "activeIdentityProviderIssuer": active_identity_provider_issuer, + "identityProviderOutcome": ( + identity_provider_outcome or "legacy-exact-issuer-supplied" + ), "serviceProviderId": expected_entity_id, - "certificateName": values["certificateName"], - "certificateValidFrom": workday_valid_from, - "certificateValidTo": workday_valid_to, + **certificate_evidence, "authenticationPolicyOutcome": values["authenticationPolicyOutcome"], "networkReadinessOutcome": values["networkReadinessOutcome"], }, diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md index cd8ca798..cd519d70 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md @@ -45,10 +45,16 @@ Customer-facing messages must describe only: Translate internal outcomes into plain language. For example, say **all Workday topics are enabled**, not that component `state` and `status` are -`Active`; say **the installed Workday agent is missing required flow -registrations**, not `CloudFlow NotFound`, `MinimalBot`, component-map, or -native-definition terminology. Never paste raw command output or internal -identifiers into a customer message. +`Active`. Topic metadata diagnostics are support context and must not be +translated into a missing-package, missing-flow, or runtime-failure claim by +themselves. Never show `CloudFlow NotFound`, `MinimalBot`, component-map, +native-definition terminology, raw command output, tracebacks, encoding +errors, or internal identifiers in a customer message. + +Read `WORKDAY_CONNECT_RESULT_JSON` directly. Do not create an ad hoc Python or +PowerShell formatter merely to render controller status. If an internal +formatting command fails, correct or retry it internally and show only the +validated customer-facing result. ## Capability contract diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md index aa567dec..57316689 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md @@ -33,9 +33,13 @@ is available, then guide them through these steps in order: Handle the answer as follows: - - **Microsoft Entra ID** - continue. Ask the administrator to copy the - exact **Issuer** value shown in that Workday row. Do not save the option - label as the issuer. + - **Microsoft Entra ID** - continue. Present + `issuerConfirmationQuestion`, which shows the exact issuer expected from + the verified Entra tenant. If the administrator confirms an exact match, + record `identityProviderOutcome` as `verified-entra-issuer`; do not make + them retype the value. If it differs, collect the exact displayed + **Issuer** in the single response form. Do not infer a match from the + provider choice alone. - **Okta**, **Ping Identity**, or **Another sign-in provider** - stop before changing the row. Explain that the current row belongs to an existing sign-in configuration and must not be replaced. Ask the Workday and @@ -51,9 +55,8 @@ is available, then guide them through these steps in order: is still unclear, stop and ask the identity administrator rather than guessing. - After the supported Microsoft Entra row is identified or created, record - its exact Issuer, Service Provider ID, X.509 certificate name, and - certificate validity dates. + After the supported Microsoft Entra row is identified or created, verify + its Issuer and Service Provider ID. 2. **Install the Entra signing certificate.** In Entra, open **Enterprise applications -> the exact Workday application -> Single sign-on -> SAML Signing Certificate** and download **Certificate (Base64)**. In Workday, run @@ -68,9 +71,14 @@ is available, then guide them through these steps in order: Handle the answer as follows: - - **The new certificate created from the Entra Base64 file** - continue. - Ask the administrator to copy the exact certificate name displayed by - Workday and its **Valid From** and **Valid To** dates. + - **The new certificate created from the Entra Base64 file** - record + `certificateSelectionOutcome` as + `entra-signing-certificate-selected`, then present + `certificateValidityQuestion`. If both displayed dates exactly match the + verified Entra dates shown in that question, record + `certificateValidityOutcome` as + `matches-verified-entra-certificate`. The customer-created certificate + display name is optional support context, not a completion gate. - **A different existing Workday certificate** - stop. Do not replace or reuse it until the Workday and identity administrators confirm it is the same active Entra signing certificate. @@ -81,8 +89,7 @@ is available, then guide them through these steps in order: SAML row's **X509 Certificate** field. If they still cannot identify the selected key, stop rather than guessing. - Compare the copied Workday **Valid From** and **Valid To** values with the - active Entra certificate. Never collect the certificate body in chat. + Never collect the certificate body in chat. 3. **Configure tenant security.** Return to **Edit Tenant Setup - Security**. Enable **OAuth 2.0 Clients Enabled** and **SAML**. In SAML Setup, set the exact Service Provider ID to @@ -108,13 +115,16 @@ is available, then guide them through these steps in order: firewall change is required. Do not wait until final employee validation to discover a known allowlist requirement. -Collect one response form containing only: +Collect exactly one response form using one structured `ask_user` call. Do not +ask for these values as a sequence of separate questions: -- exact Issuer value copied from the enabled Microsoft Entra SAML row; +- confirmation that the enabled issuer exactly matches the displayed verified + Entra issuer, or the exact different Issuer value; - enabled Service Provider ID; -- exact certificate name copied from the enabled row's **X509 Certificate** - field; -- certificate Valid From and Valid To dates; +- confirmation that the Entra-derived certificate is selected; +- confirmation that its displayed validity dates exactly match the verified + Entra dates; +- optional Workday certificate display name; - Workday OAuth client ID; - OAuth token URL; - REST base URL ending at `/ccx/api`; @@ -122,6 +132,14 @@ Collect one response form containing only: - authentication-policy outcome; - network-readiness outcome. +Prefill known non-secret reference values from the packet, including the +Service Provider ID, expected Entra issuer, and verified certificate dates. +If the administrator omits a required value or replies only with wording such +as "done", "all good", "continue", or "proceed", do not move to another field, +search workspace files, inspect environment variables, or infer the missing +evidence. Show one concise list of missing items, preserve progress, and stop +until the same consolidated form can be completed. + Never collect a secret, password, token, cookie, certificate body, or private key. Pass the response once: @@ -140,11 +158,10 @@ For example: ```json { - "activeIdentityProviderIssuer": "{exact Issuer value copied from Workday}", + "identityProviderOutcome": "verified-entra-issuer", "enabledServiceProviderId": "http://www.workday.com/{workdayTenant}", - "certificateName": "{exact certificate name copied from Workday}", - "certificateValidFrom": "{ISO-8601 date}", - "certificateValidTo": "{ISO-8601 date}", + "certificateSelectionOutcome": "entra-signing-certificate-selected", + "certificateValidityOutcome": "matches-verified-entra-certificate", "oauthClientId": "{non-secret Workday OAuth client ID}", "oauthTokenUrl": "https://{workday-host}/ccx/oauth2/{tenant}/token", "restBaseUrl": "https://{workday-host}/ccx/api", @@ -154,11 +171,10 @@ For example: } ``` -The controller validates the Service Provider ID, HTTPS endpoints, and exact -REST base suffix. It also requires the Workday certificate validity dates to -match the verified Entra signing certificate before recording the non-secret -identifiers, endpoints, and evidence atomically. It captures the completed -Entra and Workday phases as a tenant foundation that can be reused for another -environment or ESS HR agent. If the administrator is not available, stop here; -rerun +The controller validates the verified Entra issuer, Service Provider ID, HTTPS +endpoints, exact REST base suffix, certificate selection, and certificate-date +match before recording the non-secret identifiers, endpoints, and evidence +atomically. It captures the completed Entra and Workday phases as a tenant +foundation that can be reused for another environment or ESS HR agent. If the +administrator is not available, stop here; rerun `workday-admin-packet` later without losing deployment progress. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md index c81f8b29..75058f71 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md @@ -40,13 +40,16 @@ Build discovery JSON with `displayName`, application `appId`, application run: ```powershell -python scripts/workday_connect.py entra-handoff --discovery-json '{...}' +python scripts/workday_connect.py entra-handoff --discovery-json '{"applications":[{...}]}' ``` If no exact app exists, include `"allowCreate": true` only after the user chooses to create the Workday gallery app. If multiple apps have the exact Service Provider ID, stop for administrator remediation. +Use this current phase guide and the controller contract only. Do not inspect +or reuse the legacy `src/skills/setup/workday/` procedure to fill gaps. + Show the returned target, Service Provider ID, Entra Application ID URI, permissions, and administrator actions once as one handoff. Do not add a separate apply approval: the controller does not perform these portal changes. @@ -100,8 +103,19 @@ application by display name alone. Identifier (Name ID)** so the source attribute equals the Workday User Name used by the tenant, commonly `user.mail` or `user.userPrincipalName`. -After each change, reread the setting where Microsoft Graph exposes it. Do not -ask for a broad “everything is done” confirmation. +After each change, reread the setting where Microsoft Graph exposes it. A +Graph or Azure CLI command is evidence only when it exits with code 0 and +returns valid JSON. Never record a check as verified from partial stdout after +a nonzero exit. Avoid multi-parameter Graph URLs that Windows command wrappers +can split; request the resource with one query parameter and filter the +returned JSON locally when necessary. + +Do not ask for or use a broad "everything is done" confirmation as evidence. +After the Graph reread, use one structured form for only the settings Graph +cannot prove. Ask for the exact selected SAML signing option and the exact +NameID source attribute. A reply such as "done", "all good", "continue", or +"proceed" is not evidence for either field and must not be converted into +administrator attestation. The administrator performs those changes in the Microsoft Entra admin center. After the administrator confirms completion, reread the application and diff --git a/tests/scripts/test_workday_connect_contracts.py b/tests/scripts/test_workday_connect_contracts.py index 1d07caf4..a81f91aa 100644 --- a/tests/scripts/test_workday_connect_contracts.py +++ b/tests/scripts/test_workday_connect_contracts.py @@ -345,6 +345,11 @@ def test_workday_packet_uses_service_provider_id_not_app_id_uri(): { "workdaySamlEntityId": "http://www.workday.com/contoso_impl", "entraAppIdUri": ("api://44444444-4444-4444-4444-444444444444"), + "signingCertificate": { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + }, } ) packet = build_workday_admin_packet(state) @@ -353,6 +358,11 @@ def test_workday_packet_uses_service_provider_id_not_app_id_uri(): "http://www.workday.com/contoso_impl" ) assert packet["referenceValues"]["entraApplicationIdUri"].startswith("api://") + assert packet["referenceValues"]["expectedIdentityProviderIssuer"] == ( + "https://sts.windows.net/00000000-0000-0000-0000-000000000000/" + ) + assert packet["referenceValues"]["certificateValidFrom"] == "2026-01-01" + assert packet["referenceValues"]["certificateValidTo"] == "2027-01-01" provider_question = packet["identityProviderQuestion"] assert "sign-in provider" in provider_question["question"] assert any("Microsoft Entra ID" in option for option in provider_question["options"]) @@ -373,6 +383,11 @@ def test_workday_packet_uses_service_provider_id_not_app_id_uri(): ) assert "No certificate is selected" in certificate_question["options"] assert "I'm not sure" in certificate_question["options"] + assert "exactly match" in packet["issuerConfirmationQuestion"]["question"] + assert "both dates match exactly" in ( + packet["certificateValidityQuestion"]["options"][0] + ) + assert "certificateName" not in packet["responseForm"]["required"] assert "client secrets" in packet["responseForm"]["note"] @@ -382,6 +397,10 @@ def test_workday_packet_rejects_identifier_aliasing(): { "workdaySamlEntityId": "http://www.workday.com/contoso_impl", "entraAppIdUri": "http://www.workday.com/contoso_impl", + "signingCertificate": { + "validFrom": "2026-01-01", + "validTo": "2027-01-01", + }, } ) @@ -395,6 +414,10 @@ def test_workday_packet_rejects_tenant_drift(): { "workdaySamlEntityId": "http://www.workday.com/other", "entraAppIdUri": ("api://44444444-4444-4444-4444-444444444444"), + "signingCertificate": { + "validFrom": "2026-01-01", + "validTo": "2027-01-01", + }, } ) @@ -412,13 +435,12 @@ def test_workday_admin_response_validates_exact_endpoints(): result = validate_workday_admin_response( state, { - "activeIdentityProviderIssuer": ( - "https://sts.windows.net/00000000-0000-0000-0000-000000000000/" - ), + "identityProviderOutcome": "verified-entra-issuer", "enabledServiceProviderId": ("http://www.workday.com/contoso_impl"), - "certificateName": "ESS Workday Entra signing certificate", - "certificateValidFrom": "2026-01-01", - "certificateValidTo": "2027-01-01", + "certificateSelectionOutcome": "entra-signing-certificate-selected", + "certificateValidityOutcome": ( + "matches-verified-entra-certificate" + ), "oauthClientId": "safe-client-id", "oauthTokenUrl": ( "https://example.workday.com/ccx/oauth2/contoso_impl/token" @@ -431,6 +453,10 @@ def test_workday_admin_response_validates_exact_endpoints(): ) assert result["endpoints"]["restBaseUrl"].endswith("/ccx/api") + assert result["evidence"]["activeIdentityProviderIssuer"] == ( + "https://sts.windows.net/00000000-0000-0000-0000-000000000000/" + ) + assert "certificateName" not in result["evidence"] assert result["evidence"]["networkReadinessOutcome"] == ("confirmed-hosts-allowed") @@ -466,6 +492,47 @@ def test_workday_admin_response_rejects_certificate_date_drift(): ) +def test_workday_admin_response_rejects_conflicting_confirmed_defaults(): + state = _state() + state["identifiers"]["signingCertificate"] = { + "thumbprint": "AA11", + "validFrom": "2026-01-01T00:00:00Z", + "validTo": "2027-01-01T00:00:00Z", + } + response = { + "identityProviderOutcome": "verified-entra-issuer", + "enabledServiceProviderId": "http://www.workday.com/contoso_impl", + "certificateSelectionOutcome": "entra-signing-certificate-selected", + "certificateValidityOutcome": "matches-verified-entra-certificate", + "oauthClientId": "safe-client-id", + "oauthTokenUrl": ( + "https://example.workday.com/ccx/oauth2/contoso_impl/token" + ), + "restBaseUrl": "https://example.workday.com/ccx/api", + "soapBaseUrl": "https://example.workday.com/ccx/service/contoso_impl", + "authenticationPolicyOutcome": "existing-active-policy", + "networkReadinessOutcome": "confirmed-hosts-allowed", + } + + with pytest.raises(WorkdayConnectContractError, match="Issuer conflicts"): + validate_workday_admin_response( + state, + { + **response, + "activeIdentityProviderIssuer": "https://sts.windows.net/other/", + }, + ) + + with pytest.raises(WorkdayConnectContractError, match="Valid To date conflicts"): + validate_workday_admin_response( + state, + { + **response, + "certificateValidTo": "2028-01-01", + }, + ) + + def test_workday_admin_rejects_unused_response_fields(): state = _state() state["identifiers"]["signingCertificate"] = { diff --git a/tests/setup/test_workday_da_foundation.py b/tests/setup/test_workday_da_foundation.py index e86352db..781370f8 100644 --- a/tests/setup/test_workday_da_foundation.py +++ b/tests/setup/test_workday_da_foundation.py @@ -72,6 +72,12 @@ def test_entra_and_workday_identifiers_remain_distinct() -> None: assert "Never alias" in schema or "must never be aliases" in schema assert "entra-handoff" in entra assert "workday-admin-packet" in tenant + assert '{"applications":[{...}]}' in entra + assert "exits with code 0" in entra + assert "partial stdout after\na nonzero exit" in entra + assert "legacy `src/skills/setup/workday/` procedure" in entra + assert 'broad "everything is done" confirmation' in entra + assert '"all good", "continue", or\n"proceed"' in entra def test_manual_handoff_is_one_packet_not_row_attestations() -> None: @@ -89,12 +95,16 @@ def test_manual_handoff_is_one_packet_not_row_attestations() -> None: assert "Another sign-in provider" in tenant assert "No enabled SAML row" in tenant assert "I'm not sure" in tenant - assert "Do not save the option\n label as the issuer" in tenant + assert "Do not infer a match from the\n provider choice alone" in tenant assert "certificateSelectionQuestion" in tenant assert "The new certificate created from the Entra Base64 file" in tenant assert "A different existing Workday certificate" in tenant assert "No certificate is selected" in tenant assert "Never suggest, prefill, or ask the administrator to confirm" in tenant - assert "exact certificate name displayed by\n Workday" in tenant + assert "display name is optional support context" in tenant + assert "exactly one response form using one structured `ask_user` call" in tenant + assert '"all good", "continue", or "proceed"' in tenant + assert "do not move to another field" in tenant + assert "search workspace files" in tenant assert "CHECKPOINT_RESULT" not in tenant assert "ACK=true" not in tenant From 06f420c1f20a211c14e4a4842a4239546d0fb117 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Sun, 27 Sep 2026 12:54:41 -0700 Subject: [PATCH 17/20] Harden Workday connection lifecycle Add target-bound approvals, durable runtime evidence, safer state transitions, and structured customer interactions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../.github/copilot-instructions.md | 6 +- .../.github/prompts/connect.prompt.md | 6 +- .../scripts/alm/get_dataverse_token.py | 12 + .../flightcheck/checks/workday_extension.py | 22 +- .../scripts/flightcheck/registry.py | 13 + .../scripts/minimalbot_evaluation.py | 31 +- solutions/ess-maker-skills/scripts/push.py | 16 +- .../scripts/workday_connect.py | 201 +++++++++-- .../scripts/workday_connect_catalog.json | 3 + .../scripts/workday_connect_contracts.py | 336 ++++++++++++------ .../scripts/workday_connect_preflight.py | 184 ++++++++-- .../scripts/workday_connect_runtime.py | 154 ++++++-- .../scripts/workday_connect_store.py | 64 ++-- .../src/skills/connect/SKILL.md | 8 +- .../actions/activate-workday-topics.md | 9 +- .../actions/wire-user-context-redirect.md | 5 +- .../src/skills/setup/workday-da/SKILL.md | 10 + .../workday-da/configure-power-platform.md | 132 +++++-- .../setup/workday-da/configure-tenant.md | 177 +++++++-- .../setup/workday-da/install-extension.md | 45 ++- .../setup/workday-da/provision-entra-app.md | 48 ++- .../setup/workday-da/verify-connection.md | 64 +++- .../checks/test_workday_extension.py | 52 ++- tests/flightcheck/test_registry.py | 16 +- tests/scripts/test_flow_authorization.py | 45 ++- tests/scripts/test_minimalbot_detection.py | 121 ++++++- tests/scripts/test_workday_connect_agent.py | 177 ++++++++- .../scripts/test_workday_connect_contracts.py | 183 +++++++++- .../scripts/test_workday_connect_preflight.py | 266 +++++++++++++- tests/scripts/test_workday_connect_runtime.py | 97 ++++- tests/scripts/test_workday_connect_store.py | 105 ++++++ tests/setup/test_workday_da_foundation.py | 16 +- tests/setup/test_workday_da_orchestration.py | 87 ++++- 33 files changed, 2338 insertions(+), 373 deletions(-) diff --git a/solutions/ess-maker-skills/.github/copilot-instructions.md b/solutions/ess-maker-skills/.github/copilot-instructions.md index 01d20f38..d9f5f186 100644 --- a/solutions/ess-maker-skills/.github/copilot-instructions.md +++ b/solutions/ess-maker-skills/.github/copilot-instructions.md @@ -380,9 +380,9 @@ When helping a customer, match their request to one of these patterns: | Customer says... | Pattern | What to create | |-----------------|---------|---------------| -| "I need to look up X from ServiceNow/Workday" | Product extension required | Explain that DA-GA extension setup guidance is not yet available | -| "I need to create a ticket/case/request" | Product extension required | Explain that DA-GA extension setup guidance is not yet available | -| "I need to show the user their X data" | Product extension required | Explain that DA-GA extension setup guidance is not yet available | +| "I need to look up X from ServiceNow/Workday" | Product extension required | Route connection/setup requests through `src/skills/connect/SKILL.md`; create topics only after the supported integration is connected | +| "I need to create a ticket/case/request" | Product extension required | Route connection/setup requests through `src/skills/connect/SKILL.md`; create topics only after the supported integration is connected | +| "I need to show the user their X data" | Product extension required | Route connection/setup requests through `src/skills/connect/SKILL.md`; create topics only after the supported integration is connected | | "I need to call a non-ESS system (Jira, custom API)" | Standalone Topic + Workflow | Topic + new cloud flow (only for connectors without a shared orchestrator) | | "I need to add a step to an existing flow" | Modify topic | Edit the existing topic YAML | | "I need to change how the agent responds to X" | Modify topic | Update trigger phrases, messages, or conditions | diff --git a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md index bffb68f3..7a45d5e2 100644 --- a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md +++ b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md @@ -31,6 +31,6 @@ Rules: 4. Do not compose your own messages. If there is no Message block for a situation, stay silent and proceed to the next action. -After reading SKILL.md, your first action is to check for -`.local/connect/steps.md`. If starting fresh, your first message to the user -is the checklist table from the Fresh Start section. +After reading `SKILL.md`, follow its integration-specific state and routing +instructions. Do not assume a shared `.local/connect/steps.md` file or a +generic Fresh Start section. diff --git a/solutions/ess-maker-skills/scripts/alm/get_dataverse_token.py b/solutions/ess-maker-skills/scripts/alm/get_dataverse_token.py index cf52d615..d7510cf6 100644 --- a/solutions/ess-maker-skills/scripts/alm/get_dataverse_token.py +++ b/solutions/ess-maker-skills/scripts/alm/get_dataverse_token.py @@ -13,6 +13,10 @@ sys.path.insert(0, str(SCRIPTS_DIR)) import auth # noqa: E402 +from workday_connect_auth import ( # noqa: E402 + WorkdayConnectIdentityError, + require_identity, +) def main() -> int: @@ -25,6 +29,14 @@ def main() -> int: args.environment.rstrip("/"), preferred_username=args.preferred_username, ) + try: + require_identity( + token, + preferred_username=args.preferred_username, + ) + except WorkdayConnectIdentityError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 print(f"ESS_DATAVERSE_TOKEN={token}") return 0 diff --git a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py index d68dc426..650ddbaf 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py @@ -57,6 +57,8 @@ import sys from pathlib import Path +import yaml + from ..runner import CheckResult, Priority, Role, Status from ..agent_scope import resolve_agent_directory, validate_agent_slug @@ -693,8 +695,10 @@ def _check_user_context_redirect(runner) -> list[CheckResult]: )] try: - text = topic_file.read_text(encoding="utf-8", errors="replace") - except OSError as e: + document = yaml.safe_load( + topic_file.read_text(encoding="utf-8", errors="replace") + ) + except (OSError, yaml.YAMLError) as e: return [CheckResult(roles=_MAKER_ROLES, checkpoint_id="WD-REST-002", category=_CATEGORY, priority=Priority.HIGH.value, status=Status.FAILED.value, @@ -708,7 +712,19 @@ def _check_user_context_redirect(runner) -> list[CheckResult]: doc_link=_DOC_SIMPLIFIED, )] - if "BeginDialog" not in text or target_dialog not in text: + def has_redirect(value): + if isinstance(value, dict): + if ( + value.get("kind") == "BeginDialog" + and value.get("dialog") == target_dialog + ): + return True + return any(has_redirect(child) for child in value.values()) + if isinstance(value, list): + return any(has_redirect(child) for child in value) + return False + + if not has_redirect(document): return [CheckResult(roles=_MAKER_ROLES, checkpoint_id="WD-REST-002", category=_CATEGORY, priority=Priority.HIGH.value, status=Status.FAILED.value, diff --git a/solutions/ess-maker-skills/scripts/flightcheck/registry.py b/solutions/ess-maker-skills/scripts/flightcheck/registry.py index a2b14b37..a17bf5c5 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/registry.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/registry.py @@ -376,6 +376,19 @@ class ProfileSpec: priority=Priority.HIGH.value, roles=(Role.POWER_PLATFORM_ADMIN.value,), ), + # WD-CONN-013 reads the selected agent's connection-reference parameter + # sharing configuration directly from Dataverse. It does not call BAP or + # Power Automate APIs, so do not prompt for those additional audiences. + CheckpointSpec( + key="WD-CONN-013", + category_fn=run_workday_checks, + category_label="Workday", + clients=frozenset({DATAVERSE}), + requires_config=True, + requires_dataverse_endpoint=True, + priority=Priority.HIGH.value, + roles=(Role.POWER_PLATFORM_ADMIN.value,), + ), # ---- Workday dynamic families ---- # WD-CONN-* — the generic connection enumerator (connections.py emits # WD-CONN-001 summary + WD-CONN-{i+2:03d} per connection). Exact-first diff --git a/solutions/ess-maker-skills/scripts/minimalbot_evaluation.py b/solutions/ess-maker-skills/scripts/minimalbot_evaluation.py index 25ec565b..79404c99 100644 --- a/solutions/ess-maker-skills/scripts/minimalbot_evaluation.py +++ b/solutions/ess-maker-skills/scripts/minimalbot_evaluation.py @@ -802,17 +802,32 @@ def _verify_dialog_components_payload( raise MinimalBotEvaluationError( "MinimalBot dialog verification returned no component changes." ) - verified_by_id = { - str(component.get("id") or "").casefold(): component - for change in verified_changes - if isinstance(change, dict) - and isinstance((component := change.get("component")), dict) - } + verified_by_id: dict[str, list[dict[str, Any]]] = {} + for change in verified_changes: + if not isinstance(change, dict): + continue + component = change.get("component") + if not isinstance(component, dict): + continue + component_id = str(component.get("id") or "").casefold() + if component_id: + verified_by_id.setdefault(component_id, []).append(component) all_diagnostics: list[dict[str, str]] = [] for update in updates: component_id = str(update["componentId"]) - component = verified_by_id.get(component_id.casefold()) - verified_update = component is not None + matches = verified_by_id.get(component_id.casefold()) or [] + if len(matches) != 1: + raise MinimalBotEvaluationError( + "MinimalBot dialog verification found a missing or " + f"duplicate component ID: {component_id}." + ) + component = matches[0] + expected_schema = str(update.get("schemaName") or "") + verified_update = ( + component.get("$kind") == "DialogComponent" + and str(component.get("schemaName") or "").casefold() + == expected_schema.casefold() + ) if verified_update and "dialog" in update: verified_update = _without_diagnostics( component.get("dialog") diff --git a/solutions/ess-maker-skills/scripts/push.py b/solutions/ess-maker-skills/scripts/push.py index 5fd4b549..072d6726 100644 --- a/solutions/ess-maker-skills/scripts/push.py +++ b/solutions/ess-maker-skills/scripts/push.py @@ -1271,11 +1271,17 @@ def _minimalbot_topic_update_plan( + "; ".join(details) + "." ) - selected_changed = [ - path - for path in selected_changed - if path in workday_by_path - ] + changed_workday = sorted( + path for path in selected_changed if path in workday_by_path + ) + if changed_workday: + raise MinimalBotEvaluationError( + "Workday activation cannot publish pending topic content " + "changes. Push or discard these changes separately before " + "activation: " + + ", ".join(changed_workday) + ) + selected_changed = [] selected_paths = sorted( set(selected_changed) | set(selected_activation) ) diff --git a/solutions/ess-maker-skills/scripts/workday_connect.py b/solutions/ess-maker-skills/scripts/workday_connect.py index efacd205..8e0aa934 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect.py +++ b/solutions/ess-maker-skills/scripts/workday_connect.py @@ -27,6 +27,7 @@ build_workday_admin_packet, validate_agent_binding_evidence, validate_employee_evidence, + validate_employee_failure_evidence, validate_entra_verification, validate_workday_admin_response, ) @@ -60,6 +61,47 @@ def _json_object(value: str, label: str) -> dict[str, Any]: return document +def _add_json_input( + parser: argparse.ArgumentParser, + name: str, + *, + allow_legacy_inline: bool = True, +) -> None: + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( + f"--{name}-file", + type=Path, + help=f"Path to the {name.replace('-', ' ')} JSON object.", + ) + if allow_legacy_inline: + group.add_argument( + f"--{name}-json", + help=argparse.SUPPRESS, + ) + + +def _json_input( + args: argparse.Namespace, + name: str, + label: str, +) -> dict[str, Any]: + file_path = getattr(args, f"{name.replace('-', '_')}_file", None) + if file_path is not None: + try: + value = file_path.read_text(encoding="utf-8") + except OSError as exc: + raise WorkdayConnectStoreError( + f"{label} file could not be read: {file_path}: {exc}" + ) from exc + return _json_object(value, label) + value = getattr(args, f"{name.replace('-', '_')}_json", None) + if value is None: + raise WorkdayConnectStoreError( + f"{label} requires a JSON input file." + ) + return _json_object(value, label) + + def _emit(operation: str, result: dict[str, Any]) -> None: print( RESULT_MARKER @@ -90,12 +132,12 @@ def build_parser() -> argparse.ArgumentParser: tenant.add_argument("--tenant", required=True) entra_handoff = subparsers.add_parser("entra-handoff") - entra_handoff.add_argument("--discovery-json", required=True) + _add_json_input(entra_handoff, "discovery") record_entra = subparsers.add_parser("record-entra") - record_entra.add_argument("--verification-json", required=True) + _add_json_input(record_entra, "verification") subparsers.add_parser("workday-admin-packet") record_admin = subparsers.add_parser("record-workday-admin") - record_admin.add_argument("--response-json", required=True) + _add_json_input(record_admin, "response") runtime_plan = subparsers.add_parser("runtime-plan") runtime_plan.add_argument("--workday-connection-id") @@ -106,23 +148,46 @@ def build_parser() -> argparse.ArgumentParser: runtime_apply.add_argument("--workday-connection-id") runtime_apply.add_argument("--dataverse-connection-id") runtime_approve = subparsers.add_parser("runtime-approve") - runtime_approve.add_argument("--plan-json", required=True) + _add_json_input(runtime_approve, "plan") record_connections = subparsers.add_parser("record-connections") record_connections.add_argument("--workday-connection-id") record_connections.add_argument("--dataverse-connection-id") + record_connections.add_argument( + "--confirm-workday-target", + action="store_true", + ) record_connections.add_argument( "--evidence-json", help=argparse.SUPPRESS, ) subparsers.add_parser("record-topic-activation") record_binding = subparsers.add_parser("record-agent-binding") - record_binding.add_argument("--evidence-json", help=argparse.SUPPRESS) + _add_json_input( + record_binding, + "attachment", + allow_legacy_inline=False, + ) record_validation = subparsers.add_parser("record-validation") - record_validation.add_argument("--evidence-json", required=True) + _add_json_input(record_validation, "evidence") + record_validation_failure = subparsers.add_parser( + "record-validation-failure" + ) + _add_json_input( + record_validation_failure, + "evidence", + allow_legacy_inline=False, + ) preflight = subparsers.add_parser("preflight") preflight.add_argument("--dataverse-url") preflight.add_argument("--maker-username") + preflight.add_argument("--install-plan-hash") + preflight_approve = subparsers.add_parser("preflight-approve") + _add_json_input( + preflight_approve, + "plan", + allow_legacy_inline=False, + ) return parser @@ -154,7 +219,7 @@ def _entra_handoff( return { "packet": build_entra_handoff( store.load(), - _json_object(args.discovery_json, "Entra discovery"), + _json_input(args, "discovery", "Entra discovery"), ) } @@ -165,7 +230,7 @@ def _record_entra( ) -> dict[str, Any]: result = validate_entra_verification( store.load(), - _json_object(args.verification_json, "Entra verification"), + _json_input(args, "verification", "Entra verification"), ) store.merge_section("identifiers", result["identifiers"]) store.complete_action( @@ -207,10 +272,7 @@ def _record_workday_admin( ) -> dict[str, Any]: result = validate_workday_admin_response( store.load(), - _json_object( - args.response_json, - "Workday administrator response", - ), + _json_input(args, "response", "Workday administrator response"), ) store.merge_section("identifiers", result["identifiers"]) store.merge_section("endpoints", result["endpoints"]) @@ -269,7 +331,7 @@ def _runtime_approve( ) -> dict[str, Any]: _, approved_hash = store.approve_plan( "runtime", - _json_object(args.plan_json, "runtime plan"), + _json_input(args, "plan", "runtime plan"), ) return {"planHash": approved_hash, "status": store.status()} @@ -284,11 +346,25 @@ def _record_connections( "record-connections without --evidence-json so the controller " "can verify the live connections." ) + state = store.load() evidence = verify_physical_connections( - store.load(), + state, workday_connection_id=args.workday_connection_id, dataverse_connection_id=args.dataverse_connection_id, ) + if not getattr(args, "confirm_workday_target", False): + identifiers = state.get("identifiers") or {} + endpoints = state.get("endpoints") or {} + return { + "requiresConfirmation": True, + "connections": evidence["connections"], + "workdayTarget": { + "resourceUrl": identifiers.get("workdaySamlEntityId"), + "oauthTokenUrl": endpoints.get("oauthTokenUrl"), + "oauthClientId": identifiers.get("oauthClientId"), + }, + "status": store.status(), + } store.complete_action( "connections", "physical-connections-verified", @@ -297,6 +373,8 @@ def _record_connections( "source": "live-power-platform-discovery", "makerUsername": evidence["makerUsername"], "connections": evidence["connections"], + "connectionIds": evidence["connectionIds"], + "workdayTargetOutcome": "maker-confirmed-against-workday-packet", }, ) store.set_phase_status("connections", "complete") @@ -307,16 +385,17 @@ def _record_agent_binding( args: argparse.Namespace, store: WorkdayConnectStore, ) -> dict[str, Any]: - if getattr(args, "evidence_json", None) is not None: - raise WorkdayConnectStoreError( - "Manual agent-binding evidence is no longer accepted. Run " - "record-agent-binding without --evidence-json so the controller " - "can verify the live agent." - ) + flow_attachment = _json_input( + args, + "attachment", + "Workday flow attachment confirmation", + ) state = store.load() + live_evidence = verify_agent_binding(store.workspace_root, state) + live_evidence["flowAttachment"] = flow_attachment evidence = validate_agent_binding_evidence( state, - verify_agent_binding(store.workspace_root, state), + live_evidence, ) store.complete_action( "runtime", @@ -344,9 +423,11 @@ def _record_agent_binding( "environmentId": evidence["environmentId"], "botId": evidence["botId"], "makerUsername": evidence["makerUsername"], - "observedTopicDiagnostics": ( - evidence["workdayTopics"]["blockingDiagnostics"] + "flowNames": evidence["flowAttachment"]["flowNames"], + "parameterSharingOutcome": ( + evidence["flowAttachment"]["parameterSharingOutcome"] ), + "provenance": "maker-confirmed-selected-agent-settings", }, ) store.complete_action( @@ -387,7 +468,6 @@ def _record_topic_activation( }, ) diagnostics = topics["blockingDiagnostics"] - store.set_phase_status("runtime", "active") return { "verified": True, "diagnosticsObserved": len(diagnostics), @@ -400,10 +480,7 @@ def _record_validation( store: WorkdayConnectStore, ) -> dict[str, Any]: evidence = validate_employee_evidence( - _json_object( - args.evidence_json, - "employee validation evidence", - ) + _json_input(args, "evidence", "employee validation evidence") ) store.complete_action( "employee-validation", @@ -414,6 +491,30 @@ def _record_validation( return {"verified": True, "status": store.status()} +def _record_validation_failure( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + evidence = validate_employee_failure_evidence( + _json_input( + args, + "evidence", + "employee validation failure evidence", + ) + ) + store.set_phase_status( + "employee-validation", + "blocked", + blocker={ + "operation": "record-validation-failure", + "errorType": evidence["failureCategory"], + "message": evidence["remediation"], + "capturedAt": evidence["timestamp"], + }, + ) + return {"recorded": True, "status": store.status()} + + def _preflight( args: argparse.Namespace, store: WorkdayConnectStore, @@ -423,7 +524,24 @@ def _preflight( dataverse_url=args.dataverse_url, maker_username=args.maker_username, store=store, + approved_install_hash=args.install_plan_hash, + plan_verifier=lambda plan, approved_hash: store.verify_plan( + "preflight", + plan, + approved_hash, + ), + ) + + +def _preflight_approve( + args: argparse.Namespace, + store: WorkdayConnectStore, +) -> dict[str, Any]: + _, approved_hash = store.approve_plan( + "preflight", + _json_input(args, "plan", "preflight installation plan"), ) + return {"planHash": approved_hash, "status": store.status()} _COMMAND_HANDLERS: dict[ @@ -443,7 +561,9 @@ def _preflight( "record-topic-activation": _record_topic_activation, "record-agent-binding": _record_agent_binding, "record-validation": _record_validation, + "record-validation-failure": _record_validation_failure, "preflight": _preflight, + "preflight-approve": _preflight_approve, } _COMMAND_PHASES = { @@ -460,6 +580,8 @@ def _preflight( "record-topic-activation": "runtime", "record-agent-binding": "runtime", "record-validation": "employee-validation", + "record-validation-failure": "employee-validation", + "preflight-approve": "preflight", } @@ -483,6 +605,7 @@ def main() -> None: WorkdayConnectStoreError, ) as exc: phase_id = _COMMAND_PHASES.get(args.command) + blocker_persistence_error = None if phase_id: try: store.set_phase_status( @@ -498,19 +621,19 @@ def main() -> None: OSError, WorkdayConnectModelError, WorkdayConnectStoreError, - ): - pass + ) as persistence_exc: + blocker_persistence_error = str(persistence_exc) + error_payload = { + "contractVersion": CONTROLLER_CONTRACT_VERSION, + "operation": args.command, + "error": str(exc), + "errorType": type(exc).__name__, + } + if blocker_persistence_error: + error_payload["blockerPersistenceError"] = blocker_persistence_error print( ERROR_MARKER - + json.dumps( - { - "contractVersion": CONTROLLER_CONTRACT_VERSION, - "operation": args.command, - "error": str(exc), - "errorType": type(exc).__name__, - }, - sort_keys=True, - ), + + json.dumps(error_payload, sort_keys=True), file=sys.stderr, ) raise SystemExit(1) from exc diff --git a/solutions/ess-maker-skills/scripts/workday_connect_catalog.json b/solutions/ess-maker-skills/scripts/workday_connect_catalog.json index 3ba28e11..010bbc81 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_catalog.json +++ b/solutions/ess-maker-skills/scripts/workday_connect_catalog.json @@ -20,6 +20,9 @@ "ESS Workday Runtime References", "ESS Workday Runtime REST Execution", "ESS Workday Runtime" + ], + "agentConnectionFlowNames": [ + "ESS Workday Runtime REST Execution" ] }, "legacy-da": { diff --git a/solutions/ess-maker-skills/scripts/workday_connect_contracts.py b/solutions/ess-maker-skills/scripts/workday_connect_contracts.py index a2c6d147..89d36c3f 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_contracts.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_contracts.py @@ -5,13 +5,15 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timezone +import ipaddress from typing import Any, Mapping from urllib.parse import urlparse from workday_connect_model import ( PhaseStatus, WorkdayConnectModelError, + load_catalog, workday_saml_entity_id, ) @@ -231,7 +233,10 @@ def _normalize_entra_check(name: str, value: Any) -> dict[str, Any]: raise WorkdayConnectContractError( f"Entra verification check '{name}' must contain evidence." ) - unexpected = sorted(set(value) - {"outcome", "provenance"}) + allowed = {"outcome", "provenance"} + if name in {"nameId", "samlSigningOption"}: + allowed.add("observedValue") + unexpected = sorted(set(value) - allowed) if unexpected: raise WorkdayConnectContractError( f"Entra verification check '{name}' contains unsupported fields: " @@ -286,10 +291,27 @@ def _normalize_entra_check(name: str, value: Any) -> dict[str, Any]: "Entra verification check 'nameId' has an outcome that does " "not match its provenance." ) - return { + result = { "outcome": outcome, "provenance": provenance, } + if name in {"nameId", "samlSigningOption"}: + observed_value = _required_text( + value, + "observedValue", + f"Entra verification check '{name}' observed value", + ) + if ( + name == "samlSigningOption" + and observed_value.casefold() + != "sign saml response and assertion".casefold() + ): + raise WorkdayConnectContractError( + "Entra verification check 'samlSigningOption' must record " + "'Sign SAML response and assertion'." + ) + result["observedValue"] = observed_value + return result def validate_entra_verification( @@ -555,15 +577,36 @@ def build_workday_admin_packet( def _https_url(value: Any, label: str) -> str: text = str(value or "").strip().rstrip("/") parsed = urlparse(text) + try: + port = parsed.port + except ValueError as exc: + raise WorkdayConnectContractError( + f"{label} must be an HTTPS URL." + ) from exc if ( parsed.scheme.casefold() != "https" or not parsed.netloc + or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment + or port not in {None, 443} ): raise WorkdayConnectContractError(f"{label} must be an HTTPS URL.") + hostname = parsed.hostname.casefold().rstrip(".") + try: + ipaddress.ip_address(hostname) + except ValueError: + pass + else: + raise WorkdayConnectContractError( + f"{label} must use a Workday service hostname, not an IP address." + ) + if not hostname.endswith((".workday.com", ".myworkday.com")): + raise WorkdayConnectContractError( + f"{label} must use a Workday-owned service hostname." + ) return text @@ -633,27 +676,20 @@ def validate_workday_admin_response( supplied_identity_provider_issuer = str( response.get("activeIdentityProviderIssuer") or "" ).strip() - if identity_provider_outcome: - if identity_provider_outcome != "verified-entra-issuer": - raise WorkdayConnectContractError( - "identityProviderOutcome must confirm that the enabled Workday " - "Issuer exactly matches the verified Microsoft Entra tenant." - ) - if supplied_identity_provider_issuer and ( - _normalized_uri(supplied_identity_provider_issuer) - != _normalized_uri(expected_issuer) - ): - raise WorkdayConnectContractError( - "The supplied Workday Issuer conflicts with the verified " - "Microsoft Entra issuer confirmation." - ) - active_identity_provider_issuer = expected_issuer - else: - active_identity_provider_issuer = _required_text( - response, - "activeIdentityProviderIssuer", - "activeIdentityProviderIssuer", + if identity_provider_outcome != "verified-entra-issuer": + raise WorkdayConnectContractError( + "identityProviderOutcome must confirm that the enabled Workday " + "Issuer exactly matches the verified Microsoft Entra tenant." + ) + if supplied_identity_provider_issuer and ( + _normalized_uri(supplied_identity_provider_issuer) + != _normalized_uri(expected_issuer) + ): + raise WorkdayConnectContractError( + "The supplied Workday Issuer conflicts with the verified " + "Microsoft Entra issuer confirmation." ) + active_identity_provider_issuer = expected_issuer expected_entity_id = workday_saml_entity_id(tenant) observed_entity_id = _required_text( response, @@ -692,6 +728,15 @@ def validate_workday_admin_response( f"/ccx/service/{tenant}", "Workday SOAP base URL", ) + endpoint_hosts = { + str(urlparse(value).hostname or "").casefold() + for value in (oauth_token_url, rest_base_url, soap_base_url) + } + if len(endpoint_hosts) != 1: + raise WorkdayConnectContractError( + "Workday OAuth, REST, and SOAP endpoints must use the same " + "verified Workday service hostname." + ) required = { "oauthClientId", "authenticationPolicyOutcome", @@ -736,90 +781,52 @@ def validate_workday_admin_response( certificate_selection_outcome = str( response.get("certificateSelectionOutcome") or "" ).strip() - if certificate_selection_outcome: - if certificate_selection_outcome != "entra-signing-certificate-selected": - raise WorkdayConnectContractError( - "certificateSelectionOutcome must confirm that the Workday " - "row uses the certificate created from the verified Entra " - "signing certificate." - ) - elif not str(response.get("certificateName") or "").strip(): + if certificate_selection_outcome != "entra-signing-certificate-selected": raise WorkdayConnectContractError( - "certificateSelectionOutcome is required when no optional Workday " - "certificate display name is supplied." + "certificateSelectionOutcome must confirm that the Workday row " + "uses the certificate created from the verified Entra signing " + "certificate." ) certificate_validity_outcome = str( response.get("certificateValidityOutcome") or "" ).strip() - if certificate_validity_outcome: - if certificate_validity_outcome != "matches-verified-entra-certificate": - raise WorkdayConnectContractError( - "certificateValidityOutcome must confirm that both Workday " - "certificate dates exactly match the verified Entra certificate." - ) - workday_valid_from = entra_valid_from - workday_valid_to = entra_valid_to - supplied_valid_from = str( - response.get("certificateValidFrom") or "" - ).strip() - supplied_valid_to = str(response.get("certificateValidTo") or "").strip() - if supplied_valid_from and ( - _date_only( - supplied_valid_from, - "Workday certificate Valid From", - ) - != entra_valid_from - ): - raise WorkdayConnectContractError( - "The supplied Workday certificate Valid From date conflicts " - "with the verified certificate-date confirmation." - ) - if supplied_valid_to and ( - _date_only( - supplied_valid_to, - "Workday certificate Valid To", - ) - != entra_valid_to - ): - raise WorkdayConnectContractError( - "The supplied Workday certificate Valid To date conflicts " - "with the verified certificate-date confirmation." - ) - else: - workday_valid_from = _date_only( - _required_text( - response, - "certificateValidFrom", - "certificateValidFrom", - ), + if certificate_validity_outcome != "matches-verified-entra-certificate": + raise WorkdayConnectContractError( + "certificateValidityOutcome must confirm that both Workday " + "certificate dates exactly match the verified Entra certificate." + ) + workday_valid_from = entra_valid_from + workday_valid_to = entra_valid_to + supplied_valid_from = str( + response.get("certificateValidFrom") or "" + ).strip() + supplied_valid_to = str(response.get("certificateValidTo") or "").strip() + if supplied_valid_from and ( + _date_only( + supplied_valid_from, "Workday certificate Valid From", ) - workday_valid_to = _date_only( - _required_text( - response, - "certificateValidTo", - "certificateValidTo", - ), + != entra_valid_from + ): + raise WorkdayConnectContractError( + "The supplied Workday certificate Valid From date conflicts " + "with the verified certificate-date confirmation." + ) + if supplied_valid_to and ( + _date_only( + supplied_valid_to, "Workday certificate Valid To", ) - if ( - workday_valid_from != entra_valid_from - or workday_valid_to != entra_valid_to - ): - raise WorkdayConnectContractError( - "The Workday X.509 certificate validity dates do not match the " - "verified Entra signing certificate." - ) + != entra_valid_to + ): + raise WorkdayConnectContractError( + "The supplied Workday certificate Valid To date conflicts " + "with the verified certificate-date confirmation." + ) certificate_name = str(response.get("certificateName") or "").strip() certificate_evidence = { - "certificateSelectionOutcome": ( - certificate_selection_outcome - or "legacy-certificate-name-and-dates-confirmed" - ), - "certificateValidityOutcome": ( - certificate_validity_outcome - or "legacy-explicit-dates-matched" - ), + "certificateSelectionOutcome": certificate_selection_outcome, + "certificateValidityOutcome": certificate_validity_outcome, "certificateValidFrom": workday_valid_from, "certificateValidTo": workday_valid_to, } @@ -837,9 +844,7 @@ def validate_workday_admin_response( }, "evidence": { "activeIdentityProviderIssuer": active_identity_provider_issuer, - "identityProviderOutcome": ( - identity_provider_outcome or "legacy-exact-issuer-supplied" - ), + "identityProviderOutcome": identity_provider_outcome, "serviceProviderId": expected_entity_id, **certificate_evidence, "authenticationPolicyOutcome": values["authenticationPolicyOutcome"], @@ -861,6 +866,7 @@ def validate_agent_binding_evidence( "botId", "makerUsername", "checkpoints", + "flowAttachment", "workdayTopics", } unexpected = sorted(set(evidence) - allowed) @@ -924,6 +930,76 @@ def validate_agent_binding_evidence( raise WorkdayConnectContractError( "Agent binding verification did not pass: " + ", ".join(failed_checkpoints) ) + flow_attachment = evidence.get("flowAttachment") + if not isinstance(flow_attachment, Mapping): + raise WorkdayConnectContractError( + "Agent binding evidence must contain the maker-confirmed Workday " + "flow attachment." + ) + attachment_allowed = { + "outcome", + "botId", + "flowNames", + "parameterSharingOutcome", + } + attachment_unexpected = sorted( + set(flow_attachment) - attachment_allowed + ) + if attachment_unexpected: + raise WorkdayConnectContractError( + "Workday flow attachment evidence contains unsupported fields: " + + ", ".join(attachment_unexpected) + ) + if flow_attachment.get("outcome") != "maker-confirmed": + raise WorkdayConnectContractError( + "Workday flow attachment must be explicitly confirmed by the maker." + ) + attachment_bot = _required_text( + flow_attachment, + "botId", + "Workday flow attachment agent bot ID", + ) + if attachment_bot.casefold() != expected_bot.casefold(): + raise WorkdayConnectContractError( + "Workday flow attachment confirmation targeted a different agent." + ) + if ( + flow_attachment.get("parameterSharingOutcome") + != "enabled-for-exposed-connections" + ): + raise WorkdayConnectContractError( + "Workday flow attachment must confirm parameter sharing for every " + "connection exposed by the agent." + ) + package_flavor = _required_text( + scope, + "packageFlavor", + "Workday package flavor", + ) + package = (load_catalog().get("packages") or {}).get(package_flavor) + if not isinstance(package, Mapping): + raise WorkdayConnectContractError( + f"Unsupported Workday package flavor: {package_flavor}." + ) + expected_flow_names = { + str(name) + for name in package.get("agentConnectionFlowNames") or [] + } + if not expected_flow_names: + raise WorkdayConnectContractError( + "The selected Workday package does not define agent-facing flows." + ) + supplied_flow_names = flow_attachment.get("flowNames") + if ( + not isinstance(supplied_flow_names, list) + or any(not isinstance(name, str) or not name for name in supplied_flow_names) + or len(supplied_flow_names) != len(expected_flow_names) + or set(supplied_flow_names) != expected_flow_names + ): + raise WorkdayConnectContractError( + "Workday flow attachment confirmation must name exactly the " + "reviewed agent-facing Workday flows." + ) topics = evidence.get("workdayTopics") if not isinstance(topics, Mapping): raise WorkdayConnectContractError( @@ -957,6 +1033,12 @@ def validate_agent_binding_evidence( "checkpoints": { checkpoint: "Passed" for checkpoint in sorted(required_checkpoints) }, + "flowAttachment": { + "outcome": "maker-confirmed", + "botId": attachment_bot, + "flowNames": sorted(expected_flow_names), + "parameterSharingOutcome": "enabled-for-exposed-connections", + }, "workdayTopics": { "expected": expected_count, "verified": verified_count, @@ -987,4 +1069,60 @@ def validate_employee_evidence( raise WorkdayConnectContractError( "Employee validation outcome must be passed or verified." ) + category = result["testUserCategory"].casefold() + explicitly_non_maker = ( + "non-maker" in category or "non maker" in category + ) + if ( + "employee" not in category + or "admin" in category + or ("maker" in category and not explicitly_non_maker) + ): + raise WorkdayConnectContractError( + "Employee validation must use a signed-in non-maker employee." + ) + result["timestamp"] = _normalized_timestamp( + result["timestamp"], + "Employee validation timestamp", + ) + return result + + +def _normalized_timestamp(value: str, label: str) -> str: + normalized = value.replace("Z", "+00:00") + try: + observed_at = datetime.fromisoformat(normalized) + except ValueError as exc: + raise WorkdayConnectContractError( + f"{label} must be ISO-8601." + ) from exc + if observed_at.tzinfo is None: + raise WorkdayConnectContractError( + f"{label} must include a timezone." + ) + return observed_at.astimezone(timezone.utc).isoformat().replace( + "+00:00", + "Z", + ) + + +def validate_employee_failure_evidence( + evidence: Mapping[str, Any], +) -> dict[str, str]: + if not isinstance(evidence, Mapping): + raise WorkdayConnectContractError( + "Employee validation failure must contain a JSON object." + ) + allowed = {"failureCategory", "timestamp", "remediation"} + unexpected = sorted(set(evidence) - allowed) + if unexpected: + raise WorkdayConnectContractError( + "Employee validation failure contains unsupported fields: " + + ", ".join(unexpected) + ) + result = {key: _required_text(evidence, key, key) for key in allowed} + result["timestamp"] = _normalized_timestamp( + result["timestamp"], + "Employee validation failure timestamp", + ) return result diff --git a/solutions/ess-maker-skills/scripts/workday_connect_preflight.py b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py index e72b5488..65546836 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_preflight.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py @@ -22,7 +22,7 @@ authentication_plan, require_identity, ) -from workday_connect_model import load_catalog +from workday_connect_model import load_catalog, plan_hash from workday_connect_store import WorkdayConnectStore @@ -322,24 +322,32 @@ def resolve_target( "Dataverse URL for the recorded environment ID. Refresh setup " "before continuing." ) - exact_url = foundation_url or inventory_url or stored_url - if supplied_url: - if exact_url and ( - _normalize_dataverse_url(supplied_url) - != _normalize_dataverse_url(exact_url) - ): - raise WorkdayConnectPreflightError( - "The supplied Dataverse URL does not match the URL recorded " - "for the setup environment ID." - ) - if not exact_url: - exact_url = supplied_url + exact_url = foundation_url or inventory_url if not exact_url: - exact_url = _pac_dataverse_url( + pac_url = _pac_dataverse_url( environment_id, pac_resolver=pac_resolver, runner=pac_runner, ) + if not pac_url: + raise WorkdayConnectPreflightError( + "PAC could not prove the Dataverse URL for the recorded " + "environment ID. Refresh Power Platform authentication and " + "try again." + ) + exact_url = pac_url + for candidate, source in ( + (stored_url, "stored Workday state"), + (supplied_url, "supplied Dataverse URL"), + ): + if candidate and ( + _normalize_dataverse_url(candidate) + != _normalize_dataverse_url(exact_url) + ): + raise WorkdayConnectPreflightError( + f"The {source} does not match the URL proven for the setup " + "environment ID." + ) exact_url = exact_url.rstrip("/") if not exact_url: raise WorkdayConnectPreflightError( @@ -405,6 +413,10 @@ def run_preflight( installer: Callable[..., dict[str, Any]] = install_workday_package, identity_provider: Callable[..., dict[str, str]] = require_identity, catalog: dict[str, Any] | None = None, + approved_install_hash: str | None = None, + plan_verifier: Callable[[dict[str, Any], str], Any] | None = None, + pac_resolver: Callable[[], Path] = resolve_pac_executable, + pac_runner: Callable[..., subprocess.CompletedProcess] = subprocess.run, ) -> dict[str, Any]: active_catalog = catalog or load_catalog() state_store = store or WorkdayConnectStore(workspace_root) @@ -415,17 +427,42 @@ def run_preflight( ).get("username") or "" ).strip() - intended_maker = str(maker_username or stored_maker or "").strip() or None + preflight_phase = (state.get("phases") or {}).get("preflight") or {} + approved_plan = preflight_phase.get("approvedPlan") or {} + approved_scope = ( + approved_plan.get("scope") + if isinstance(approved_plan, dict) + else {} + ) + approved_maker = ( + str((approved_scope or {}).get("makerUsername") or "").strip() + if approved_install_hash + else "" + ) + intended_maker = str( + maker_username or stored_maker or approved_maker or "" + ).strip() or None target = resolve_target( workspace_root, dataverse_url=dataverse_url, state=state, catalog=active_catalog, - ) - token = token_provider( - target.dataverse_url, - preferred_username=intended_maker, + pac_resolver=pac_resolver, + pac_runner=pac_runner, ) + try: + token = token_provider( + target.dataverse_url, + preferred_username=intended_maker, + ) + except SystemExit as exc: + raise WorkdayConnectPreflightError( + "Dataverse authentication did not complete." + ) from exc + except (OSError, RuntimeError, ValueError) as exc: + raise WorkdayConnectPreflightError( + f"Dataverse authentication failed: {exc}" + ) from exc try: identity = identity_provider( token, @@ -433,23 +470,75 @@ def run_preflight( ) except WorkdayConnectIdentityError as exc: raise WorkdayConnectPreflightError(str(exc)) from exc - installed = _installed_solutions( - target.dataverse_url, - token, - query=query, - catalog=active_catalog, - ) + try: + installed = _installed_solutions( + target.dataverse_url, + token, + query=query, + catalog=active_catalog, + ) + except (OSError, RuntimeError, ValueError) as exc: + raise WorkdayConnectPreflightError( + f"Dataverse package discovery failed: {exc}" + ) from exc package = active_catalog["packages"][target.package_flavor] required_schema = package["solutionSchemaName"] package_action = "unchanged" pac_identity = None if required_schema.casefold() not in installed: - install_result = installer( - target.dataverse_url, - target.package_flavor, - ring=target.pac_ring, - preferred_username=identity["username"], - ) + install_plan = { + "phase": "preflight", + "scope": { + "environmentId": target.environment_id, + "dataverseUrl": target.dataverse_url, + "agent": { + key: target.agent.get(key) + for key in ("slug", "botId", "schemaName") + }, + "makerUsername": identity["username"], + }, + "package": { + "flavor": target.package_flavor, + "schemaName": required_schema, + }, + "actions": [ + "Install the supported Workday runtime package", + "Reread Dataverse to verify the package installation", + ], + } + if not approved_install_hash: + return { + "requiresApproval": True, + "plan": { + **install_plan, + "planHash": plan_hash(install_plan), + }, + "approvalSummary": { + "environmentUrl": target.dataverse_url, + "agent": target.agent.get("name") or target.agent.get("slug"), + "makerAccount": identity["username"], + "packageSchema": required_schema, + "actions": install_plan["actions"], + }, + "authenticationPlan": authentication_plan(), + "status": state_store.status(), + } + if plan_verifier is None: + raise WorkdayConnectPreflightError( + "Package installation requires a stored approved plan." + ) + plan_verifier(install_plan, approved_install_hash) + try: + install_result = installer( + target.dataverse_url, + target.package_flavor, + ring=target.pac_ring, + preferred_username=identity["username"], + ) + except (OSError, PacCliError, RuntimeError) as exc: + raise WorkdayConnectPreflightError( + f"Workday package installation failed: {exc}" + ) from exc pac_identity = install_result.get("authenticatedAccount") if not pac_identity or ( pac_identity.casefold() != identity["username"].casefold() @@ -458,12 +547,17 @@ def run_preflight( "PAC package installation did not prove the intended " "Environment Maker account." ) - installed = _installed_solutions( - target.dataverse_url, - token, - query=query, - catalog=active_catalog, - ) + try: + installed = _installed_solutions( + target.dataverse_url, + token, + query=query, + catalog=active_catalog, + ) + except (OSError, RuntimeError, ValueError) as exc: + raise WorkdayConnectPreflightError( + f"Post-install package verification failed: {exc}" + ) from exc if required_schema.casefold() not in installed: raise WorkdayConnectPreflightError( "PAC completed, but the required Workday package was not " @@ -471,6 +565,20 @@ def run_preflight( ) package_action = "installed" + existing_operator = ( + state_store.load().get("operators", {}).get("powerPlatformMaker") or {} + ) + existing_credential_stores = ( + existing_operator.get("credentialStores") or {} + if isinstance(existing_operator, dict) + else {} + ) + pac_status = ( + "verified" + if pac_identity + or existing_credential_stores.get("pac") == "verified" + else "not-required" + ) state_store.merge_section( "scope", { @@ -492,7 +600,7 @@ def run_preflight( "tenantId": identity["tenantId"], "credentialStores": { "dataverse-msal": "verified", - "pac": "verified" if pac_identity else "not-required", + "pac": pac_status, }, } }, diff --git a/solutions/ess-maker-skills/scripts/workday_connect_runtime.py b/solutions/ess-maker-skills/scripts/workday_connect_runtime.py index 62b16538..4474d8e7 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_runtime.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_runtime.py @@ -318,9 +318,47 @@ def _runtime_discovery_context( "package": package, "flowNames": [str(name) for name in flow_names], "referencesCatalog": active_catalog["connectionReferences"], + "selectedConnectionIds": _selected_connection_ids(state), } +def _selected_connection_ids( + state: Mapping[str, Any], +) -> dict[str, str]: + phase = (state.get("phases") or {}).get("connections") or {} + for record in reversed(phase.get("evidence") or []): + if ( + isinstance(record, Mapping) + and record.get("action") == "physical-connections-verified" + and isinstance(record.get("connectionIds"), Mapping) + ): + values = record["connectionIds"] + return { + "workday": str(values.get("workday") or "").strip(), + "dataverse": str(values.get("dataverse") or "").strip(), + } + return {"workday": "", "dataverse": ""} + + +def _effective_connection_id( + connector: str, + requested: str | None, + recorded: str, +) -> str | None: + requested_value = str(requested or "").strip() + recorded_value = str(recorded or "").strip() + if ( + requested_value + and recorded_value + and requested_value.casefold() != recorded_value.casefold() + ): + raise WorkdayConnectRuntimeError( + f"The requested {connector} connection ID differs from the " + "connection verified during the Connections phase." + ) + return requested_value or recorded_value or None + + def _build_runtime_discovery( context: Mapping[str, Any], *, @@ -430,10 +468,19 @@ def discover_runtime_plan( ) -> dict[str, Any]: """Discover exact runtime targets and return a stable approval plan.""" context = _runtime_discovery_context(state, catalog) + selected_ids = context["selectedConnectionIds"] workday, dataverse = _discover_physical_connections( context, - workday_connection_id=workday_connection_id, - dataverse_connection_id=dataverse_connection_id, + workday_connection_id=_effective_connection_id( + "Workday", + workday_connection_id, + selected_ids["workday"], + ), + dataverse_connection_id=_effective_connection_id( + "Dataverse", + dataverse_connection_id, + selected_ids["dataverse"], + ), pac_resolver=pac_resolver, pac_auth=pac_auth, runner=runner, @@ -527,17 +574,37 @@ def verify_physical_connections( runner: Callable[..., subprocess.CompletedProcess] = _default_runner, ) -> dict[str, Any]: """Verify selected Workday and Dataverse connections from live state.""" - context = _runtime_discovery_context(state, catalog) - workday, dataverse = _discover_physical_connections( - context, - workday_connection_id=workday_connection_id, - dataverse_connection_id=dataverse_connection_id, - pac_resolver=pac_resolver, - pac_auth=pac_auth, - runner=runner, - ) + try: + context = _runtime_discovery_context(state, catalog) + selected_ids = context["selectedConnectionIds"] + workday, dataverse = _discover_physical_connections( + context, + workday_connection_id=_effective_connection_id( + "Workday", + workday_connection_id, + selected_ids["workday"], + ), + dataverse_connection_id=_effective_connection_id( + "Dataverse", + dataverse_connection_id, + selected_ids["dataverse"], + ), + pac_resolver=pac_resolver, + pac_auth=pac_auth, + runner=runner, + ) + except WorkdayConnectRuntimeError: + raise + except (OSError, RuntimeError, ValueError, SystemExit) as exc: + raise WorkdayConnectRuntimeError( + f"Power Platform connection discovery failed: {exc}" + ) from exc return { "makerUsername": context["maker"], + "connectionIds": { + "workday": str(workday.get("name") or ""), + "dataverse": str(dataverse.get("name") or ""), + }, "connections": [ { "connector": context["referencesCatalog"]["workday"]["connectorName"], @@ -588,10 +655,19 @@ def run_runtime_operation( "username", "Power Platform maker account", ) - token = token_provider( - environment_url, - preferred_username=maker, - ) + try: + token = token_provider( + environment_url, + preferred_username=maker, + ) + except SystemExit as exc: + raise WorkdayConnectRuntimeError( + "Dataverse authentication did not complete." + ) from exc + except (OSError, RuntimeError, ValueError) as exc: + raise WorkdayConnectRuntimeError( + f"Dataverse authentication failed: {exc}" + ) from exc try: identity = identity_provider( token, @@ -599,15 +675,22 @@ def run_runtime_operation( ) except WorkdayConnectIdentityError as exc: raise WorkdayConnectRuntimeError(str(exc)) from exc - discovery = discover_runtime_plan( - state, - workday_connection_id=workday_connection_id, - dataverse_connection_id=dataverse_connection_id, - token=token, - token_provider=token_provider, - query=query, - **discovery_dependencies, - ) + try: + discovery = discover_runtime_plan( + state, + workday_connection_id=workday_connection_id, + dataverse_connection_id=dataverse_connection_id, + token=token, + token_provider=token_provider, + query=query, + **discovery_dependencies, + ) + except WorkdayConnectRuntimeError: + raise + except (OSError, RuntimeError, ValueError, SystemExit) as exc: + raise WorkdayConnectRuntimeError( + f"Runtime target discovery failed: {exc}" + ) from exc if not apply: return {**discovery, "authenticatedAccount": identity["username"]} if not approved_hash or verifier is None: @@ -615,14 +698,21 @@ def run_runtime_operation( "Runtime apply requires an approved plan hash." ) verifier(discovery["plan"], approved_hash) - applied = apply_runtime_plan( - discovery["plan"], - token=token, - query=query, - updater=updater, - authorization_runner=authorization_runner, - stage_recorder=stage_recorder, - ) + try: + applied = apply_runtime_plan( + discovery["plan"], + token=token, + query=query, + updater=updater, + authorization_runner=authorization_runner, + stage_recorder=stage_recorder, + ) + except WorkdayConnectRuntimeError: + raise + except (OSError, RuntimeError, ValueError, SystemExit) as exc: + raise WorkdayConnectRuntimeError( + f"Runtime apply failed: {exc}" + ) from exc return { "observedBeforeApply": discovery["observed"], "applied": applied, diff --git a/solutions/ess-maker-skills/scripts/workday_connect_store.py b/solutions/ess-maker-skills/scripts/workday_connect_store.py index a3a22767..a381eadd 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_store.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_store.py @@ -447,6 +447,37 @@ def _invalidate_from_phase( _reset_phase(state["phases"][definition.identifier.value]) +def _invalidate_after_phase( + state: dict[str, Any], + phase_id: str, +) -> None: + matched = False + for definition in PHASE_DEFINITIONS: + if matched: + _reset_phase(state["phases"][definition.identifier.value]) + if definition.identifier.value == phase_id: + matched = True + + +def _upgrade_or_migrate_state( + document: Mapping[str, Any], +) -> dict[str, Any]: + source_version = document.get("schemaVersion") + if source_version == 4: + return upgrade_v4_state(document) + if source_version == 3: + return upgrade_v3_state(document) + if source_version == 2: + return upgrade_v2_state(document) + if "schemaVersion" in document: + raise WorkdayConnectStoreError( + "Unsupported Workday connect state schema version: " + f"{source_version!r}. Use the kit version that created this state " + "or restore a compatible backup." + ) + return migrate_legacy_state(document) + + def _scope_invalidation_phase(changed_keys: set[str]) -> str: if changed_keys & _PREFLIGHT_SCOPE_KEYS: return "preflight" @@ -574,15 +605,7 @@ def initialize(self) -> dict[str, Any]: if not self.backup_path.exists(): self.backup_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(self.config_path, self.backup_path) - source_version = existing.get("schemaVersion") - if source_version == 4: - state = upgrade_v4_state(existing) - elif source_version == 3: - state = upgrade_v3_state(existing) - elif source_version == 2: - state = upgrade_v2_state(existing) - else: - state = migrate_legacy_state(existing) + state = _upgrade_or_migrate_state(existing) _atomic_write_json(self.config_path, state) return state @@ -602,15 +625,7 @@ def _mutate(self, mutation) -> dict[str, Any]: elif current.get("schemaVersion") != STATE_SCHEMA_VERSION: if not self.backup_path.exists(): shutil.copy2(self.config_path, self.backup_path) - source_version = current.get("schemaVersion") - if source_version == 4: - current = upgrade_v4_state(current) - elif source_version == 3: - current = upgrade_v3_state(current) - elif source_version == 2: - current = upgrade_v2_state(current) - else: - current = migrate_legacy_state(current) + current = _upgrade_or_migrate_state(current) state = copy.deepcopy(validate_state(current)) mutation(state) state["status"] = ( @@ -740,6 +755,11 @@ def mutation(state: dict[str, Any]) -> None: f"Complete '{prerequisite.value}' before '{phase_id}'." ) phase = state["phases"][phase_id] + if ( + phase["status"] == PhaseStatus.COMPLETE.value + and status != PhaseStatus.COMPLETE.value + ): + _invalidate_after_phase(state, phase_id) if status == PhaseStatus.COMPLETE.value: required = PHASE_REQUIRED_ACTIONS[phase_id] completed = set(phase["completedActions"]) @@ -814,10 +834,10 @@ def approve_plan( phase_id: str, plan: Mapping[str, Any], ) -> tuple[dict[str, Any], str]: - if phase_id != "runtime": + if phase_id not in {"preflight", "runtime"}: raise WorkdayConnectStoreError( - "Exact apply-plan approval is supported only for the " - "controller-owned runtime phase." + "Exact apply-plan approval is supported only for preflight " + "installation and controller-owned runtime changes." ) if plan.get("phase") != phase_id: raise WorkdayConnectStoreError( @@ -836,6 +856,8 @@ def mutation(state: dict[str, Any]) -> None: f"Complete '{prerequisite.value}' before approving '{phase_id}'." ) phase = state["phases"][phase_id] + if phase["status"] == PhaseStatus.COMPLETE.value: + _invalidate_after_phase(state, phase_id) phase["approvedPlan"] = dict(plan) phase["approvedPlanHash"] = approved_hash phase["status"] = PhaseStatus.ACTIVE.value diff --git a/solutions/ess-maker-skills/src/skills/connect/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/SKILL.md index 6fd58398..8ede07a5 100644 --- a/solutions/ess-maker-skills/src/skills/connect/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/connect/SKILL.md @@ -60,6 +60,8 @@ Workday routes by architecture before package detection: `.local/connect/workday/agents/{agent-slug}/lifecycle.json`. DA Workday state is stored only in `.local/connect/workday-da/config.json`. -Each integration's steps.md and config.json persist after completion. -Running `/connect` again lets the user add a different integration -without losing existing ones. +Each integration retains only the state artifacts listed above. ServiceNow +uses its `steps.md` and `config.json`; CEA Workday uses per-agent +`lifecycle.json`; native DA Workday uses only its `config.json`. Running +`/connect` again lets the user add a different integration without losing +existing ones. diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/actions/activate-workday-topics.md b/solutions/ess-maker-skills/src/skills/connect/workday/actions/activate-workday-topics.md index c32aba82..08633b44 100644 --- a/solutions/ess-maker-skills/src/skills/connect/workday/actions/activate-workday-topics.md +++ b/solutions/ess-maker-skills/src/skills/connect/workday/actions/activate-workday-topics.md @@ -43,7 +43,9 @@ python scripts/push.py {WORKDAY_TOPIC_ARGS} --activate --dry-run --preferred-use The preview count must equal the selected component-map count. Every previewed component must be one of the resolved Workday dialog topics. Stop if the count -differs or any non-Workday topic appears. +differs, any non-Workday topic appears, or the command reports pending local +content changes. Content changes require their own scoped review and push; +activation approval never approves topic-content edits. **Message:** @@ -61,7 +63,7 @@ Use the `vscode_askQuestions` tool: "header": "Enable Workday topics", "question": "Enable all Workday topics in the active ESS HR agent?", "options": [ - { "label": "Enable", "recommended": true }, + { "label": "Enable" }, { "label": "Not now" } ], "allowFreeformInput": false @@ -69,6 +71,9 @@ Use the `vscode_askQuestions` tool: ] ``` +Leave the selection unset. Enabling topics is an explicit mutation approval, +not a recommended answer. + If the user selects **Not now**, set `ACTION_RESULT = "cancelled"` and leave the runtime phase active. diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md b/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md index 084695a0..7e2cdd67 100644 --- a/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md +++ b/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md @@ -94,7 +94,7 @@ Use the `vscode_askQuestions` tool: "header": "Publish Workday wiring", "question": "Publish this scoped User Context topic change to the active agent?", "options": [ - { "label": "Publish", "recommended": true }, + { "label": "Publish" }, { "label": "Not now" } ], "allowFreeformInput": false @@ -102,6 +102,9 @@ Use the `vscode_askQuestions` tool: ] ``` +Leave the selection unset. Publishing is an explicit mutation approval, not a +recommended answer. + If the user selects **Not now**, set `ACTION_RESULT = "cancelled"`, return to the lifecycle runner without pushing, and leave the phase `in-progress`. If the user selects **Publish**, run: diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md index cd519d70..35c58388 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md @@ -27,6 +27,16 @@ it is not a customer-executed phase and must not be shown as an extra step. verified result matches the approved plan. - Never diagnose a permission problem from a guess. Show the API, CLI, or checked-in script evidence that produced the diagnosis. +- Use structured `vscode_askQuestions` forms for customer evidence. Never + replace a multi-field form with one large free-text question or ask the + customer to edit a prose template. +- Leave every option initially unset. Do not add `recommended`, `default`, + “recommended” label text, or any equivalent preselection to approvals, + factual observations, connection choices, or validation outcomes. Continue + only after the customer explicitly submits a choice. +- Reuse the exact recorded account through the shared credential cache. Run + only the narrow verification required for the current phase; do not launch + broader checks that request unrelated API audiences. ## Customer-facing language contract diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md index 5645b2ec..845df8e3 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md @@ -9,14 +9,15 @@ result. Do not begin with a yes/no question asking whether both connections are already connected. First explain that this phase needs exactly two Power -Platform connections and run live discovery: +Platform connections, then discover them: ```powershell python scripts/workday_connect.py record-connections ``` -If both required connections are already live, continue without asking the -maker to recreate or reconfirm them. +When both required connections resolve exactly, this read-only pass returns +`requiresConfirmation: true` with safe connection display names and the saved +non-secret Workday target values. It does not mark the phase complete. If the Workday connection is missing or disconnected, read the already validated values from the Workday state and show them with these @@ -62,11 +63,30 @@ Reuse a healthy existing connection when one already exists. Do not create duplicates merely to satisfy the phase, and do not ask the maker to paste connection IDs. -After the maker creates or repairs the missing connection, verify both live -connections again: +Show the safe display name of the selected Workday connection and the three +saved non-secret Workday values. Use `vscode_askQuestions`: + +```json +[ + { + "header": "Confirm Workday connection", + "question": "Was this exact Workday connection created with the displayed Microsoft Entra resource URL, Workday OAuth token URL, and Workday OAuth client ID?", + "options": [ + { "label": "Yes, confirm this connection" }, + { "label": "No, review or repair it" } + ], + "allowFreeformInput": false + } +] +``` + +Leave the selection unset. This is target evidence, not a suggested answer. + +If the maker does not confirm, leave Connections waiting. After confirmation, +verify both live connections using the internally resolved connection IDs: ```powershell -python scripts/workday_connect.py record-connections +python scripts/workday_connect.py record-connections --workday-connection-id "{WORKDAY_CONNECTION_ID}" --dataverse-connection-id "{DATAVERSE_CONNECTION_ID}" --confirm-workday-target ``` The command discovers connected physical connections in the selected @@ -76,7 +96,8 @@ live. - If exactly one connection exists for each connector, discovery is deterministic. - If more than one exists, show safe display names and ask which connection to - use. Resolve the selected display name to its ID internally, then rerun + use. Do not recommend, preselect, or visually favor a connection based on its + name or owner. Resolve the selected display name to its ID internally, then rerun `record-connections` with `--workday-connection-id` and/or `--dataverse-connection-id`; never ask the maker to paste or repeat an ID. @@ -87,21 +108,47 @@ Do not construct or pass manual connection evidence. ## Runtime approval and apply -Run runtime discovery: +Run runtime discovery. The controller reuses the exact connection IDs recorded +in the Connections phase: ```powershell python scripts/workday_connect.py runtime-plan ``` This discovers the installed connection references, supported package flows, -selected agent, and employee-context topics. +and selected agent. Employee-context topic wiring is verified later in this +phase. For a package with a reviewed runtime flow catalog, the controller performs the following writes after exact-plan approval. These are real automated changes, not instructions for the maker: Show only the returned `approvalSummary`, not raw connection, application, -workflow, or bot identifiers. The combined runtime plan will: +workflow, or bot identifiers. Then use `vscode_askQuestions`: + +```json +[ + { + "header": "Apply Workday runtime changes", + "question": "Apply these exact Workday runtime changes to the selected environment and agent?", + "options": [ + { "label": "Apply changes" }, + { "label": "Not now" } + ], + "allowFreeformInput": false + } +] +``` + +Leave the selection unset. Do not represent mutation approval as recommended. + +If the maker selects **Not now**, leave Runtime waiting and do not call +`runtime-approve` or `runtime-apply`. + +If the maker approves, write the returned `plan` object directly to +`.local/connect/workday-da/runtime-plan.json` using a structured file-write +tool. Do not serialize it into a generated shell command. The combined runtime +plan will: - bind the two reviewed connection references; - activate only the checked-in Workday flow catalog; @@ -114,7 +161,7 @@ overwrite or approximate the topic. Approve the exact plan: ```powershell -python scripts/workday_connect.py runtime-approve --plan-json '{...}' +python scripts/workday_connect.py runtime-approve --plan-file ".local\connect\workday-da\runtime-plan.json" ``` Apply using the returned hash and the same disambiguating connection IDs, if @@ -126,13 +173,13 @@ python scripts/workday_connect.py runtime-apply --plan-hash "{hash}" The controller rediscovers the current target, rejects stale approval, reuses one Dataverse token for Python mutations, invokes the checked-in delegated -authorization script, and verifies bindings, flow state, authorization, and -User Context V2 after each ordered stage. It records each verified stage -immediately, so a later failure resumes from durable evidence rather than -hiding earlier successful changes. The runtime phase remains active until the -maker completes the agent binding below. Report permission issues only from -an explicit forbidden response, `[FAIL]` marker, ambiguity result, or nonzero -script exit. +authorization script, and verifies connection-reference bindings, flow state, +and authorization after each ordered stage. User Context V2 and selected-agent +flow attachment are verified separately below. The controller records each +verified stage immediately, so a later failure resumes from durable evidence +rather than hiding earlier successful changes. Report permission issues only +from an explicit forbidden response, `[FAIL]` marker, ambiguity result, or +nonzero script exit. ## Agent binding after flow activation @@ -173,10 +220,38 @@ agent-facing connection contract; the latter grants the Cosmos-backed agent principal access to the reviewed Dataverse workflows. Do not skip or scope the authorization stage solely from `connectionType`. -Run the existing FlightCheck and require `WD-CONN-013` to pass: +Show the exact agent name and the reviewed agent-facing flow +**ESS Workday Runtime REST Execution**. Use `vscode_askQuestions`: + +```json +[ + { + "header": "Confirm Workday flow connection", + "question": "In this exact agent, is ESS Workday Runtime REST Execution connected and is parameter sharing enabled for every Workday connection shown by Copilot Studio?", + "options": [ + { "label": "Yes, confirmed" }, + { "label": "No, review the agent connections" } + ], + "allowFreeformInput": false + } +] +``` -```powershell -python scripts/flightcheck/cli.py --checkpoint WD-CONN-013 --connect-config ".local/connect/workday-da/config.json" --agent-slug "{AGENT_SLUG}" --preferred-username "{POWER_PLATFORM_MAKER}" +Leave the selection unset. This confirmation must reflect what the maker +observed in the selected agent. + +If the maker does not confirm, leave Runtime active. If confirmed, write this +target-bound evidence to +`.local/connect/workday-da/agent-flow-attachment.json` using a structured +file-write tool: + +```json +{ + "outcome": "maker-confirmed", + "botId": "{SELECTED_AGENT_BOT_ID}", + "flowNames": ["ESS Workday Runtime REST Execution"], + "parameterSharingOutcome": "enabled-for-exposed-connections" +} ``` Then run internally: @@ -199,18 +274,21 @@ the activation result or create a package-repair blocker by themselves. Then run: ```powershell -python scripts/workday_connect.py record-agent-binding +python scripts/workday_connect.py record-agent-binding --attachment-file ".local\connect\workday-da\agent-flow-attachment.json" ``` This command reruns `WD-REST-002` and `WD-CONN-013` with the recorded Workday state, signs in to the native components endpoint as the recorded maker, derives the complete Workday topic set from `.component-map.json`, and rereads every mapped topic. It completes the runtime phase only when every checkpoint -passes and every Workday topic is Active. It retains any topic diagnostics for -support correlation without presenting them as runtime failure evidence. The -signed-in employee scenario remains the functional confirmation that the -Workday runtime works. Do not construct or pass manual -boolean evidence. +passes, the target-bound flow attachment is confirmed, and every Workday topic +is Active. It retains any topic diagnostics for support correlation without +presenting them as runtime failure evidence. The signed-in employee scenario +remains the functional confirmation that the Workday runtime works. Do not +substitute an unscoped "done" response for the structured confirmation. +Do not run `WD-CONN-013` separately or ask for the flow-connection +confirmation twice; `record-agent-binding` performs the required live check +after the single target-bound confirmation above. If runtime discovery reports that the selected package has no reviewed flow catalog, record a manual handoff. Do not claim that connection references, diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md index 57316689..664b4c4f 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md @@ -21,21 +21,23 @@ handoff and do not require the Workday administrator again. Continue at Connections. Show only the affected Workday remediation step if a later connection or employee test proves that the stored foundation has drifted. -When no matching foundation can be reused, confirm that a Workday administrator -is available, then guide them through these steps in order: +When no matching foundation can be reused, guide the Workday administrator +through these steps in order. Do not add a separate availability confirmation; +if the administrator is not available, present the handoff and pause before +showing the response form. 1. **Protect the existing federation.** In Workday, run **Edit Tenant Setup - Security** and find **SAML Setup**. In **SAML Identity Providers**, locate the enabled row whose **Used for Environments** value matches the employee - environment being connected. Before asking the administrator to interpret - its **Issuer**, present `identityProviderQuestion` from the packet as one - choice question. The domain examples are recognition clues, not proof. + environment being connected. Use `identityProviderQuestion` and + `issuerConfirmationQuestion` from the packet to populate the corresponding + fields in the consolidated response form below. Do not present them as + separate questions. The domain examples are recognition clues, not proof. Handle the answer as follows: - - **Microsoft Entra ID** - continue. Present - `issuerConfirmationQuestion`, which shows the exact issuer expected from - the verified Entra tenant. If the administrator confirms an exact match, + - **Microsoft Entra ID** - continue. If the administrator confirms that the + displayed Issuer exactly matches the value shown in the form, record `identityProviderOutcome` as `verified-entra-issuer`; do not make them retype the value. If it differs, collect the exact displayed **Issuer** in the single response form. Do not infer a match from the @@ -64,18 +66,18 @@ is available, then guide them through these steps in order: customer-chosen recognizable name, and save it. Return to the enabled Microsoft Entra row and select that key in its **X509 Certificate** field. - Present `certificateSelectionQuestion` from the packet as one choice - question. Never suggest, prefill, or ask the administrator to confirm a - guessed certificate name such as “Microsoft Azure Federated SSO + Use `certificateSelectionQuestion` and `certificateValidityQuestion` from + the packet to populate the corresponding fields in the consolidated form. + Do not present them separately. Never suggest, prefill, or ask the administrator to confirm + a guessed certificate name such as “Microsoft Azure Federated SSO Certificate.” Handle the answer as follows: - **The new certificate created from the Entra Base64 file** - record `certificateSelectionOutcome` as - `entra-signing-certificate-selected`, then present - `certificateValidityQuestion`. If both displayed dates exactly match the - verified Entra dates shown in that question, record + `entra-signing-certificate-selected`. If both displayed dates exactly + match the verified Entra dates shown in the form, record `certificateValidityOutcome` as `matches-verified-entra-certificate`. The customer-created certificate display name is optional support context, not a completion gate. @@ -115,8 +117,10 @@ is available, then guide them through these steps in order: firewall change is required. Do not wait until final employee validation to discover a known allowlist requirement. -Collect exactly one response form using one structured `ask_user` call. Do not -ask for these values as a sequence of separate questions: +Collect exactly one response form using one structured +`vscode_askQuestions` call. Do not collapse these fields into a multiline text +box, ask the administrator to edit a prose template, or ask for these values +as a sequence of separate chat questions: - confirmation that the enabled issuer exactly matches the displayed verified Entra issuer, or the exact different Issuer value; @@ -132,19 +136,148 @@ ask for these values as a sequence of separate questions: - authentication-policy outcome; - network-readiness outcome. -Prefill known non-secret reference values from the packet, including the -Service Provider ID, expected Entra issuer, and verified certificate dates. +Use this exact form, substituting the packet's expected issuer, Service +Provider ID, and verified certificate dates: + +```json +[ + { + "header": "Identity provider", + "question": "Which sign-in provider does the enabled Workday SAML row use?", + "options": [ + { "label": "Microsoft Entra ID", "description": "Issuer commonly contains login.microsoftonline.com or sts.windows.net" }, + { "label": "Okta", "description": "Issuer commonly contains okta.com" }, + { "label": "Ping Identity", "description": "Issuer commonly contains pingone.com, pingidentity.com, or an organization-specific Ping host" }, + { "label": "Another sign-in provider" }, + { "label": "No enabled SAML row" }, + { "label": "I'm not sure" } + ], + "allowFreeformInput": false + }, + { + "header": "Issuer", + "question": "Does the enabled Microsoft Entra SAML row's Issuer exactly match {EXPECTED_ENTRA_ISSUER}?", + "options": [ + { "label": "Yes, it matches exactly" }, + { "label": "No, the displayed Issuer is different" }, + { "label": "I'm not sure" } + ], + "allowFreeformInput": false + }, + { + "header": "Different issuer", + "question": "If the Issuer is different, enter the exact displayed value. Otherwise leave this blank." + }, + { + "header": "Service Provider ID", + "question": "Enter the enabled Service Provider ID. Expected value: {EXPECTED_SERVICE_PROVIDER_ID}" + }, + { + "header": "Certificate", + "question": "Which certificate is selected on the enabled Microsoft Entra SAML row in Workday?", + "options": [ + { "label": "The new certificate created from the Entra Base64 file" }, + { "label": "A different existing Workday certificate" }, + { "label": "No certificate is selected" }, + { "label": "I'm not sure" } + ], + "allowFreeformInput": false + }, + { + "header": "Certificate dates", + "question": "Do the selected Workday certificate dates exactly match {CERTIFICATE_VALID_FROM} through {CERTIFICATE_VALID_TO}?", + "options": [ + { "label": "Yes, both dates match exactly" }, + { "label": "No, one or both dates are different" }, + { "label": "I'm not sure" } + ], + "allowFreeformInput": false + }, + { + "header": "Certificate name", + "question": "Optional: enter the Workday certificate display name, or leave this blank." + }, + { + "header": "OAuth client ID", + "question": "Enter the non-secret OAuth client ID shown by Workday." + }, + { + "header": "OAuth token URL", + "question": "Enter the OAuth token URL shown by Workday." + }, + { + "header": "REST base URL", + "question": "Enter the Workday REST base URL ending at /ccx/api." + }, + { + "header": "SOAP base URL", + "question": "Enter the Workday SOAP service base URL." + }, + { + "header": "Authentication policy", + "question": "What did the administrator verify for the employee authentication policy?", + "options": [ + { "label": "An existing active policy allows SAML" }, + { "label": "A reviewed policy was activated" }, + { "label": "I'm not sure" } + ], + "allowFreeformInput": false + }, + { + "header": "Network readiness", + "question": "What did the administrator verify for the Workday service hosts?", + "options": [ + { "label": "Both Workday hosts are allowed" }, + { "label": "No customer-managed firewall change is required" }, + { "label": "I'm not sure" } + ], + "allowFreeformInput": false + } +] +``` + +Leave every field and option initially unset. Do not add `recommended`, +`default`, suggested-answer wording, or any equivalent preselection. The +expected values in the question text are comparison references, not answers. + +Map the submitted fields to the controller response object: + +- **Microsoft Entra ID** plus **Yes, it matches exactly** -> + `identityProviderOutcome: verified-entra-issuer`; +- a different Issuer -> `activeIdentityProviderIssuer`, then stop for identity + administrator review instead of submitting successful evidence; +- **The new certificate created from the Entra Base64 file** -> + `certificateSelectionOutcome: entra-signing-certificate-selected`; +- matching certificate dates -> + `certificateValidityOutcome: matches-verified-entra-certificate`; +- the optional display name -> `certificateName`; +- the six entered connection/policy fields -> their corresponding controller + keys; +- existing active policy -> `existing-active-policy`; +- reviewed and activated policy -> `reviewed-policy-activated`; +- both hosts allowed -> `confirmed-hosts-allowed`; +- no firewall change required -> + `no-customer-firewall-change-required`. + +Any unsupported provider, mismatch, missing certificate, date mismatch, or +**I'm not sure** answer is a remediation outcome, not successful evidence. +Show the affected remediation step and keep the phase waiting. + If the administrator omits a required value or replies only with wording such as "done", "all good", "continue", or "proceed", do not move to another field, search workspace files, inspect environment variables, or infer the missing -evidence. Show one concise list of missing items, preserve progress, and stop -until the same consolidated form can be completed. +evidence. Reopen the same structured form with only the missing or invalid +fields; never replace it with a free-text request for several numbered answers. +Preserve progress and stop until the structured form is complete. Never collect a secret, password, token, cookie, certificate body, or private -key. Pass the response once: +key. Write the response directly to +`.local/connect/workday-da/workday-admin-response.json` using a structured +file-write tool; never interpolate administrator-entered values into a +generated shell command. Pass the response once: ```powershell -python scripts/workday_connect.py record-workday-admin --response-json '{...}' +python scripts/workday_connect.py record-workday-admin --response-file ".local\connect\workday-da\workday-admin-response.json" ``` Use these exact outcome values: diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md index fe3095fa..49eb9ddc 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md @@ -8,7 +8,7 @@ Explain only credential stores that may prompt during this phase: PAC is a separate Microsoft credential store, so this can be one additional sign-in. The controller pins and verifies the resulting PAC account. -Then run: +Run read-only preflight discovery: ```powershell python scripts/workday_connect.py preflight @@ -45,14 +45,53 @@ Use `--maker-username` only to pin an intended maker account or resolve account ambiguity. On resume, the controller reuses the previously verified maker identity automatically. -The command performs the complete phase: +The command verifies the target and checks whether the package already exists. +When the package is already installed, it completes the phase without an +installation approval. + +When the returned result contains `requiresApproval: true`, show only its +`approvalSummary`. Then use `vscode_askQuestions`: + +```json +[ + { + "header": "Install Workday package", + "question": "Install the supported Workday package in the verified Power Platform environment?", + "options": [ + { "label": "Install" }, + { "label": "Not now" } + ], + "allowFreeformInput": false + } +] +``` + +Leave the selection unset until the maker explicitly chooses. + +If the maker selects **Not now**, leave Preflight waiting and return to the +lifecycle runner. Do not call either approval or apply. + +If the maker selects **Install**, write the returned `plan` object directly to +`.local/connect/workday-da/preflight-plan.json` using a structured file-write +tool. Do not serialize it into a generated shell command. Then run: + +```powershell +python scripts/workday_connect.py preflight-approve --plan-file ".local\connect\workday-da\preflight-plan.json" +python scripts/workday_connect.py preflight --install-plan-hash "{PLAN_HASH}" +``` + +The second `preflight` call rediscovers the target and rejects the operation if +the approved package, environment, agent, or maker changed. + +The completed phase: - verifies the selected setup-complete native ESS HR agent; - chooses the architecture-specific Workday package; - verifies the exact Dataverse URL directly rather than relying on inventory visibility; - verifies the authenticated account and Entra tenant; -- detects or installs the package through PAC; and +- detects the package or installs it only after exact-plan approval through + PAC; and - rereads Dataverse to prove the package is installed. This is a controller-owned automated change. It is accurate to say the package diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md index 75058f71..1139f8f7 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md @@ -36,11 +36,13 @@ configure, update, grant, or enable an Entra setting. Never select by display name alone and never use substring matching. Build discovery JSON with `displayName`, application `appId`, application -`objectId`, service-principal `servicePrincipalId`, and `identifierUris`, then -run: +`objectId`, service-principal `servicePrincipalId`, and `identifierUris`. +Write it directly to `.local/connect/workday-da/entra-discovery.json` using a +structured file-write tool; never interpolate Graph values into a generated +shell command. Then run: ```powershell -python scripts/workday_connect.py entra-handoff --discovery-json '{"applications":[{...}]}' +python scripts/workday_connect.py entra-handoff --discovery-file ".local\connect\workday-da\entra-discovery.json" ``` If no exact app exists, include `"allowCreate": true` only after the user @@ -113,10 +115,36 @@ returned JSON locally when necessary. Do not ask for or use a broad "everything is done" confirmation as evidence. After the Graph reread, use one structured form for only the settings Graph cannot prove. Ask for the exact selected SAML signing option and the exact -NameID source attribute. A reply such as "done", "all good", "continue", or +NameID source attribute. Store each exact non-secret value as `observedValue` +in its check object. A reply such as "done", "all good", "continue", or "proceed" is not evidence for either field and must not be converted into administrator attestation. +Use this exact `vscode_askQuestions` form: + +```json +[ + { + "header": "NameID source", + "question": "What exact source attribute is configured for Unique User Identifier (Name ID) in the Workday application's SAML Attributes & Claims?" + }, + { + "header": "SAML signing", + "question": "What exact SAML Signing Option is selected under SAML Signing Certificate -> Edit?", + "options": [ + { "label": "Sign SAML response and assertion" }, + { "label": "Sign SAML assertion" }, + { "label": "Sign SAML response" } + ], + "allowFreeformInput": false + } +] +``` + +Leave both answers unset. Do not label a factual value as recommended. After +the administrator submits the form, perform the Graph reread immediately; do +not add a separate **Verify now** confirmation. + The administrator performs those changes in the Microsoft Entra admin center. After the administrator confirms completion, reread the application and service principal through Microsoft Graph. Do not mark a planned action as @@ -130,10 +158,12 @@ For a portal-only setting that Graph cannot prove, include its non-secret administrator confirmation in the `checks` object rather than claiming the skill changed it. -Pass the Graph reread as: +Write the Graph reread directly to +`.local/connect/workday-da/entra-verification.json` using a structured +file-write tool, then run: ```powershell -python scripts/workday_connect.py record-entra --verification-json '{...}' +python scripts/workday_connect.py record-entra --verification-file ".local\connect\workday-da\entra-verification.json" ``` The JSON must contain the Graph-authenticated `tenantId`, exact application and @@ -169,11 +199,13 @@ scope GUID, safe certificate metadata, and one evidence object for each check: }, "nameId": { "outcome": "verified", - "provenance": "microsoft-graph" + "provenance": "microsoft-graph", + "observedValue": "user.userPrincipalName" }, "samlSigningOption": { "outcome": "confirmed", - "provenance": "administrator-attestation" + "provenance": "administrator-attestation", + "observedValue": "Sign SAML response and assertion" } } } diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md index 9e05c4f5..d30124b8 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md @@ -18,19 +18,71 @@ Ask the maker to: 5. confirm the agent identifies the signed-in employee and returns real Workday data without an unexpected repeated sign-in. +Use `vscode_askQuestions` for the test result: + +```json +[ + { + "header": "Employee test result", + "question": "What happened in the signed-in employee Workday test?", + "options": [ + { "label": "Passed - employee identified and Workday data returned" }, + { "label": "Failed - repeated sign-in" }, + { "label": "Failed - connector error" }, + { "label": "Failed - flow error" }, + { "label": "Failed - employee mismatch" }, + { "label": "Failed - network error" } + ], + "allowFreeformInput": false + } +] +``` + +Leave the result unset and do not mark the passing outcome as recommended. If +the test passed, ask for the scenario with this separate structured choice: + +```json +[ + { + "header": "Tested scenario", + "question": "Which read-only Workday scenario did the test employee run?", + "options": [ + { "label": "Check vacation balance" }, + { "label": "View employment information" }, + { "label": "View compensation" }, + { "label": "View organization or manager information" }, + { "label": "Another read-only Workday scenario" } + ], + "allowFreeformInput": true + } +] +``` + On success, record only the scenario name, test-user category, timestamp, and -outcome: +outcome in `.local/connect/workday-da/employee-validation.json`. Write the +object using a structured file-write tool rather than a generated shell +command, then run: ```powershell -python scripts/workday_connect.py record-validation --evidence-json '{...}' +python scripts/workday_connect.py record-validation --evidence-file ".local\connect\workday-da\employee-validation.json" ``` Provide only `scenarioName`, `testUserCategory`, `timestamp`, and a passed or -verified `outcome`. The controller rejects additional fields. Never record -employee data or credentials. +verified `outcome`. Use a non-maker employee category and a +timezone-qualified ISO-8601 timestamp. The controller rejects additional +fields. Never record employee data or credentials. + +On failure, record only a safe `failureCategory`, a timezone-qualified +ISO-8601 `timestamp`, and a concise non-sensitive `remediation` in +`.local/connect/workday-da/employee-validation-failure.json`, then run: + +```powershell +python scripts/workday_connect.py record-validation-failure --evidence-file ".local\connect\workday-da\employee-validation-failure.json" +``` -On failure, keep the phase active and persist one current blocker. Use the -failing surface to choose the next check: +This marks Employee validation blocked and persists one current blocker while +keeping completed prerequisite phases intact. Use the failing surface to +choose the next check: - sign-in loop -> identify which credential store prompted and whether the account or tenant differs; diff --git a/tests/flightcheck/checks/test_workday_extension.py b/tests/flightcheck/checks/test_workday_extension.py index 38cc4b7b..ee6dbe08 100644 --- a/tests/flightcheck/checks/test_workday_extension.py +++ b/tests/flightcheck/checks/test_workday_extension.py @@ -547,8 +547,9 @@ def test_wired_topic_passes(self, tmp_path, monkeypatch): tmp_path, "acme", "kind: AdaptiveDialog\n" - " - kind: BeginDialog\n" - " dialog: cr123_WorkdaySystemGetUserContextV3\n", + "beginDialog:\n" + " kind: BeginDialog\n" + " dialog: cr123_WorkdaySystemGetUserContextV3\n", ) _write_installed_topic( tmp_path, @@ -562,13 +563,40 @@ def test_wired_topic_passes(self, tmp_path, monkeypatch): assert "WorkdaySystemGetUserContextV3" in r.result assert "acme" in r.result + def test_comment_or_unrelated_field_does_not_count_as_redirect( + self, + tmp_path, + monkeypatch, + ): + monkeypatch.chdir(tmp_path) + _write_topic( + tmp_path, + "acme", + "kind: AdaptiveDialog\n" + "# kind: BeginDialog\n" + "# dialog: cr123_WorkdaySystemGetUserContextV3\n" + "description: cr123_WorkdaySystemGetUserContextV3\n", + ) + _write_installed_topic( + tmp_path, + "acme", + "cr123_WorkdaySystemGetUserContextV3", + ) + runner = _Runner(config={}, agent_slug="acme") + + r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-002"] + + assert r.status == Status.FAILED.value + def test_selected_agent_ignores_wired_sibling(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) _write_topic( tmp_path, "wired", - " - kind: BeginDialog\n" - " dialog: cr123_WorkdaySystemGetUserContextV3\n", + "kind: AdaptiveDialog\n" + "beginDialog:\n" + " kind: BeginDialog\n" + " dialog: cr123_WorkdaySystemGetUserContextV3\n", ) _write_topic(tmp_path, "broken", "kind: AdaptiveDialog\n") _write_installed_topic( @@ -595,8 +623,10 @@ def test_missing_selected_agent_does_not_scan_siblings( _write_topic( tmp_path, "wired", - " - kind: BeginDialog\n" - " dialog: cr123_WorkdaySystemGetUserContextV3\n", + "kind: AdaptiveDialog\n" + "beginDialog:\n" + " kind: BeginDialog\n" + " dialog: cr123_WorkdaySystemGetUserContextV3\n", ) _write_installed_topic( tmp_path, @@ -617,7 +647,10 @@ def test_active_agent_scope_ignores_unrelated_unwired_agent( _write_topic( tmp_path, "active", - " - kind: BeginDialog\n dialog: WorkdaySystemGetUserContextV2\n", + "kind: AdaptiveDialog\n" + "beginDialog:\n" + " kind: BeginDialog\n" + " dialog: WorkdaySystemGetUserContextV2\n", ) _write_installed_topic( tmp_path, @@ -640,7 +673,10 @@ def test_active_agent_scope_does_not_pass_from_other_agent( _write_topic( tmp_path, "other", - " - kind: BeginDialog\n dialog: WorkdaySystemGetUserContextV2\n", + "kind: AdaptiveDialog\n" + "beginDialog:\n" + " kind: BeginDialog\n" + " dialog: WorkdaySystemGetUserContextV2\n", ) _write_installed_topic( tmp_path, diff --git a/tests/flightcheck/test_registry.py b/tests/flightcheck/test_registry.py index e8882af5..3da19170 100644 --- a/tests/flightcheck/test_registry.py +++ b/tests/flightcheck/test_registry.py @@ -73,9 +73,14 @@ def test_exact_fixed_id(self): assert registry.resolve("WD-PKG-001").key == "WD-PKG-001" def test_exact_beats_family(self): - # WD-CONN-010 / -012 / -102 are fixed entries that must NOT collapse + # These fixed entries must NOT collapse # into the WD-CONN family even though that family exists. - for fixed in ("WD-CONN-010", "WD-CONN-012", "WD-CONN-102"): + for fixed in ( + "WD-CONN-010", + "WD-CONN-012", + "WD-CONN-013", + "WD-CONN-102", + ): assert registry.resolve(fixed).key == fixed assert registry.resolve(fixed).is_family is False @@ -133,6 +138,13 @@ def test_entra_only_checkpoint_needs_no_dataverse(self): # Only the Workday owning function runs (no prereqs). assert [label for label, _ in plan.ordered_fns] == ["Workday"] + def test_obo_sharing_checkpoint_needs_only_dataverse(self): + plan = registry.transitive_requirements("WD-CONN-013") + assert plan.clients == frozenset({registry.DATAVERSE}) + assert registry.PP_ADMIN not in plan.clients + assert plan.requires_dataverse_endpoint is True + assert [label for label, _ in plan.ordered_fns] == ["Workday"] + def test_closure_unions_clients_across_prereqs(self): # WD-CONN-012 itself declares only dataverse, but pulls pp_admin in via # its WD-001 prerequisite — naive one-level resolution would miss it. diff --git a/tests/scripts/test_flow_authorization.py b/tests/scripts/test_flow_authorization.py index 0e98cca7..d3405063 100644 --- a/tests/scripts/test_flow_authorization.py +++ b/tests/scripts/test_flow_authorization.py @@ -3,8 +3,9 @@ """Structural contracts for the Dataverse flow-authorization tooling.""" +import importlib.util from pathlib import Path - +import sys _REPO_ROOT = Path(__file__).resolve().parents[2] _ALM_DIR = ( @@ -62,6 +63,48 @@ def test_token_fallback_uses_the_kit_authentication_helper() -> None: assert "preferred_username=args.preferred_username" in helper +def test_token_helper_rejects_mismatched_identity_without_emitting_token( + monkeypatch, + capsys, +) -> None: + spec = importlib.util.spec_from_file_location( + "get_dataverse_token_test", + _ALM_DIR / "get_dataverse_token.py", + ) + assert spec and spec.loader + helper = importlib.util.module_from_spec(spec) + spec.loader.exec_module(helper) + monkeypatch.setattr( + helper.auth, + "authenticate", + lambda *_args, **_kwargs: "secret-token", + ) + monkeypatch.setattr( + helper, + "require_identity", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + helper.WorkdayConnectIdentityError("different account") + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "get_dataverse_token.py", + "--environment", + "https://example.crm.dynamics.com", + "--preferred-username", + "maker@example.com", + ], + ) + + assert helper.main() == 1 + captured = capsys.readouterr() + assert "different account" in captured.err + assert "secret-token" not in captured.out + assert "secret-token" not in captured.err + + def test_candidate_tokens_are_attached_to_dataverse_requests() -> None: script = _script_text() diff --git a/tests/scripts/test_minimalbot_detection.py b/tests/scripts/test_minimalbot_detection.py index c804a065..ad9922dd 100644 --- a/tests/scripts/test_minimalbot_detection.py +++ b/tests/scripts/test_minimalbot_detection.py @@ -672,7 +672,7 @@ def test_workday_topic_resolution_enforces_reviewed_ess_hr_count(tmp_path): ) -def test_minimalbot_topic_push_updates_only_pushed_baseline_paths( +def test_minimalbot_activation_rejects_local_topic_content_changes( tmp_path, monkeypatch, ): @@ -687,15 +687,19 @@ def test_minimalbot_topic_push_updates_only_pushed_baseline_paths( _patch_client(monkeypatch, fake) monkeypatch.setattr(push, "yaml_to_object_models", _fake_topic_conversion) - push._minimalbot_push( - _minimalbot_config(str(tmp_path)), - auto_yes=True, - only_globs=["*"], - activate_topics=True, - preferred_username="maker@contoso.com", - ) + with pytest.raises( + SystemExit, + ): + push._minimalbot_push( + _minimalbot_config(str(tmp_path)), + auto_yes=True, + only_globs=["*"], + activate_topics=True, + preferred_username="maker@contoso.com", + ) - assert [entry["path"] for entry in fake.topic_updates] == paths + assert paths + assert fake.topic_updates == [] assert baseline_workflow.read_text(encoding="utf-8") == ( '{"state":"before"}' ) @@ -893,6 +897,105 @@ def test_minimalbot_activation_rejects_blocking_diagnostics(monkeypatch): ) +@pytest.mark.parametrize( + "components", + [ + [], + [ + { + "$kind": "DialogComponent", + "id": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + }, + { + "$kind": "DialogComponent", + "id": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + }, + ], + ], +) +def test_minimalbot_verification_rejects_missing_or_duplicate_ids( + monkeypatch, + components, +): + client = _mb_client() + client._token = "token" + monkeypatch.setattr( + client, + "read_components", + lambda: { + "changeToken": "token-1", + "botComponentChanges": [ + {"$kind": "BotComponentInsert", "component": component} + for component in components + ], + }, + ) + + with pytest.raises( + mbe.MinimalBotEvaluationError, + match="missing or duplicate component ID", + ): + client.verify_dialog_components( + [ + { + "componentId": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + } + ] + ) + + +@pytest.mark.parametrize( + "component", + [ + { + "$kind": "UnexpectedComponent", + "id": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + }, + { + "$kind": "DialogComponent", + "id": "workday-topic", + "schemaName": "contoso.topic.OtherTopic", + }, + ], +) +def test_minimalbot_verification_rejects_kind_or_schema_drift( + monkeypatch, + component, +): + client = _mb_client() + client._token = "token" + monkeypatch.setattr( + client, + "read_components", + lambda: { + "changeToken": "token-1", + "botComponentChanges": [ + { + "$kind": "BotComponentInsert", + "component": component, + } + ], + }, + ) + + with pytest.raises( + mbe.MinimalBotEvaluationError, + match="verification failed", + ): + client.verify_dialog_components( + [ + { + "componentId": "workday-topic", + "schemaName": "contoso.topic.WorkdayTopic", + } + ] + ) + + def test_minimalbot_activation_reports_diagnostics_without_rejecting( monkeypatch, ): diff --git a/tests/scripts/test_workday_connect_agent.py b/tests/scripts/test_workday_connect_agent.py index bc5f07bb..55d940e3 100644 --- a/tests/scripts/test_workday_connect_agent.py +++ b/tests/scripts/test_workday_connect_agent.py @@ -234,6 +234,45 @@ def test_controller_persists_phase_blocker( assert status["blocker"]["operation"] == "set-workday-tenant" +def test_controller_surfaces_blocker_persistence_failure( + tmp_path: Path, + monkeypatch, + capsys, +) -> None: + import pytest + + import workday_connect + from workday_connect_store import WorkdayConnectStore + + def fail_persistence(*_args, **_kwargs): + raise OSError("state is read-only") + + monkeypatch.setattr( + WorkdayConnectStore, + "set_phase_status", + fail_persistence, + ) + monkeypatch.setattr( + sys, + "argv", + [ + "workday_connect.py", + "--root", + str(tmp_path), + "set-workday-tenant", + "--tenant", + "", + ], + ) + + with pytest.raises(SystemExit) as exc: + workday_connect.main() + + assert exc.value.code == 1 + error = capsys.readouterr().err + assert '"blockerPersistenceError": "state is read-only"' in error + + def test_record_connections_uses_live_verification( tmp_path: Path, monkeypatch, @@ -257,6 +296,10 @@ def test_record_connections_uses_live_verification( "verify_physical_connections", lambda *_args, **_kwargs: { "makerUsername": "maker@example.com", + "connectionIds": { + "workday": "workday-id", + "dataverse": "dataverse-id", + }, "connections": [ { "connector": "shared_workdaysoap", @@ -275,6 +318,7 @@ def test_record_connections_uses_live_verification( evidence_json=None, workday_connection_id=None, dataverse_connection_id=None, + confirm_workday_target=True, ), store, ) @@ -283,6 +327,71 @@ def test_record_connections_uses_live_verification( assert result["verified"] is True assert connections["status"] == "complete" assert connections["completedActions"] == ["physical-connections-verified"] + assert connections["evidence"][0]["connectionIds"] == { + "workday": "workday-id", + "dataverse": "dataverse-id", + } + + +def test_record_connections_previews_before_target_confirmation( + tmp_path: Path, + monkeypatch, +) -> None: + import workday_connect + from workday_connect_store import WorkdayConnectStore + + store = WorkdayConnectStore(tmp_path) + store.initialize() + store.merge_section( + "identifiers", + { + "workdaySamlEntityId": "http://www.workday.com/contoso", + "oauthClientId": "client-id", + }, + ) + store.merge_section( + "endpoints", + {"oauthTokenUrl": "https://example.workday.com/oauth/token"}, + ) + monkeypatch.setattr( + workday_connect, + "verify_physical_connections", + lambda *_args, **_kwargs: { + "makerUsername": "maker@example.com", + "connectionIds": { + "workday": "workday-id", + "dataverse": "dataverse-id", + }, + "connections": [ + { + "connector": "shared_workdaysoap", + "displayName": "Workday", + }, + { + "connector": "shared_commondataserviceforapps", + "displayName": "Dataverse", + }, + ], + }, + ) + + result = workday_connect._record_connections( + SimpleNamespace( + evidence_json=None, + workday_connection_id=None, + dataverse_connection_id=None, + confirm_workday_target=False, + ), + store, + ) + + assert result["requiresConfirmation"] is True + assert result["workdayTarget"] == { + "resourceUrl": "http://www.workday.com/contoso", + "oauthTokenUrl": "https://example.workday.com/oauth/token", + "oauthClientId": "client-id", + } + assert store.load()["phases"]["connections"]["status"] == "pending" def test_record_agent_binding_completes_only_from_verifier_output( @@ -299,6 +408,7 @@ def test_record_agent_binding_completes_only_from_verifier_output( "scope", { "environmentId": "environment-id", + "packageFlavor": "runtime", "agent": { "slug": "ess-hr", "botId": "bot-id", @@ -353,9 +463,23 @@ def test_record_agent_binding_completes_only_from_verifier_output( }, }, ) + attachment_file = tmp_path / "attachment.json" + attachment_file.write_text( + json.dumps( + { + "outcome": "maker-confirmed", + "botId": "bot-id", + "flowNames": ["ESS Workday Runtime REST Execution"], + "parameterSharingOutcome": ( + "enabled-for-exposed-connections" + ), + } + ), + encoding="utf-8", + ) result = workday_connect._record_agent_binding( - SimpleNamespace(), + SimpleNamespace(attachment_file=attachment_file), store, ) @@ -437,9 +561,56 @@ def test_record_agent_binding_rejects_manual_boolean_evidence( with pytest.raises( WorkdayConnectStoreError, - match="Manual agent-binding evidence is no longer accepted", + match="requires a JSON input file", ): workday_connect._record_agent_binding( - SimpleNamespace(evidence_json='{"workdayTopicsActivated":true}'), + SimpleNamespace(attachment_file=None), WorkdayConnectStore(tmp_path), ) + + +def test_record_validation_failure_blocks_employee_phase( + tmp_path: Path, +) -> None: + import workday_connect + import workday_connect_model as model + from workday_connect_store import WorkdayConnectStore + + store = WorkdayConnectStore(tmp_path) + store.initialize() + for phase_id in ( + "preflight", + "entra", + "workday-admin", + "connections", + "runtime", + ): + for action in model.PHASE_REQUIRED_ACTIONS[phase_id]: + store.complete_action( + phase_id, + action, + evidence={"outcome": "verified"}, + ) + store.set_phase_status(phase_id, "complete") + evidence_file = tmp_path / "employee-failure.json" + evidence_file.write_text( + json.dumps( + { + "failureCategory": "workday-access-denied", + "timestamp": "2026-09-25T00:00:00Z", + "remediation": "Verify the employee's Workday access.", + } + ), + encoding="utf-8", + ) + + result = workday_connect._record_validation_failure( + SimpleNamespace(evidence_file=evidence_file), + store, + ) + + assert result["recorded"] is True + phase = store.load()["phases"]["employee-validation"] + assert phase["status"] == "blocked" + assert phase["blocker"]["errorType"] == "workday-access-denied" + assert phase["blocker"]["capturedAt"] == "2026-09-25T00:00:00Z" diff --git a/tests/scripts/test_workday_connect_contracts.py b/tests/scripts/test_workday_connect_contracts.py index a81f91aa..8effa906 100644 --- a/tests/scripts/test_workday_connect_contracts.py +++ b/tests/scripts/test_workday_connect_contracts.py @@ -18,9 +18,11 @@ build_workday_admin_packet, validate_agent_binding_evidence, validate_employee_evidence, + validate_employee_failure_evidence, validate_entra_verification, validate_workday_admin_response, ) +import workday_connect_contracts as contracts # noqa: E402 from workday_connect_model import default_state # noqa: E402 @@ -30,6 +32,7 @@ def _state(): { "entraTenantId": "00000000-0000-0000-0000-000000000000", "workdayTenant": "contoso_impl", + "packageFlavor": "runtime", } ) state["phases"]["preflight"]["status"] = "complete" @@ -65,10 +68,12 @@ def _entra_checks(): "nameId": { "outcome": "verified", "provenance": "microsoft-graph", + "observedValue": "user.userPrincipalName", }, "samlSigningOption": { "outcome": "confirmed", "provenance": "administrator-attestation", + "observedValue": "Sign SAML response and assertion", }, } @@ -200,9 +205,25 @@ def test_entra_verification_requires_all_expected_graph_evidence(): assert result["evidence"]["checks"]["samlSigningOption"] == { "outcome": "confirmed", "provenance": "administrator-attestation", + "observedValue": "Sign SAML response and assertion", } +def test_saml_signing_option_records_the_exact_required_value(): + with pytest.raises( + WorkdayConnectContractError, + match="Sign SAML response and assertion", + ): + contracts._normalize_entra_check( + "samlSigningOption", + { + "outcome": "confirmed", + "provenance": "administrator-attestation", + "observedValue": "Sign SAML assertion only", + }, + ) + + def test_entra_verification_rejects_a_different_graph_tenant(): app_id = "44444444-4444-4444-4444-444444444444" with pytest.raises( @@ -470,14 +491,19 @@ def test_workday_admin_response_rejects_certificate_date_drift(): with pytest.raises( WorkdayConnectContractError, - match="do not match", + match="conflicts", ): validate_workday_admin_response( state, { - "activeIdentityProviderIssuer": "https://sts.example/", + "identityProviderOutcome": "verified-entra-issuer", "enabledServiceProviderId": ("http://www.workday.com/contoso_impl"), - "certificateName": "Wrong certificate", + "certificateSelectionOutcome": ( + "entra-signing-certificate-selected" + ), + "certificateValidityOutcome": ( + "matches-verified-entra-certificate" + ), "certificateValidFrom": "2026-01-01", "certificateValidTo": "2028-01-01", "oauthClientId": "safe-client-id", @@ -592,6 +618,14 @@ def test_agent_binding_and_employee_evidence_are_strict(): "active": 21, "blockingDiagnostics": [], }, + "flowAttachment": { + "outcome": "maker-confirmed", + "botId": "bot-id", + "flowNames": ["ESS Workday Runtime REST Execution"], + "parameterSharingOutcome": ( + "enabled-for-exposed-connections" + ), + }, }, )["workdayTopics"]["active"] == 21 @@ -613,6 +647,14 @@ def test_agent_binding_and_employee_evidence_are_strict(): "active": 21, "blockingDiagnostics": [{"errorCode": "NotFound"}], }, + "flowAttachment": { + "outcome": "maker-confirmed", + "botId": "bot-id", + "flowNames": ["ESS Workday Runtime REST Execution"], + "parameterSharingOutcome": ( + "enabled-for-exposed-connections" + ), + }, }, ) assert diagnostic_evidence["workdayTopics"]["blockingDiagnostics"] == [ @@ -623,7 +665,7 @@ def test_agent_binding_and_employee_evidence_are_strict(): validate_employee_evidence( { "scenarioName": "Read-only scenario", - "testUserCategory": "non-maker employee", + "testUserCategory": "standard employee", "timestamp": "2026-09-25T00:00:00Z", "outcome": "passed", "employeeName": "not allowed", @@ -700,11 +742,14 @@ def test_workday_admin_rejects_endpoint_path_drift( "validTo": "2027-01-01", } response = { - "activeIdentityProviderIssuer": "https://sts.windows.net/tenant/", + "identityProviderOutcome": "verified-entra-issuer", "enabledServiceProviderId": ("http://www.workday.com/contoso_impl"), - "certificateName": "ESS Workday Entra signing certificate", - "certificateValidFrom": "2026-01-01", - "certificateValidTo": "2027-01-01", + "certificateSelectionOutcome": ( + "entra-signing-certificate-selected" + ), + "certificateValidityOutcome": ( + "matches-verified-entra-certificate" + ), "oauthClientId": "safe-client-id", "oauthTokenUrl": ("https://example.workday.com/ccx/oauth2/contoso_impl/token"), "restBaseUrl": "https://example.workday.com/ccx/api", @@ -716,3 +761,125 @@ def test_workday_admin_rejects_endpoint_path_drift( with pytest.raises(WorkdayConnectContractError, match=message): validate_workday_admin_response(state, response) + + +def test_workday_admin_rejects_legacy_identity_provider_evidence(): + state = _state() + state["identifiers"]["signingCertificate"] = { + "thumbprint": "ABC123", + "validFrom": "2026-01-01", + "validTo": "2027-01-01", + } + + with pytest.raises( + WorkdayConnectContractError, + match="identityProviderOutcome", + ): + validate_workday_admin_response( + state, + { + "activeIdentityProviderIssuer": "https://wrong.example/", + "enabledServiceProviderId": ( + "http://www.workday.com/contoso_impl" + ), + "certificateName": "Unrelated certificate", + "certificateValidFrom": "2026-01-01", + "certificateValidTo": "2027-01-01", + "oauthClientId": "safe-client-id", + "oauthTokenUrl": ( + "https://example.workday.com/ccx/oauth2/" + "contoso_impl/token" + ), + "restBaseUrl": "https://example.workday.com/ccx/api", + "soapBaseUrl": ( + "https://example.workday.com/ccx/service/contoso_impl" + ), + "authenticationPolicyOutcome": "existing-active-policy", + "networkReadinessOutcome": "confirmed-hosts-allowed", + }, + ) + + +def test_workday_admin_rejects_non_workday_or_mixed_endpoint_hosts(): + state = _state() + state["identifiers"]["signingCertificate"] = { + "thumbprint": "ABC123", + "validFrom": "2026-01-01", + "validTo": "2027-01-01", + } + response = { + "identityProviderOutcome": "verified-entra-issuer", + "enabledServiceProviderId": "http://www.workday.com/contoso_impl", + "certificateSelectionOutcome": "entra-signing-certificate-selected", + "certificateValidityOutcome": "matches-verified-entra-certificate", + "oauthClientId": "safe-client-id", + "oauthTokenUrl": ( + "https://attacker.example/ccx/oauth2/contoso_impl/token" + ), + "restBaseUrl": "https://example.workday.com/ccx/api", + "soapBaseUrl": "https://example.workday.com/ccx/service/contoso_impl", + "authenticationPolicyOutcome": "existing-active-policy", + "networkReadinessOutcome": "confirmed-hosts-allowed", + } + + with pytest.raises(WorkdayConnectContractError, match="Workday-owned"): + validate_workday_admin_response(state, response) + + response["oauthTokenUrl"] = ( + "https://other.workday.com/ccx/oauth2/contoso_impl/token" + ) + with pytest.raises(WorkdayConnectContractError, match="same verified"): + validate_workday_admin_response(state, response) + + response["oauthTokenUrl"] = ( + "https://example.workday.com:notaport/ccx/oauth2/" + "contoso_impl/token" + ) + with pytest.raises(WorkdayConnectContractError, match="HTTPS URL"): + validate_workday_admin_response(state, response) + + +def test_employee_evidence_rejects_maker_and_invalid_timestamp(): + with pytest.raises(WorkdayConnectContractError, match="non-maker"): + validate_employee_evidence( + { + "scenarioName": "Read-only scenario", + "testUserCategory": "Environment Maker", + "timestamp": "2026-09-25T00:00:00Z", + "outcome": "passed", + } + ) + + +def test_employee_failure_evidence_requires_safe_structured_fields(): + assert validate_employee_failure_evidence( + { + "failureCategory": "workday-access-denied", + "timestamp": "2026-09-25T00:00:00+00:00", + "remediation": "Ask a Workday administrator to verify access.", + } + ) == { + "failureCategory": "workday-access-denied", + "timestamp": "2026-09-25T00:00:00Z", + "remediation": "Ask a Workday administrator to verify access.", + } + + with pytest.raises(WorkdayConnectContractError, match="unsupported fields"): + validate_employee_failure_evidence( + { + "failureCategory": "workday-access-denied", + "timestamp": "2026-09-25T00:00:00Z", + "remediation": "Investigate.", + "accessToken": "must-not-be-recorded", + } + ) + + with pytest.raises(WorkdayConnectContractError, match="ISO-8601"): + validate_employee_evidence( + { + "scenarioName": "Read-only scenario", + "testUserCategory": "non-maker employee", + "timestamp": "not-a-time", + "outcome": "passed", + } + ) diff --git a/tests/scripts/test_workday_connect_preflight.py b/tests/scripts/test_workday_connect_preflight.py index c57f0e55..7046dce3 100644 --- a/tests/scripts/test_workday_connect_preflight.py +++ b/tests/scripts/test_workday_connect_preflight.py @@ -319,7 +319,7 @@ def test_preflight_skips_install_when_package_exists(tmp_path: Path) -> None: import workday_connect_preflight as preflight import workday_connect_store as store_module - _write_foundation(tmp_path) + _write_foundation(tmp_path, dataverse_url=ENV_URL) installer_calls = [] def query(_url, _token, entity_set, _select, _filter): @@ -353,7 +353,7 @@ def test_preflight_carries_verified_tenant_into_entra_handoff( import workday_connect_preflight as preflight import workday_connect_store as store_module - _write_foundation(tmp_path) + _write_foundation(tmp_path, dataverse_url=ENV_URL) store = store_module.WorkdayConnectStore(tmp_path) preflight.run_preflight( tmp_path, @@ -400,26 +400,69 @@ def test_preflight_installs_and_reverifies_with_same_account( tmp_path: Path, ) -> None: import workday_connect_preflight as preflight + import workday_connect_store as store_module - _write_foundation(tmp_path) - query_results = iter( - [[], [{"uniquename": "msdyn_EssWorkdayRuntime"}]] - ) + _write_foundation(tmp_path, dataverse_url=ENV_URL) + store = store_module.WorkdayConnectStore(tmp_path) + installed = False - result = preflight.run_preflight( + def query(*_args, **_kwargs): + return ( + [{"uniquename": "msdyn_EssWorkdayRuntime"}] + if installed + else [] + ) + + plan_result = preflight.run_preflight( tmp_path, dataverse_url=ENV_URL, maker_username="maker@example.com", + store=store, token_provider=lambda *_args, **_kwargs: "token", identity_provider=lambda *_args, **_kwargs: { "username": "maker@example.com", "tenantId": "tenant-id", }, - query=lambda *_args, **_kwargs: next(query_results), - installer=lambda *_args, **kwargs: { + query=query, + ) + assert plan_result["requiresApproval"] is True + assert plan_result["plan"]["scope"]["agent"] == { + "slug": "ess-hr", + "botId": BOT_ID, + "schemaName": "gptagent_copilotforemployeeselfservicehr", + } + assert store.status()["nextPhaseId"] == "preflight" + _state, approved_hash = store.approve_plan( + "preflight", + plan_result["plan"], + ) + + def installer(*_args, **kwargs): + nonlocal installed + installed = True + return { "schemaName": "msdyn_EssWorkdayRuntime", "authenticatedAccount": kwargs["preferred_username"], + } + + result = preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username="maker@example.com", + store=store, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=lambda *_args, **_kwargs: { + "username": "maker@example.com", + "tenantId": "tenant-id", }, + query=query, + installer=installer, + approved_install_hash=approved_hash, + plan_verifier=lambda plan, value: store.verify_plan( + "preflight", + plan, + value, + ), ) assert result["package"]["action"] == "installed" @@ -431,7 +474,7 @@ def test_preflight_reuses_persisted_maker_identity(tmp_path: Path) -> None: import workday_connect_preflight as preflight import workday_connect_store as store_module - _write_foundation(tmp_path) + _write_foundation(tmp_path, dataverse_url=ENV_URL) store = store_module.WorkdayConnectStore(tmp_path) store.initialize() store.merge_section( @@ -467,12 +510,82 @@ def token_provider(_url, *, preferred_username): assert observed["preferred"] == "maker@example.com" +def test_preflight_apply_reuses_approved_plan_maker_identity( + tmp_path: Path, +) -> None: + import workday_connect_preflight as preflight + import workday_connect_store as store_module + + _write_foundation(tmp_path, dataverse_url=ENV_URL) + store = store_module.WorkdayConnectStore(tmp_path) + installed = False + + def query(*_args, **_kwargs): + return ( + [{"uniquename": "msdyn_EssWorkdayRuntime"}] + if installed + else [] + ) + + plan_result = preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username="maker@example.com", + store=store, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=lambda *_args, **_kwargs: { + "username": "maker@example.com", + "tenantId": "tenant-id", + }, + query=query, + ) + _state, approved_hash = store.approve_plan( + "preflight", + plan_result["plan"], + ) + observed = {} + + def token_provider(_url, *, preferred_username): + observed["preferred"] = preferred_username + return "token" + + def installer(*_args, **kwargs): + nonlocal installed + installed = True + return { + "schemaName": "msdyn_EssWorkdayRuntime", + "authenticatedAccount": kwargs["preferred_username"], + } + + preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username=None, + store=store, + token_provider=token_provider, + identity_provider=lambda _token, *, preferred_username: { + "username": preferred_username, + "tenantId": "tenant-id", + }, + query=query, + installer=installer, + approved_install_hash=approved_hash, + plan_verifier=lambda plan, value: store.verify_plan( + "preflight", + plan, + value, + ), + ) + + assert observed["preferred"] == "maker@example.com" + + def test_preflight_identity_mismatch_is_structured(tmp_path: Path) -> None: import workday_connect_auth as auth import workday_connect_preflight as preflight import workday_connect_store as store_module - _write_foundation(tmp_path) + _write_foundation(tmp_path, dataverse_url=ENV_URL) store = store_module.WorkdayConnectStore(tmp_path) store.initialize() @@ -500,7 +613,26 @@ def mismatch(_token, *, preferred_username): def test_preflight_rejects_unproven_pac_account(tmp_path: Path) -> None: import workday_connect_preflight as preflight - _write_foundation(tmp_path) + _write_foundation(tmp_path, dataverse_url=ENV_URL) + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + plan_result = preflight.run_preflight( + tmp_path, + dataverse_url=ENV_URL, + maker_username="maker@example.com", + store=store, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=lambda *_args, **_kwargs: { + "username": "maker@example.com", + "tenantId": "tenant-id", + }, + query=lambda *_args, **_kwargs: [], + ) + _state, approved_hash = store.approve_plan( + "preflight", + plan_result["plan"], + ) with pytest.raises( preflight.WorkdayConnectPreflightError, @@ -510,6 +642,7 @@ def test_preflight_rejects_unproven_pac_account(tmp_path: Path) -> None: tmp_path, dataverse_url=ENV_URL, maker_username="maker@example.com", + store=store, token_provider=lambda *_args, **_kwargs: "token", identity_provider=lambda *_args, **_kwargs: { "username": "maker@example.com", @@ -520,4 +653,113 @@ def test_preflight_rejects_unproven_pac_account(tmp_path: Path) -> None: "schemaName": "msdyn_EssWorkdayRuntime", "authenticatedAccount": "other@example.com", }, + approved_install_hash=approved_hash, + plan_verifier=lambda plan, value: store.verify_plan( + "preflight", + plan, + value, + ), ) + + +def test_preflight_rejects_supplied_url_not_proven_for_environment( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + _write_foundation(tmp_path) + + with pytest.raises( + preflight.WorkdayConnectPreflightError, + match="does not match", + ): + preflight.resolve_target( + tmp_path, + dataverse_url="https://wrong.crm.dynamics.com", + state=model.default_state(), + pac_resolver=lambda: Path("pac.exe"), + pac_runner=lambda command, **_kwargs: subprocess.CompletedProcess( + command, + 0, + stdout=json.dumps( + { + "EnvironmentId": "agent-environment", + "OrgUrl": ENV_URL, + } + ), + stderr="", + ), + ) + + +def test_repeated_preflight_preserves_verified_pac_evidence( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + import workday_connect_store as store_module + + _write_foundation(tmp_path, dataverse_url=ENV_URL) + store = store_module.WorkdayConnectStore(tmp_path) + installed = False + + def query(*_args, **_kwargs): + return ( + [{"uniquename": "msdyn_EssWorkdayRuntime"}] + if installed + else [] + ) + + common_kwargs = { + "dataverse_url": ENV_URL, + "maker_username": "maker@example.com", + "store": store, + "token_provider": lambda *_args, **_kwargs: "token", + "identity_provider": lambda *_args, **_kwargs: { + "username": "maker@example.com", + "tenantId": "tenant-id", + }, + "query": query, + } + plan_result = preflight.run_preflight(tmp_path, **common_kwargs) + _state, approved_hash = store.approve_plan( + "preflight", + plan_result["plan"], + ) + + def installer(*_args, **kwargs): + nonlocal installed + installed = True + return { + "schemaName": "msdyn_EssWorkdayRuntime", + "authenticatedAccount": kwargs["preferred_username"], + } + + preflight.run_preflight( + tmp_path, + **common_kwargs, + installer=installer, + approved_install_hash=approved_hash, + plan_verifier=lambda plan, value: store.verify_plan( + "preflight", + plan, + value, + ), + ) + for phase_id in ("entra", "workday-admin"): + for action in model.PHASE_REQUIRED_ACTIONS[phase_id]: + store.complete_action( + phase_id, + action, + evidence={"outcome": "verified"}, + ) + store.set_phase_status(phase_id, "complete") + + result = preflight.run_preflight( + tmp_path, + **common_kwargs, + ) + + assert result["operator"]["credentialStores"]["pac"] == "verified" + assert store.load()["phases"]["entra"]["status"] == "complete" diff --git a/tests/scripts/test_workday_connect_runtime.py b/tests/scripts/test_workday_connect_runtime.py index ca8e4bbe..a9edb131 100644 --- a/tests/scripts/test_workday_connect_runtime.py +++ b/tests/scripts/test_workday_connect_runtime.py @@ -220,7 +220,7 @@ def test_runtime_plan_requires_verified_physical_connections(): ) -def test_physical_connection_verification_returns_safe_live_evidence(): +def test_physical_connection_verification_returns_selected_connection_ids(): result = runtime.verify_physical_connections( _state(), pac_resolver=lambda: Path("pac.exe"), @@ -229,6 +229,10 @@ def test_physical_connection_verification_returns_safe_live_evidence(): assert result == { "makerUsername": "maker@contoso.com", + "connectionIds": { + "workday": WORKDAY_CONNECTION, + "dataverse": DATAVERSE_CONNECTION, + }, "connections": [ { "connector": "shared_workdaysoap", @@ -240,8 +244,95 @@ def test_physical_connection_verification_returns_safe_live_evidence(): }, ], } - assert WORKDAY_CONNECTION not in json.dumps(result) - assert DATAVERSE_CONNECTION not in json.dumps(result) + + +def test_runtime_plan_reuses_recorded_connection_ids(): + state = _state() + state["phases"]["connections"]["evidence"] = [ + { + "action": "physical-connections-verified", + "outcome": "verified", + "connectionIds": { + "workday": WORKDAY_CONNECTION, + "dataverse": DATAVERSE_CONNECTION, + }, + } + ] + duplicate_workday = "99999999-9999-9999-9999-999999999999" + payload = _connections() + payload["value"].append( + { + "name": duplicate_workday, + "properties": { + "apiId": ( + "/providers/Microsoft.PowerApps/apis/shared_workdaysoap" + ), + "displayName": "Workday duplicate", + "statuses": [{"status": "Connected"}], + }, + } + ) + + def runner(command, **_kwargs): + if command[1:3] == ["auth", "list"]: + return SimpleNamespace( + returncode=0, + stdout="[1] * maker@contoso.com Public\n", + stderr="", + ) + if command[1:3] == ["connectivity", "list-connections"]: + return SimpleNamespace( + returncode=0, + stdout=json.dumps(payload), + stderr="", + ) + raise AssertionError(command) + + result = runtime.run_runtime_operation( + state, + apply=False, + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=_identity, + runner=runner, + query=_query_for(_records()), + pac_resolver=lambda: Path("pac.exe"), + ) + + bindings = result["plan"]["connectionBindings"].values() + assert next( + binding["connectionId"] + for binding in bindings + if binding["connector"] == "shared_workdaysoap" + ) == WORKDAY_CONNECTION + + +def test_runtime_plan_rejects_connection_id_drift_after_verification(): + state = _state() + state["phases"]["connections"]["evidence"] = [ + { + "action": "physical-connections-verified", + "outcome": "verified", + "connectionIds": { + "workday": WORKDAY_CONNECTION, + "dataverse": DATAVERSE_CONNECTION, + }, + } + ] + + with pytest.raises( + runtime.WorkdayConnectRuntimeError, + match="differs from the connection verified", + ): + runtime.run_runtime_operation( + state, + apply=False, + workday_connection_id=( + "99999999-9999-9999-9999-999999999999" + ), + token_provider=lambda *_args, **_kwargs: "token", + identity_provider=_identity, + **_discovery_dependencies(_records()), + ) def test_runtime_apply_verifies_all_mutations(monkeypatch): diff --git a/tests/scripts/test_workday_connect_store.py b/tests/scripts/test_workday_connect_store.py index 61c2b5bb..e5aa4311 100644 --- a/tests/scripts/test_workday_connect_store.py +++ b/tests/scripts/test_workday_connect_store.py @@ -82,6 +82,28 @@ def test_migration_preserves_existing_tasks_as_snapshot(tmp_path: Path) -> None: assert connect_tasks.read_text(encoding="utf-8") == ("legacy connect checklist") +def test_future_schema_is_rejected_without_rewriting_state( + tmp_path: Path, +) -> None: + import workday_connect_store as store_module + + path = _config_path(tmp_path) + path.parent.mkdir(parents=True) + original = { + "schemaVersion": 999, + "futureField": {"mustRemain": True}, + } + path.write_text(json.dumps(original), encoding="utf-8") + + with pytest.raises( + store_module.WorkdayConnectStoreError, + match="Unsupported Workday connect state schema version", + ): + store_module.WorkdayConnectStore(tmp_path).initialize() + + assert json.loads(path.read_text(encoding="utf-8")) == original + + def test_legacy_ready_state_reopens_runtime_for_live_topic_proof( tmp_path: Path, ) -> None: @@ -142,6 +164,28 @@ def test_complete_action_is_idempotent(tmp_path: Path) -> None: assert len(state["phases"]["preflight"]["evidence"]) == 1 +def test_complete_action_does_not_regress_completed_phase( + tmp_path: Path, +) -> None: + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + _complete_phase( + store, + "preflight", + store_module.PHASE_REQUIRED_ACTIONS["preflight"], + ) + + state = store.complete_action( + "preflight", + "verify-target", + evidence={"outcome": "verified"}, + ) + + assert state["phases"]["preflight"]["status"] == "complete" + + def test_complete_action_reactivates_a_blocked_phase(tmp_path: Path) -> None: import workday_connect_store as store_module @@ -212,6 +256,36 @@ def test_approved_plan_rejects_changed_target(tmp_path: Path) -> None: store.verify_plan("runtime", changed, approved_hash) +def test_approving_completed_runtime_invalidates_employee_validation( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + for definition in model.PHASE_DEFINITIONS: + phase_id = definition.identifier.value + _complete_phase( + store, + phase_id, + set(model.PHASE_REQUIRED_ACTIONS[phase_id]), + ) + + state, _hash = store.approve_plan( + "runtime", + { + "phase": "runtime", + "scope": {"botId": "bot-id"}, + "actions": ["Reapply reviewed runtime bindings"], + }, + ) + + assert state["phases"]["runtime"]["status"] == "active" + assert state["phases"]["employee-validation"]["status"] == "pending" + assert state["phases"]["employee-validation"]["completedActions"] == [] + + def test_status_returns_progress_roadmap_and_next_phase_summary( tmp_path: Path, ) -> None: @@ -273,6 +347,37 @@ def test_scope_change_invalidates_affected_phases(tmp_path: Path) -> None: assert state["phases"]["entra"]["status"] == "pending" +def test_regressing_completed_phase_invalidates_downstream( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_store as store_module + + store = store_module.WorkdayConnectStore(tmp_path) + store.initialize() + for definition in model.PHASE_DEFINITIONS: + phase_id = definition.identifier.value + _complete_phase( + store, + phase_id, + set(model.PHASE_REQUIRED_ACTIONS[phase_id]), + ) + + state = store.set_phase_status( + "runtime", + "blocked", + blocker={ + "operation": "record-topic-activation", + "errorType": "RuntimeVerificationFailed", + "message": "The live topic is not active.", + }, + ) + + assert state["phases"]["runtime"]["status"] == "blocked" + assert state["phases"]["employee-validation"]["status"] == "pending" + assert state["phases"]["employee-validation"]["completedActions"] == [] + + def _complete_phase(store, phase_id: str, actions: set[str]) -> None: for action in actions: store.complete_action( diff --git a/tests/setup/test_workday_da_foundation.py b/tests/setup/test_workday_da_foundation.py index 781370f8..e12d510e 100644 --- a/tests/setup/test_workday_da_foundation.py +++ b/tests/setup/test_workday_da_foundation.py @@ -72,7 +72,10 @@ def test_entra_and_workday_identifiers_remain_distinct() -> None: assert "Never alias" in schema or "must never be aliases" in schema assert "entra-handoff" in entra assert "workday-admin-packet" in tenant - assert '{"applications":[{...}]}' in entra + assert "--discovery-file" in entra + assert "--verification-file" in entra + assert "--discovery-json" not in entra + assert "--verification-json" not in entra assert "exits with code 0" in entra assert "partial stdout after\na nonzero exit" in entra assert "legacy `src/skills/setup/workday/` procedure" in entra @@ -102,7 +105,16 @@ def test_manual_handoff_is_one_packet_not_row_attestations() -> None: assert "No certificate is selected" in tenant assert "Never suggest, prefill, or ask the administrator to confirm" in tenant assert "display name is optional support context" in tenant - assert "exactly one response form using one structured `ask_user` call" in tenant + assert ( + "exactly one response form using one structured\n" + "`vscode_askQuestions` call" + ) in tenant + assert '"header": "Identity provider"' in tenant + assert '"header": "Authentication policy"' in tenant + assert '"header": "Network readiness"' in tenant + assert "multiline text\nbox" in tenant + assert "Do not add `recommended`" in tenant + assert "free-text request for several numbered answers" in tenant assert '"all good", "continue", or "proceed"' in tenant assert "do not move to another field" in tenant assert "search workspace files" in tenant diff --git a/tests/setup/test_workday_da_orchestration.py b/tests/setup/test_workday_da_orchestration.py index 7644cc60..2c242ba4 100644 --- a/tests/setup/test_workday_da_orchestration.py +++ b/tests/setup/test_workday_da_orchestration.py @@ -54,6 +54,81 @@ def test_every_controller_command_is_documented() -> None: assert documented == set(controller._COMMAND_HANDLERS) +def test_workday_guides_use_file_backed_json_inputs() -> None: + guide_text = "\n".join( + path.read_text(encoding="utf-8") + for path in sorted(_WORKDAY_DA.rglob("*.md")) + ) + + for unsafe_option in ( + "--discovery-json", + "--verification-json", + "--response-json", + "--plan-json", + "--evidence-json", + "--attachment-json", + ): + assert unsafe_option not in guide_text + + +def test_workday_forms_do_not_preselect_or_recommend_answers() -> None: + guide_paths = list(_WORKDAY_DA.rglob("*.md")) + list( + ( + _REPO_ROOT + / "solutions" + / "ess-maker-skills" + / "src" + / "skills" + / "connect" + / "workday" + / "actions" + ).glob("*.md") + ) + form_text = "\n".join( + path.read_text(encoding="utf-8") + for path in sorted(guide_paths) + ) + + assert '"recommended": true' not in form_text + assert "(Recommended)" not in form_text + assert "Leave every field and option initially unset" in form_text + assert "do not mark the passing outcome as recommended" in form_text + + +def test_controller_reads_json_payload_from_file(tmp_path: Path) -> None: + import workday_connect as controller + + payload = { + "value": "customer text with 'quotes'; $(not-a-command)", + } + path = tmp_path / "payload.json" + path.write_text(json.dumps(payload), encoding="utf-8") + + assert controller._json_input( + argparse.Namespace(discovery_file=path, discovery_json=None), + "discovery", + "test discovery", + ) == payload + + +@pytest.mark.parametrize( + "command,option", + [ + ("preflight-approve", "--plan-json"), + ("record-agent-binding", "--attachment-json"), + ("record-validation-failure", "--evidence-json"), + ], +) +def test_new_commands_do_not_accept_inline_json( + command: str, + option: str, +) -> None: + import workday_connect as controller + + with pytest.raises(SystemExit): + controller.build_parser().parse_args([command, option, "{}"]) + + def test_every_phase_dispatch_target_exists_and_schema_is_reference_only() -> None: skill = (_WORKDAY_DA / "SKILL.md").read_text(encoding="utf-8") dispatch_targets = re.findall(r"-> read `([^`]+\.md)`", skill) @@ -123,7 +198,10 @@ def test_preflight_is_one_identity_aware_operation() -> None: assert "workday_connect.py preflight" in text assert "pins and verifies the resulting PAC account" in text assert "verifies the exact Dataverse URL directly" in text - assert "detects or installs the package" in text + assert "requiresApproval: true" in text + assert "preflight-approve --plan-file" in text + assert "--install-plan-hash" in text + assert "installs it only after exact-plan approval" in text assert "manual-install instruction" in text @@ -136,6 +214,7 @@ def test_connections_are_proven_before_runtime_apply() -> None: assert "record-connections" in text assert "record-connections --evidence-json" not in text assert "manual connection evidence" in text + assert "--confirm-workday-target" in text assert "Do not begin with a yes/no question" in text assert text.count("workday_connect.py record-connections") == 2 assert "Power Apps maker portal" in text @@ -150,8 +229,12 @@ def test_connections_are_proven_before_runtime_apply() -> None: assert "Reuse a healthy existing connection" in text assert "runtime-apply" in text assert "record-agent-binding" in text + assert "--attachment-file" in text assert text.index("runtime-apply") < text.index("record-agent-binding") - assert "Do not construct or pass manual\nboolean evidence" in text + assert "--checkpoint WD-CONN-013" not in text + assert "Do not run `WD-CONN-013` separately" in text + assert "confirmation twice" in text + assert "Do not substitute an unscoped" in normalized assert "--connect-config" in text assert "one Dataverse token" in normalized assert "delegated" in text From 5988e0eb3648b0d3774eb8917f798a4c37ce2a5f Mon Sep 17 00:00:00 2001 From: is-goutham Date: Mon, 28 Sep 2026 15:00:12 -0700 Subject: [PATCH 18/20] Improve Workday connection guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workday-da/configure-power-platform.md | 79 ++++++++++++++++--- tests/setup/test_workday_da_orchestration.py | 28 +++++++ 2 files changed, 95 insertions(+), 12 deletions(-) diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md index 845df8e3..9c4791c1 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md @@ -7,6 +7,37 @@ The skill does not create physical connector connections or complete connector OAuth. The maker performs those actions; the skill discovers and verifies the result. +Agent **Connection Settings** is a later Runtime step. While either physical +connection is missing, disconnected, ambiguous, or awaiting confirmation, tell +the maker: + +> Do not open Copilot Studio Connection Settings yet. That page is configured +> only after both Power Platform connections are verified and the Workday +> Runtime changes have been applied. + +Do not provide the agent Connection Settings link during the Connections phase. +Do not diagnose the flow authorization script as failed when Runtime apply has +not run. + +Form direct connection-creation links from the recorded environment ID and +service ring. Resolve `{POWER_AUTOMATE_ORIGIN}` exactly as follows: + +- `prod` -> `https://make.powerautomate.com` +- `preprod` -> `https://make.preprod.powerautomate.com` +- `test` -> `https://make.test.powerautomate.com` + +Never send a non-production environment to the production maker portal. If the +ring is missing or unsupported, do not guess; ask the maker to confirm it. + +Use these environment-scoped links: + +- Workday: + `{POWER_AUTOMATE_ORIGIN}/environments/{ENVIRONMENT_ID}/connections/available/shared_workdaysoap` +- Microsoft Dataverse: + `{POWER_AUTOMATE_ORIGIN}/environments/{ENVIRONMENT_ID}/connections/available/shared_commondataserviceforapps` +- Connections list fallback: + `{POWER_AUTOMATE_ORIGIN}/environments/{ENVIRONMENT_ID}/connections` + Do not begin with a yes/no question asking whether both connections are already connected. First explain that this phase needs exactly two Power Platform connections, then discover them: @@ -37,9 +68,12 @@ return to the affected Workday administrator step rather than guessing. Then give the maker this creation process: -1. Open the Power Apps maker portal and select the exact Power Platform - environment being configured. -2. Open **Connections**, select **New connection**, and choose **Workday**. +1. Show a **Create Workday connection** link using the environment-scoped + Workday URL above. It opens the correct connector in the already selected + environment. +2. If the direct link does not open, use the Connections list fallback or open + the Power Apps maker portal, select the exact environment, open + **Connections**, select **New connection**, and choose **Workday**. 3. Select **Microsoft Entra ID Integrated** authentication. 4. Enter the three displayed values in the matching connection fields. 5. Select **Create** and complete the Workday sign-in window. This connector @@ -54,8 +88,10 @@ sign-in window. If the Microsoft Dataverse connection is missing or disconnected: -1. In the same environment's **Connections** page, select **New connection**. -2. Choose **Microsoft Dataverse**. +1. Show a **Create Microsoft Dataverse connection** link using the + environment-scoped Microsoft Dataverse URL above. +2. If the direct link does not open, use the Connections list fallback, select + **New connection**, and choose **Microsoft Dataverse**. 3. Create or repair the connection using the selected maker account. 4. Confirm that it shows **Connected**. @@ -63,6 +99,11 @@ Reuse a healthy existing connection when one already exists. Do not create duplicates merely to satisfy the phase, and do not ask the maker to paste connection IDs. +Until both connections show **Connected** and `record-connections` succeeds, +keep the customer in the Power Apps **Connections** page. Ask them to return to +the skill for another check; do not redirect them to the agent's Connection +Settings page. + Show the safe display name of the selected Workday connection and the three saved non-secret Workday values. Use `vscode_askQuestions`: @@ -181,6 +222,12 @@ rather than hiding earlier successful changes. Report permission issues only from an explicit forbidden response, `[FAIL]` marker, ambiguity result, or nonzero script exit. +Do not direct the maker to agent Connection Settings unless `runtime-apply` +returns `applied.verified: true` and confirms all three verified stages: +`connection-references-bound`, `runtime-flows-active`, and +`delegated-authorization-configured`. If Runtime apply has not run or any stage +is incomplete, keep Runtime active and show the controller's actual blocker. + ## Agent binding after flow activation Only after runtime apply has activated the reviewed flows, wire the native @@ -205,13 +252,21 @@ Continue with the supported live checkpoints, topic-state verification, and employee scenario. Do not recreate, clone, reselect, or rewrite packaged flows in response to topic metadata alone. -Then open the agent connection settings. Connect **ESS Workday Runtime REST -Execution** and any other Workday flow shown there. The reviewed native agent -contract marks **ESS Workday Runtime** and **ESS Workday Runtime References** as -`EmbeddedOnly`; embedded flows are not expected to require a maker-selected -user connection. For every Workday connection the page does expose, enable -**Allow permission to share parameters**. This prevents each employee from -receiving an unexpected first-use connection prompt. +Only now direct the maker to the selected agent's **Settings** > +**Connection Settings** page. Before they continue, show the exact recorded +Power Platform maker account and tell them to verify that Copilot Studio's +browser profile is signed in as that account. The CLI credential cache and the +Copilot Studio browser session are separate. If the browser shows another +account, the maker must switch accounts or use a separate browser profile +before selecting a connection. + +Connect **ESS Workday Runtime REST Execution** and any other Workday flow shown +there. The reviewed native agent contract marks **ESS Workday Runtime** and +**ESS Workday Runtime References** as `EmbeddedOnly`; embedded flows are not +expected to require a maker-selected user connection. For every Workday +connection the page does expose, enable **Allow permission to share +parameters**. This prevents each employee from receiving an unexpected +first-use connection prompt. Internal execution note—never show this implementation detail to the customer: `connectionType: EmbeddedOnly` is separate from the Dataverse diff --git a/tests/setup/test_workday_da_orchestration.py b/tests/setup/test_workday_da_orchestration.py index 2c242ba4..9838b49a 100644 --- a/tests/setup/test_workday_da_orchestration.py +++ b/tests/setup/test_workday_da_orchestration.py @@ -217,6 +217,21 @@ def test_connections_are_proven_before_runtime_apply() -> None: assert "--confirm-workday-target" in text assert "Do not begin with a yes/no question" in text assert text.count("workday_connect.py record-connections") == 2 + assert "`prod` -> `https://make.powerautomate.com`" in text + assert "`preprod` -> `https://make.preprod.powerautomate.com`" in text + assert "`test` -> `https://make.test.powerautomate.com`" in text + assert "Never send a non-production environment to the production maker portal" in text + assert ( + "{POWER_AUTOMATE_ORIGIN}/environments/{ENVIRONMENT_ID}/connections/" + "available/shared_workdaysoap" + ) in text + assert ( + "{POWER_AUTOMATE_ORIGIN}/environments/{ENVIRONMENT_ID}/connections/" + "available/shared_commondataserviceforapps" + ) in text + assert "Create Workday connection" in text + assert "Create Microsoft Dataverse connection" in text + assert "Connections list fallback" in text assert "Power Apps maker portal" in text assert "Microsoft Entra ID Integrated" in text assert "**Microsoft Entra resource URL:**" in text @@ -227,7 +242,20 @@ def test_connections_are_proven_before_runtime_apply() -> None: assert "Do not ask the maker or administrator to provide them again" in normalized assert "Do not request or collect a Workday password" in text assert "Reuse a healthy existing connection" in text + assert "Do not open Copilot Studio Connection Settings yet" in text + assert "Do not provide the agent Connection Settings link" in text + assert "Do not diagnose the flow authorization script as failed" in text + assert "keep the customer in the Power Apps **Connections** page" in text assert "runtime-apply" in text + assert "applied.verified: true" in text + assert "connection-references-bound" in text + assert "runtime-flows-active" in text + assert "delegated-authorization-configured" in text + assert text.index("applied.verified: true") < text.index( + "Only now direct the maker" + ) + assert "CLI credential cache and the\nCopilot Studio browser session are separate" in text + assert "show the exact recorded\nPower Platform maker account" in text assert "record-agent-binding" in text assert "--attachment-file" in text assert text.index("runtime-apply") < text.index("record-agent-binding") From 35180acf7dea6cf6982c155b50cc46b03897c309 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Mon, 28 Sep 2026 15:24:15 -0700 Subject: [PATCH 19/20] Address Workday connection review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../flightcheck/checks/workday_extension.py | 64 +++++++++++++---- .../scripts/flightcheck/cli.py | 5 ++ .../scripts/workday_connect.py | 6 ++ .../scripts/workday_connect_preflight.py | 2 +- .../scripts/workday_connect_runtime.py | 50 +++++++++---- .../checks/test_workday_extension.py | 67 +++++++++++++++++- .../flightcheck/test_cli_single_checkpoint.py | 10 ++- tests/scripts/test_workday_connect_agent.py | 65 +++++++++++++++++ .../scripts/test_workday_connect_preflight.py | 18 +++++ tests/scripts/test_workday_connect_runtime.py | 70 +++++++++++++++++++ tests/setup/test_workday_da_orchestration.py | 22 ------ 11 files changed, 325 insertions(+), 54 deletions(-) diff --git a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py index 650ddbaf..d8814a60 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py @@ -146,19 +146,19 @@ def _selected_agent_slug(runner) -> str: def _mapped_user_context_topics( agent_dir: Path, -) -> tuple[Path | None, str | None, str | None]: +) -> tuple[Path | None, Path | None, str | None, str | None]: component_map_path = agent_dir / _COMPONENT_MAP_FILE try: component_map = json.loads( component_map_path.read_text(encoding="utf-8") ) except (OSError, json.JSONDecodeError) as exc: - return None, None, ( + return None, None, None, ( f"The selected agent '{agent_dir.name}' {_COMPONENT_MAP_FILE} " f"could not be read: {exc}" ) if not isinstance(component_map, dict): - return None, None, ( + return None, None, None, ( f"The selected agent's {_COMPONENT_MAP_FILE} is not a JSON object." ) @@ -171,31 +171,49 @@ def _mapped_user_context_topics( == _SETUP_TOPIC_DISPLAY.casefold() ] target_matches = [ - entry - for entry in component_map.values() + (relative_path, entry) + for relative_path, entry in component_map.items() if isinstance(entry, dict) and entry.get("componentKind") == "DialogComponent" and str(entry.get("displayName") or "").casefold() == _TARGET_TOPIC_DISPLAY.casefold() ] if len(setup_matches) != 1 or len(target_matches) != 1: - return None, None, ( + return None, None, None, ( f"Expected exactly one mapped admin user-context topic and one " f"mapped Workday User Context V2 topic for selected agent " f"'{agent_dir.name}'." ) - relative_path = Path(str(setup_matches[0][0])) - if relative_path.is_absolute() or ".." in relative_path.parts: - return None, None, ( + setup_relative_path = Path(str(setup_matches[0][0])) + if ( + setup_relative_path.is_absolute() + or ".." in setup_relative_path.parts + ): + return None, None, None, ( "The mapped admin user-context topic path is unsafe." ) - target_schema = str(target_matches[0].get("schemaName") or "").strip() + target_relative_path = Path(str(target_matches[0][0])) + if ( + target_relative_path.is_absolute() + or ".." in target_relative_path.parts + ): + return None, None, None, ( + "The mapped Workday User Context V2 topic path is unsafe." + ) + target_schema = str( + target_matches[0][1].get("schemaName") or "" + ).strip() if not target_schema: - return None, None, ( + return None, None, None, ( "The mapped Workday User Context V2 topic has no schemaName." ) - return agent_dir / relative_path, target_schema, None + return ( + agent_dir / setup_relative_path, + agent_dir / target_relative_path, + target_schema, + None, + ) def _fmt(config, key: str) -> str: @@ -667,9 +685,12 @@ def _check_user_context_redirect(runner) -> list[CheckResult]: )] agent_dir = resolve_agent_directory(agents_root, agent_slug) - topic_file, target_dialog, mapping_error = _mapped_user_context_topics( - agent_dir - ) + ( + topic_file, + target_topic_file, + target_dialog, + mapping_error, + ) = _mapped_user_context_topics(agent_dir) if mapping_error: return [CheckResult(roles=_MAKER_ROLES, checkpoint_id="WD-REST-002", category=_CATEGORY, @@ -680,6 +701,7 @@ def _check_user_context_redirect(runner) -> list[CheckResult]: doc_link=_DOC_SIMPLIFIED, )] assert topic_file is not None + assert target_topic_file is not None assert target_dialog is not None if not topic_file.is_file(): return [CheckResult(roles=_MAKER_ROLES, @@ -693,6 +715,18 @@ def _check_user_context_redirect(runner) -> list[CheckResult]: remediation=_REDIRECT_REMEDIATION, doc_link=_DOC_SIMPLIFIED, )] + if not target_topic_file.is_file(): + return [CheckResult(roles=_MAKER_ROLES, + checkpoint_id="WD-REST-002", category=_CATEGORY, + priority=Priority.HIGH.value, status=Status.FAILED.value, + description=_REDIRECT_DESC, + result=( + f"No mapped Workday User Context V2 topic found for selected " + f"agent '{agent_slug}' at {target_topic_file}." + ), + remediation=_REDIRECT_REMEDIATION, + doc_link=_DOC_SIMPLIFIED, + )] try: document = yaml.safe_load( diff --git a/solutions/ess-maker-skills/scripts/flightcheck/cli.py b/solutions/ess-maker-skills/scripts/flightcheck/cli.py index c88d3266..dc9619c5 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/cli.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/cli.py @@ -822,6 +822,11 @@ def _merge_connect_config(config: dict, connect_config_path: str | None) -> dict "tenantId": scope.get("entraTenantId"), "sidecarDataverseEndpoint": scope.get("dataverseUrl"), "appIdUri": identifiers.get("entraAppIdUri"), + "tokenEndpoint": ( + endpoints.get("tokenEndpoint") + or endpoints.get("oauthTokenUrl") + or overlay.get("tokenEndpoint") + ), } for key in _PROVIDER_CONNECT_CONFIG_KEYS: diff --git a/solutions/ess-maker-skills/scripts/workday_connect.py b/solutions/ess-maker-skills/scripts/workday_connect.py index 8e0aa934..e162916c 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect.py +++ b/solutions/ess-maker-skills/scripts/workday_connect.py @@ -438,6 +438,9 @@ def _record_agent_binding( "expected": evidence["workdayTopics"]["expected"], "verified": evidence["workdayTopics"]["verified"], "active": evidence["workdayTopics"]["active"], + "blockingDiagnostics": ( + evidence["workdayTopics"]["blockingDiagnostics"] + ), }, ) store.set_phase_status("runtime", "complete") @@ -629,6 +632,9 @@ def main() -> None: "error": str(exc), "errorType": type(exc).__name__, } + details = getattr(exc, "details", None) + if isinstance(details, dict) and details: + error_payload["details"] = details if blocker_persistence_error: error_payload["blockerPersistenceError"] = blocker_persistence_error print( diff --git a/solutions/ess-maker-skills/scripts/workday_connect_preflight.py b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py index 65546836..0f4ca408 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_preflight.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_preflight.py @@ -355,7 +355,7 @@ def resolve_target( "Refresh environment inventory for the recorded environment ID " "or provide that environment's exact Dataverse URL." ) - if not exact_url.startswith("https://"): + if not exact_url.casefold().startswith("https://"): raise WorkdayConnectPreflightError( "The Workday Dataverse environment URL must use HTTPS." ) diff --git a/solutions/ess-maker-skills/scripts/workday_connect_runtime.py b/solutions/ess-maker-skills/scripts/workday_connect_runtime.py index 4474d8e7..b536b731 100644 --- a/solutions/ess-maker-skills/scripts/workday_connect_runtime.py +++ b/solutions/ess-maker-skills/scripts/workday_connect_runtime.py @@ -32,6 +32,15 @@ class WorkdayConnectRuntimeError(RuntimeError): """Raised when runtime configuration cannot proceed safely.""" + def __init__( + self, + message: str, + *, + details: Mapping[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.details = dict(details or {}) + def _required_text( document: Mapping[str, Any], @@ -68,29 +77,44 @@ def _select_connection( *, explicit_id: str | None, ) -> dict[str, Any]: - matches = [ + connected_matches = [ value for value in connections if _connector_name(value) == connector_name.casefold() and _connected(value) - and ( - not explicit_id - or str(value.get("name") or "").casefold() == explicit_id.casefold() - ) + ] + matches = [ + value + for value in connected_matches + if not explicit_id + or str(value.get("name") or "").casefold() == explicit_id.casefold() ] if len(matches) != 1: - safe = sorted( - { - str( - (value.get("properties") or {}).get("displayName") or connector_name - ) - for value in matches - } + candidates = sorted( + ( + { + "connectionId": str(value.get("name") or ""), + "displayName": str( + (value.get("properties") or {}).get("displayName") + or connector_name + ), + } + for value in connected_matches + ), + key=lambda value: ( + value["displayName"].casefold(), + value["connectionId"].casefold(), + ), ) + safe = sorted({value["displayName"] for value in candidates}) raise WorkdayConnectRuntimeError( f"Expected exactly one connected {connector_name} connection; " f"found {len(matches)}. Connected display names: " - f"{json.dumps(safe, sort_keys=True)}" + f"{json.dumps(safe, sort_keys=True)}", + details={ + "connector": connector_name, + "candidateConnections": candidates, + }, ) return matches[0] diff --git a/tests/flightcheck/checks/test_workday_extension.py b/tests/flightcheck/checks/test_workday_extension.py index ee6dbe08..ffcbdb8e 100644 --- a/tests/flightcheck/checks/test_workday_extension.py +++ b/tests/flightcheck/checks/test_workday_extension.py @@ -449,6 +449,7 @@ def _write_component_map( agent: str, *, setup_file: str = "Setusercontext.mcs.yml", + target_file: str = "WorkdaySystemGetUserContextV2.mcs.yml", dialog: str | None = None, ): agent_dir = tmp_path / "workspace" / "agents" / agent @@ -467,7 +468,7 @@ def _write_component_map( } }) if dialog: - component_map["topics/WorkdaySystemGetUserContextV2.mcs.yml"] = { + component_map[f"topics/{target_file}"] = { "componentKind": "DialogComponent", "displayName": "Workday [System] - 1: Set User Context V2", "schemaName": dialog, @@ -485,8 +486,21 @@ def _write_topic(tmp_path, agent: str, body: str): _write_component_map(tmp_path, agent) -def _write_installed_topic(tmp_path, agent: str, dialog: str): +def _write_installed_topic( + tmp_path, + agent: str, + dialog: str, + *, + create_file: bool = True, +): _write_component_map(tmp_path, agent, dialog=dialog) + if create_file: + topics = tmp_path / "workspace" / "agents" / agent / "topics" + topics.mkdir(parents=True, exist_ok=True) + (topics / "WorkdaySystemGetUserContextV2.mcs.yml").write_text( + "kind: AdaptiveDialog\n", + encoding="utf-8", + ) class TestUserContextRedirect: @@ -563,6 +577,55 @@ def test_wired_topic_passes(self, tmp_path, monkeypatch): assert "WorkdaySystemGetUserContextV3" in r.result assert "acme" in r.result + def test_missing_mapped_target_topic_fails(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write_topic( + tmp_path, + "acme", + "kind: AdaptiveDialog\n" + "beginDialog:\n" + " kind: BeginDialog\n" + " dialog: cr123_WorkdaySystemGetUserContextV3\n", + ) + _write_installed_topic( + tmp_path, + "acme", + "cr123_WorkdaySystemGetUserContextV3", + create_file=False, + ) + runner = _Runner(config={}, agent_slug="acme") + + r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-002"] + + assert r.status == Status.FAILED.value + assert "No mapped Workday User Context V2 topic found" in r.result + assert "mapped Workday user-context" in r.remediation + + def test_unsafe_mapped_target_topic_path_fails( + self, + tmp_path, + monkeypatch, + ): + monkeypatch.chdir(tmp_path) + _write_topic( + tmp_path, + "acme", + "kind: AdaptiveDialog\n", + ) + _write_component_map( + tmp_path, + "acme", + target_file="../outside.mcs.yml", + dialog="cr123_WorkdaySystemGetUserContextV3", + ) + runner = _Runner(config={}, agent_slug="acme") + + r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-002"] + + assert r.status == Status.FAILED.value + assert "Workday User Context V2 topic path is unsafe" in r.result + assert "mapped Workday user-context" in r.remediation + def test_comment_or_unrelated_field_does_not_count_as_redirect( self, tmp_path, diff --git a/tests/flightcheck/test_cli_single_checkpoint.py b/tests/flightcheck/test_cli_single_checkpoint.py index ba234c61..457a3d6f 100644 --- a/tests/flightcheck/test_cli_single_checkpoint.py +++ b/tests/flightcheck/test_cli_single_checkpoint.py @@ -270,7 +270,11 @@ def test_connect_config_flattens_workday_state( "endpoints": { "restBaseUrl": ( "https://wd2-impl-services1.workday.com/ccx/api" - ) + ), + "oauthTokenUrl": ( + "https://wd2-impl-services1.workday.com/" + "ccx/oauth2/acme_impl/token" + ), }, } ), @@ -289,6 +293,10 @@ def test_connect_config_flattens_workday_state( assert merged["workdaySamlEntityId"] == ( "http://www.workday.com/acme_impl" ) + assert merged["tokenEndpoint"] == ( + "https://wd2-impl-services1.workday.com/" + "ccx/oauth2/acme_impl/token" + ) @pytest.mark.parametrize( "agent_slug", diff --git a/tests/scripts/test_workday_connect_agent.py b/tests/scripts/test_workday_connect_agent.py index 55d940e3..065dd3d8 100644 --- a/tests/scripts/test_workday_connect_agent.py +++ b/tests/scripts/test_workday_connect_agent.py @@ -273,6 +273,59 @@ def fail_persistence(*_args, **_kwargs): assert '"blockerPersistenceError": "state is read-only"' in error +def test_controller_emits_structured_runtime_error_details( + tmp_path: Path, + monkeypatch, + capsys, +) -> None: + import pytest + + import workday_connect + from workday_connect_runtime import WorkdayConnectRuntimeError + + details = { + "connector": "shared_workdaysoap", + "candidateConnections": [ + { + "connectionId": "connection-id", + "displayName": "Workday", + } + ], + } + + def raise_ambiguity(_args, _store): + raise WorkdayConnectRuntimeError( + "Select one connected Workday connection.", + details=details, + ) + + monkeypatch.setitem( + workday_connect._COMMAND_HANDLERS, + "status", + raise_ambiguity, + ) + monkeypatch.setattr( + sys, + "argv", + [ + "workday_connect.py", + "--root", + str(tmp_path), + "status", + ], + ) + + with pytest.raises(SystemExit) as exc: + workday_connect.main() + + assert exc.value.code == 1 + error = capsys.readouterr().err + payload = json.loads( + error.split(workday_connect.ERROR_MARKER, maxsplit=1)[1] + ) + assert payload["details"] == details + + def test_record_connections_uses_live_verification( tmp_path: Path, monkeypatch, @@ -487,6 +540,18 @@ def test_record_agent_binding_completes_only_from_verifier_output( assert result["verified"] is True assert runtime["status"] == "complete" assert "workday-topics-activated" in runtime["completedActions"] + topic_evidence = next( + item + for item in runtime["evidence"] + if item["action"] == "workday-topics-activated" + ) + assert topic_evidence["blockingDiagnostics"] == [ + { + "errorCode": "NotFound", + "errorMessage": "CloudFlow not found", + "referenceType": "CloudFlow", + } + ] def test_record_topic_activation_does_not_infer_runtime_failure_from_diagnostics( diff --git a/tests/scripts/test_workday_connect_preflight.py b/tests/scripts/test_workday_connect_preflight.py index 7046dce3..48d0ff36 100644 --- a/tests/scripts/test_workday_connect_preflight.py +++ b/tests/scripts/test_workday_connect_preflight.py @@ -119,6 +119,24 @@ def test_resolve_target_accepts_exact_url_without_inventory_lookup( assert target.dataverse_url == ENV_URL +def test_resolve_target_accepts_case_insensitive_https_scheme( + tmp_path: Path, +) -> None: + import workday_connect_model as model + import workday_connect_preflight as preflight + + uppercase_scheme_url = "HTTPS://target.crm.dynamics.com" + _write_foundation(tmp_path, dataverse_url=uppercase_scheme_url) + + target = preflight.resolve_target( + tmp_path, + dataverse_url=uppercase_scheme_url, + state=model.default_state(), + ) + + assert target.dataverse_url == uppercase_scheme_url + + def test_resolve_target_ignores_stale_url_from_different_environment( tmp_path: Path, ) -> None: diff --git a/tests/scripts/test_workday_connect_runtime.py b/tests/scripts/test_workday_connect_runtime.py index a9edb131..d741c08d 100644 --- a/tests/scripts/test_workday_connect_runtime.py +++ b/tests/scripts/test_workday_connect_runtime.py @@ -246,6 +246,76 @@ def test_physical_connection_verification_returns_selected_connection_ids(): } +@pytest.mark.parametrize( + ("connector_name", "duplicate_id", "display_name"), + [ + ( + "shared_workdaysoap", + "88888888-8888-8888-8888-888888888888", + "Workday alternate", + ), + ( + "shared_commondataserviceforapps", + "99999999-9999-9999-9999-999999999999", + "Dataverse alternate", + ), + ], +) +def test_physical_connection_ambiguity_exposes_structured_candidates( + connector_name: str, + duplicate_id: str, + display_name: str, +): + payload = _connections() + payload["value"].append( + { + "name": duplicate_id, + "properties": { + "apiId": ( + f"/providers/Microsoft.PowerApps/apis/{connector_name}" + ), + "displayName": display_name, + "statuses": [{"status": "Connected"}], + }, + } + ) + + def runner(command, **_kwargs): + if command[1:3] == ["auth", "list"]: + return SimpleNamespace( + returncode=0, + stdout="[1] * maker@contoso.com Public\n", + stderr="", + ) + if command[1:3] == ["connectivity", "list-connections"]: + return SimpleNamespace( + returncode=0, + stdout=json.dumps(payload), + stderr="", + ) + raise AssertionError(command) + + with pytest.raises(runtime.WorkdayConnectRuntimeError) as raised: + runtime.verify_physical_connections( + _state(), + pac_resolver=lambda: Path("pac.exe"), + runner=runner, + ) + + details = raised.value.details + candidates = details["candidateConnections"] + assert details["connector"] == connector_name + assert {value["connectionId"] for value in candidates} == { + duplicate_id, + ( + WORKDAY_CONNECTION + if connector_name == "shared_workdaysoap" + else DATAVERSE_CONNECTION + ), + } + assert duplicate_id not in str(raised.value) + + def test_runtime_plan_reuses_recorded_connection_ids(): state = _state() state["phases"]["connections"]["evidence"] = [ diff --git a/tests/setup/test_workday_da_orchestration.py b/tests/setup/test_workday_da_orchestration.py index 9838b49a..cde77387 100644 --- a/tests/setup/test_workday_da_orchestration.py +++ b/tests/setup/test_workday_da_orchestration.py @@ -305,28 +305,6 @@ def test_workday_topic_activation_uses_complete_mapped_scope() -> None: assert "do not treat them as an activation failure" in action assert "--activate" not in redirect - agent_dir = ( - _REPO_ROOT - / "solutions" - / "ess-maker-skills" - / "workspace" - / "agents" - / "employee-self-service-hr" - ) - component_map = json.loads( - (agent_dir / ".component-map.json").read_text(encoding="utf-8") - ) - workday_topics = [ - path - for path, entry in component_map.items() - if isinstance(entry, dict) - and entry.get("componentKind") == "DialogComponent" - and str(entry.get("schemaName") or "").split(".")[-1].startswith("Workday") - and str(entry.get("displayName") or "").startswith("Workday") - ] - assert len(workday_topics) == 21 - assert all((agent_dir / path).is_file() for path in workday_topics) - def test_readiness_requires_real_employee_runtime_evidence() -> None: text = (_WORKDAY_DA / "verify-connection.md").read_text(encoding="utf-8") From d74ff9ca68b4458e3742b525c2fd2440e90d1da0 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Mon, 28 Sep 2026 16:38:44 -0700 Subject: [PATCH 20/20] Harden Workday topic path containment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../flightcheck/checks/workday_extension.py | 62 +++++++++++------ .../checks/test_workday_extension.py | 68 ++++++++++++++++++- 2 files changed, 109 insertions(+), 21 deletions(-) diff --git a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py index d8814a60..2292f4b7 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py @@ -55,7 +55,7 @@ import os import re import sys -from pathlib import Path +from pathlib import Path, PureWindowsPath import yaml @@ -144,6 +144,32 @@ def _selected_agent_slug(runner) -> str: return validate_agent_slug(str(slug)) if slug else "" +def _safe_mapped_topic_path( + agent_dir: Path, + mapped_path: object, + label: str, +) -> tuple[Path | None, str | None]: + raw_path = str(mapped_path) + relative_path = Path(raw_path) + windows_path = PureWindowsPath(raw_path) + if ( + relative_path.is_absolute() + or windows_path.drive + or windows_path.root + or ".." in relative_path.parts + or ".." in windows_path.parts + ): + return None, f"The mapped {label} topic path is unsafe." + + agent_root = agent_dir.resolve() + candidate = (agent_root / relative_path).resolve() + try: + candidate.relative_to(agent_root) + except ValueError: + return None, f"The mapped {label} topic path is unsafe." + return candidate, None + + def _mapped_user_context_topics( agent_dir: Path, ) -> tuple[Path | None, Path | None, str | None, str | None]: @@ -185,22 +211,20 @@ def _mapped_user_context_topics( f"'{agent_dir.name}'." ) - setup_relative_path = Path(str(setup_matches[0][0])) - if ( - setup_relative_path.is_absolute() - or ".." in setup_relative_path.parts - ): - return None, None, None, ( - "The mapped admin user-context topic path is unsafe." - ) - target_relative_path = Path(str(target_matches[0][0])) - if ( - target_relative_path.is_absolute() - or ".." in target_relative_path.parts - ): - return None, None, None, ( - "The mapped Workday User Context V2 topic path is unsafe." - ) + setup_topic_path, path_error = _safe_mapped_topic_path( + agent_dir, + setup_matches[0][0], + "admin user-context", + ) + if path_error: + return None, None, None, path_error + target_topic_path, path_error = _safe_mapped_topic_path( + agent_dir, + target_matches[0][0], + "Workday User Context V2", + ) + if path_error: + return None, None, None, path_error target_schema = str( target_matches[0][1].get("schemaName") or "" ).strip() @@ -209,8 +233,8 @@ def _mapped_user_context_topics( "The mapped Workday User Context V2 topic has no schemaName." ) return ( - agent_dir / setup_relative_path, - agent_dir / target_relative_path, + setup_topic_path, + target_topic_path, target_schema, None, ) diff --git a/tests/flightcheck/checks/test_workday_extension.py b/tests/flightcheck/checks/test_workday_extension.py index ffcbdb8e..1f94ecf0 100644 --- a/tests/flightcheck/checks/test_workday_extension.py +++ b/tests/flightcheck/checks/test_workday_extension.py @@ -29,6 +29,7 @@ import json from typing import Any +import pytest import responses from tests.conftest import require_validated_mock @@ -449,7 +450,9 @@ def _write_component_map( agent: str, *, setup_file: str = "Setusercontext.mcs.yml", + setup_path: str | None = None, target_file: str = "WorkdaySystemGetUserContextV2.mcs.yml", + target_path: str | None = None, dialog: str | None = None, ): agent_dir = tmp_path / "workspace" / "agents" / agent @@ -461,14 +464,14 @@ def _write_component_map( else {} ) component_map.update({ - f"topics/{setup_file}": { + setup_path or f"topics/{setup_file}": { "componentKind": "DialogComponent", "displayName": "[Admin] - User Context - Setup", "schemaName": "contoso.topic.Setusercontext", } }) if dialog: - component_map[f"topics/{target_file}"] = { + component_map[target_path or f"topics/{target_file}"] = { "componentKind": "DialogComponent", "displayName": "Workday [System] - 1: Set User Context V2", "schemaName": dialog, @@ -626,6 +629,67 @@ def test_unsafe_mapped_target_topic_path_fails( assert "Workday User Context V2 topic path is unsafe" in r.result assert "mapped Workday user-context" in r.remediation + @pytest.mark.parametrize( + "mapped_path", + [ + r"\outside.mcs.yml", + r"D:outside.mcs.yml", + ], + ) + def test_windows_mapped_setup_topic_escape_fails( + self, + tmp_path, + monkeypatch, + mapped_path, + ): + monkeypatch.chdir(tmp_path) + _write_component_map( + tmp_path, + "acme", + setup_path=mapped_path, + dialog="cr123_WorkdaySystemGetUserContextV3", + ) + runner = _Runner(config={}, agent_slug="acme") + + r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-002"] + + assert r.status == Status.FAILED.value + assert "admin user-context topic path is unsafe" in r.result + assert "mapped Workday user-context" in r.remediation + + @pytest.mark.parametrize( + "mapped_path", + [ + r"\outside.mcs.yml", + r"D:outside.mcs.yml", + ], + ) + def test_windows_mapped_target_topic_escape_fails( + self, + tmp_path, + monkeypatch, + mapped_path, + ): + monkeypatch.chdir(tmp_path) + _write_topic( + tmp_path, + "acme", + "kind: AdaptiveDialog\n", + ) + _write_component_map( + tmp_path, + "acme", + target_path=mapped_path, + dialog="cr123_WorkdaySystemGetUserContextV3", + ) + runner = _Runner(config={}, agent_slug="acme") + + r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-002"] + + assert r.status == Status.FAILED.value + assert "Workday User Context V2 topic path is unsafe" in r.result + assert "mapped Workday user-context" in r.remediation + def test_comment_or_unrelated_field_does_not_count_as_redirect( self, tmp_path,