diff --git a/.gitignore b/.gitignore index 6af5a7a1..017daf1c 100644 --- a/.gitignore +++ b/.gitignore @@ -422,6 +422,7 @@ FodyWeavers.xsd bin/ obj/ *.csproj.user +*.lscache # Keep bundled 7-Zip architecture folders tracked. !src/Foundry/Assets/7z/[xX]64/ diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md new file mode 100644 index 00000000..65f25c40 --- /dev/null +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -0,0 +1,51 @@ +# Autopilot Hardware Hash Upload Implementation Plan + +This index is the entry point for the Autopilot hardware hash upload plan. The detailed plan is split into smaller documents so implementation agents can load only the context needed for the current phase. + +## Required Instructions +- Implementation agents must follow the repository instructions in [AGENTS.md](../../AGENTS.md). +- All PR titles, commit messages, code, and documentation changes must be in English. +- Add XML documentation comments for public or non-obvious C# APIs when they clarify intent, contracts, or operational constraints. +- Each phase branch must branch from `feature/autopilot-hash-upload-foundation` and merge back into that foundation branch before the next phase starts. +- Do not merge, squash, or auto-squash pull requests automatically. The repository owner handles PR merges manually. +- Do not run full solution tests during planning-only updates unless explicitly requested. + +## Plan Documents +- [Overview](autopilot-hash-upload/00-overview.md): purpose, branch strategy, PR roadmap, non-goals, resolved decisions, and references. +- [Feasibility And Current State](autopilot-hash-upload/01-feasibility-current-state.md): WinPE feasibility, constraints, current Foundry flow, and target data flow. +- [UX And Runtime Model](autopilot-hash-upload/02-ux-runtime-model.md): Foundry OSD UX, Foundry Deploy UX, and proposed configuration/runtime records. +- [Security And Graph](autopilot-hash-upload/03-security-graph.md): certificate handling, permission split, Graph import shape, and request rules. +- [WinPE And Deploy Workflow](autopilot-hash-upload/04-winpe-deploy-workflow.md): OA3Tool strategy, `PCPKsp.dll`, retained artifacts, failure taxonomy, and ownership boundaries. +- [Implementation Phases](autopilot-hash-upload/05-implementation-phases.md): phase-by-phase checklist with PR titles and manual checks. +- [Validation Risk And Documentation](autopilot-hash-upload/06-validation-risk-docs.md): automated test matrix, physical validation matrix, risk register, Docusaurus/user documentation deliverables. + +## Phase Order +| Order | Branch | Pull request title | Detail | +| --- | --- | --- | --- | +| 0 | `feature/autopilot-hash-upload-foundation` | `docs(autopilot): plan hardware hash upload from WinPE` | [Overview](autopilot-hash-upload/00-overview.md) | +| 1 | `feature/autopilot-hash-upload-config` | `feat(autopilot): add provisioning mode configuration` | [Implementation Phases](autopilot-hash-upload/05-implementation-phases.md#phase-1-configuration-model) | +| 2 | `feature/autopilot-hash-upload-security` | `feat(autopilot): add secure tenant upload onboarding` | [Implementation Phases](autopilot-hash-upload/05-implementation-phases.md#phase-2-security-and-tenant-onboarding) | +| 3 | `feature/autopilot-hash-upload-ui` | `feat(autopilot): add provisioning method selection` | [Implementation Phases](autopilot-hash-upload/05-implementation-phases.md#phase-3-autopilot-page-ux) | +| 4 | `feature/autopilot-hash-upload-media` | `feat(winpe): stage autopilot hash capture assets` | [Implementation Phases](autopilot-hash-upload/05-implementation-phases.md#phase-4-media-build-and-winpe-assets) | +| 5 | `feature/autopilot-hash-upload-runtime` | `feat(deploy): branch autopilot runtime by provisioning mode` | [Implementation Phases](autopilot-hash-upload/05-implementation-phases.md#phase-5-foundry-deploy-runtime-branching) | +| 6 | `feature/autopilot-hash-upload-capture` | `feat(deploy): capture autopilot hardware hash in WinPE` | [Implementation Phases](autopilot-hash-upload/05-implementation-phases.md#phase-6-hash-capture-service) | +| 7 | `feature/autopilot-hash-upload-graph` | `feat(autopilot): import hardware hashes with Graph` | [Implementation Phases](autopilot-hash-upload/05-implementation-phases.md#phase-7-graph-upload-service) | +| 8 | `feature/autopilot-hash-upload-docs` | `docs(autopilot): document WinPE hardware hash upload` | [Implementation Phases](autopilot-hash-upload/05-implementation-phases.md#phase-8-documentation-and-release-guardrails) | + +## Implementation Progress Tracker +Use this table as the cross-phase implementation status board. Detailed task, automated test, and manual checkboxes live in [Implementation Phases](autopilot-hash-upload/05-implementation-phases.md). + +| Phase | Branch created | Implementation complete | Verification complete | Manual checks complete | PR opened | Merged back | +| --- | --- | --- | --- | --- | --- | --- | +| 0 Foundation | [x] | [x] | [x] | [x] | [ ] | [ ] | +| 1 Configuration model | [x] | [x] | [x] | [x] | [x] | [ ] | +| 2 Security and tenant onboarding | [x] | [x] | [x] | [ ] | [x] | [ ] | +| 3 Autopilot page UX | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | +| 4 Media build and WinPE assets | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | +| 5 Foundry Deploy runtime branching | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | +| 6 Hash capture service | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | +| 7 Graph upload service | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | +| 8 Documentation and release guardrails | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | + +## Documentation Reminder +Phase 8 must update the Docusaurus documentation when the implemented behavior affects user-facing OSD, Deploy, WinPE requirements, setup, troubleshooting, permissions, or release notes. The Docusaurus repository is `E:\Github\Foundry Project\foundry-osd.github.io`; create a dedicated worktree and branch for that repository before editing documentation. diff --git a/docs/implementation/autopilot-hash-upload/00-overview.md b/docs/implementation/autopilot-hash-upload/00-overview.md new file mode 100644 index 00000000..e038086d --- /dev/null +++ b/docs/implementation/autopilot-hash-upload/00-overview.md @@ -0,0 +1,75 @@ +# Autopilot Hardware Hash Upload - Overview + +Part of the [Autopilot hardware hash upload implementation plan](../autopilot-hardware-hash-upload.md). + +Implementation agents must follow the repository instructions in [AGENTS.md](../../../AGENTS.md). Add XML documentation comments for public or non-obvious C# APIs when they clarify intent, contracts, or operational constraints. + +## Purpose +This document defines the feasibility, integration approach, and phased implementation plan for adding Windows Autopilot hardware hash capture and upload from WinPE to Foundry. + +This feature is intended to complement the existing offline Autopilot JSON profile staging flow. It must not replace the current behavior. + + +## Branch Strategy +- Foundation branch: `feature/autopilot-hash-upload-foundation` +- Foundation worktree: `C:\Users\mchav\.config\superpowers\worktrees\foundry\autopilot-hash-upload-foundation` +- Phase branches should branch from the foundation branch: + - `feature/autopilot-hash-upload-config` + - `feature/autopilot-hash-upload-security` + - `feature/autopilot-hash-upload-ui` + - `feature/autopilot-hash-upload-media` + - `feature/autopilot-hash-upload-runtime` + - `feature/autopilot-hash-upload-capture` + - `feature/autopilot-hash-upload-graph` + - `feature/autopilot-hash-upload-docs` + +The foundation branch should remain documentation-first. Implementation branches should be small, reviewable, and merged back into the foundation branch in phase order. +Implementation agents must not merge, squash, or auto-squash pull requests automatically. The repository owner handles PR merges manually. + + +## Pull Request Roadmap +All PR titles must stay in English and use Conventional Commits. Each phase branch should be merged back into the foundation branch before the next phase branch starts. + +| Order | Branch | Pull request title | Scope | +| --- | --- | --- | --- | +| 0 | `feature/autopilot-hash-upload-foundation` | `docs(autopilot): plan hardware hash upload from WinPE` | Feasibility, phased integration plan, risk register, test matrix. | +| 1 | `feature/autopilot-hash-upload-config` | `feat(autopilot): add provisioning mode configuration` | Expert and Deploy configuration models, backward compatibility, readiness rules. | +| 2 | `feature/autopilot-hash-upload-security` | `feat(autopilot): add secure tenant upload onboarding` | Tenant sign-in, app registration creation, certificate lifecycle, secret handling, permission validation. | +| 3 | `feature/autopilot-hash-upload-ui` | `feat(autopilot): add provisioning method selection` | Autopilot page expanders, mutually exclusive method selection, localized strings. | +| 4 | `feature/autopilot-hash-upload-media` | `feat(winpe): stage autopilot hash capture assets` | WinPE optional component requirements, x64/ARM64 OA3Tool discovery, media payload layout. | +| 5 | `feature/autopilot-hash-upload-runtime` | `feat(deploy): branch autopilot runtime by provisioning mode` | Deploy startup snapshot, launch validation, runtime state, late deployment step, dry-run manifests. | +| 6 | `feature/autopilot-hash-upload-capture` | `feat(deploy): capture autopilot hardware hash in WinPE` | C# OA3Tool execution service, `PCPKsp.dll` copy, `OA3.xml` parsing, CSV/diagnostic artifacts. | +| 7 | `feature/autopilot-hash-upload-graph` | `feat(autopilot): import hardware hashes with Graph` | C# Graph client, import polling, retry policy, operator-facing errors. | +| 8 | `feature/autopilot-hash-upload-docs` | `docs(autopilot): document WinPE hardware hash upload` | User docs, permissions matrix, troubleshooting, screenshots, release notes. | + +Expected PR description structure: +- Summary: one short paragraph. +- Reason: why this phase is needed and what risk it removes. +- Main changes: concise bullet list. +- Testing notes: exact automated commands and manual checks. + + +## Non-Goals +- Do not remove or redesign the existing offline JSON profile workflow. +- Do not involve Foundry Connect. +- Do not implement hardware hash capture or upload with PowerShell scripts, PowerShell Gallery modules, or community wrapper modules. +- Do not delete existing Intune, Autopilot, or Entra records automatically. +- Do not add group membership automation. +- Do not claim full Microsoft support for WinPE-based hash capture. +- Do not limit the implementation to x64. x64 and ARM64 are both in scope. +- Do not redistribute `PCPKsp.dll` in generated media. Copy it from the applied Windows image during deployment. + + +## Resolved Decisions +- No capture-only mode in the first implementation. Foundry always captures and uploads, while retaining OA3 and CSV diagnostics for troubleshooting. +- `PCPKsp.dll` copy/load failure is blocking for the Autopilot hash upload workflow because it is a prerequisite for this capture path. +- Duplicate device cleanup is not part of the final implementation. Foundry surfaces duplicate/import errors clearly, retains diagnostics, and continues OS deployment without deleting Intune, Autopilot, or Entra records. + + +## Source References +- Microsoft Learn: [Manually register devices with Windows Autopilot](https://learn.microsoft.com/en-us/autopilot/add-devices) +- Microsoft Learn: [Microsoft Graph importedWindowsAutopilotDeviceIdentity import](https://learn.microsoft.com/en-us/graph/api/intune-enrollment-importedwindowsautopilotdeviceidentity-import?view=graph-rest-1.0) +- Microsoft Learn: [Using the OA 3.0 tool on the factory floor](https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/oa3-using-on-factory-floor?view=windows-11) +- Microsoft Learn: [WinPE optional components reference](https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/winpe-add-packages--optional-components-reference?view=windows-11) +- Local artifact: `C:\Users\mchav\Downloads\foundry-autopilot-hash-winpe-en.md` +- Local artifact: `C:\Users\mchav\Downloads\HashUpload_WinPE.ps1` diff --git a/docs/implementation/autopilot-hash-upload/01-feasibility-current-state.md b/docs/implementation/autopilot-hash-upload/01-feasibility-current-state.md new file mode 100644 index 00000000..98ef3ccd --- /dev/null +++ b/docs/implementation/autopilot-hash-upload/01-feasibility-current-state.md @@ -0,0 +1,93 @@ +# Autopilot Hardware Hash Upload - Feasibility And Current State + +Part of the [Autopilot hardware hash upload implementation plan](../autopilot-hardware-hash-upload.md). + +Implementation agents must follow the repository instructions in [AGENTS.md](../../../AGENTS.md). Add XML documentation comments for public or non-obvious C# APIs when they clarify intent, contracts, or operational constraints. + +## Feasibility Summary +- WinPE hardware hash capture is technically feasible with `OA3Tool.exe /Report`. +- This is an operational workaround, not Microsoft's standard recommended Autopilot registration path. +- The current Foundry architecture already has the right integration boundaries: + - Foundry OSD configures and builds WinPE media. + - Foundry Deploy runs inside WinPE and already stages the selected JSON profile into the applied Windows image. + - Foundry Connect is not part of the feature scope. +- The final implementation should support x64 and ARM64 by using architecture-specific ADK assets. +- The final implementation should support available WinPE networking, including Ethernet and Wi-Fi, and should avoid destructive cleanup of existing Intune, Autopilot, or Entra records. +- Hash capture and upload should run near the end of the OS deployment workflow, after the Windows image is applied and the target Windows `System32` directory is available. + +## Feasibility Constraints +WinPE capture is useful because it lets an operator register a device before the installed OS reaches OOBE. The tradeoff is that Microsoft documents the normal Autopilot hash capture path around a full Windows environment, OOBE diagnostics, Audit Mode, or OEM/reseller registration. + +Operational constraints: +- Ethernet and Wi-Fi should be treated as equivalent supported network paths when WinPE has the required network stack, drivers, and credentials. +- Network readiness validation should focus on actual connectivity to Microsoft Graph, not on the adapter type. +- TPM-dependent scenarios require extra caution. Self-deploying and pre-provisioning depend heavily on correct TPM capture. +- Drivers matter. Missing storage, chipset, NIC, or TPM visibility can produce an empty or incomplete hash. +- ADK version and architecture matter. The OA3Tool staged into media should come from the same installed ADK family used to build the WinPE image, with separate x64 and ARM64 resolution. +- `PCPKsp.dll` should not be bundled into media at build time. Foundry Deploy should copy it from `\Windows\System32\PCPKsp.dll` to `X:\Windows\System32\PCPKsp.dll` after the OS image has been applied. + +Support positioning: +- Supported by Foundry as a best-effort workflow for user-driven Autopilot registration. +- Not positioned as the Microsoft-standard or OEM-supported registration workflow. +- Not recommended for self-deploying or pre-provisioning until physical validation proves TPM/EKPub capture quality. + + +## Current State +Foundry currently supports one Autopilot provisioning method: + +```text +Foundry OSD -> WinPE media config -> Foundry Deploy -> Windows\Provisioning\Autopilot\AutopilotConfigurationFile.json +``` + +Key files: +- `src/Foundry/Views/AutopilotPage.xaml` +- `src/Foundry/ViewModels/AutopilotConfigurationViewModel.cs` +- `src/Foundry.Core/Models/Configuration/AutopilotSettings.cs` +- `src/Foundry.Core/Models/Configuration/Deploy/DeployAutopilotSettings.cs` +- `src/Foundry.Core/Services/Configuration/DeployConfigurationGenerator.cs` +- `src/Foundry.Core/Services/WinPe/WinPeMountedImageAssetProvisioningService.cs` +- `src/Foundry.Deploy/Services/Autopilot/AutopilotProfileCatalogService.cs` +- `src/Foundry.Deploy/Services/Deployment/DeploymentLaunchPreparationService.cs` +- `src/Foundry.Deploy/Services/Deployment/Steps/StageAutopilotConfigurationStep.cs` + +Current data flow: + +```text +Foundry app + -> FoundryConfigurationStateService + -> DeployConfigurationGenerator + -> StartMediaViewModel + -> WinPeWorkspacePreparationService + -> WinPeMountedImageAssetProvisioningService + -> X:\Foundry\Config\foundry.deploy.config.json + -> X:\Foundry\Config\Autopilot\\AutopilotConfigurationFile.json + -> Foundry.Deploy startup + -> AutopilotProfileCatalogService + -> DeploymentLaunchPreparationService + -> StageAutopilotConfigurationStep + -> \Windows\Provisioning\Autopilot\AutopilotConfigurationFile.json +``` + +Target hash upload data flow: + +```text +Foundry Deploy deployment run + -> apply Windows image + -> configure target computer name, recovery, drivers, firmware, and partition sealing + -> enter the late Autopilot provisioning step + -> resolve the target Windows root + -> copy \Windows\System32\PCPKsp.dll to X:\Windows\System32\PCPKsp.dll + -> run C# OA3Tool capture service + -> parse OA3.xml and create diagnostic CSV + -> run C# Graph import service + -> poll import state when configured + -> retain OA3, CSV, and upload result logs under the applied OS + -> finalize deployment +``` + +Current assumptions to break carefully: +- `IsAutopilotEnabled` currently implies "a JSON profile must be selected". +- `AutopilotProfileCatalogService` only loads folder-based JSON profile assets. +- `StageAutopilotConfigurationStep` has a profile-staging name and should become the late, mode-aware Autopilot provisioning boundary. +- Media readiness only checks whether a selected profile exists when Autopilot is enabled. +- Deploy summary and telemetry only track `autopilot_enabled`, not the provisioning method. diff --git a/docs/implementation/autopilot-hash-upload/02-ux-runtime-model.md b/docs/implementation/autopilot-hash-upload/02-ux-runtime-model.md new file mode 100644 index 00000000..4878c0e5 --- /dev/null +++ b/docs/implementation/autopilot-hash-upload/02-ux-runtime-model.md @@ -0,0 +1,229 @@ +# Autopilot Hardware Hash Upload - UX And Runtime Model + +Part of the [Autopilot hardware hash upload implementation plan](../autopilot-hardware-hash-upload.md). + +Implementation agents must follow the repository instructions in [AGENTS.md](../../../AGENTS.md). Add XML documentation comments for public or non-obvious C# APIs when they clarify intent, contracts, or operational constraints. + +## Target UX +On the Autopilot page: + +- The user enables or disables Autopilot globally. +- When enabled, two settings expanders are shown: + - Offline JSON profile provisioning. + - Hardware hash capture and upload. +- Only one provisioning method can be active at a time. +- Existing JSON import, tenant profile download, default profile selection, and profile table remain inside the JSON provisioning expander. +- Hardware hash upload settings stay in a separate expander with its own validation and readiness text. + +Suggested expander content: + +JSON profile provisioning: +- Method toggle or radio selection: "Use offline Autopilot profile JSON". +- Existing import JSON action. +- Existing download from tenant action. +- Existing remove selected profiles action. +- Default profile selector. +- Existing profiles table. + +Hardware hash upload: +- Method toggle or radio selection: "Capture and upload hardware hash". +- Default state: tenant connection prompt. +- Connected state: + - Tenant readiness summary. + - Foundry-managed app registration status. + - Tenant ID. + - Client ID. + - Readiness status. + - Certificate actions. + - Provisioned certificates table. + - Boot media certificate PFX selection and password. + - Optional default group tag selection. +- Optional assigned user UPN field. +- No user-facing wait option. Foundry Deploy always waits for import/device visibility with the default countdown and timeout behavior. +- No profile assignment wait in the final implementation. +- Readiness and warning text for x64, ARM64, network connectivity, WinPE-SecureStartup, and unsupported scenarios. + +UX rules: +- The global Autopilot toggle controls whether either method is active. +- Selecting one method immediately deselects the other. +- JSON profile data can remain stored while hash mode is selected, but it must not be required. +- Hash upload settings can remain stored while JSON mode is selected, but they must not be required. +- The Start page readiness summary should show the active method, not just "Autopilot enabled". + +Foundry OSD tenant onboarding UX: +- The hardware hash expander first asks the user to connect to the tenant. +- `Connect tenant` uses the official Foundry multi-tenant public client app as the interactive sign-in bootstrap. This app has client ID `83eb3a92-030d-49b7-881b-32a1eb3e110a` and is separate from the tenant-local app used by generated WinPE media. +- Persisted tenant metadata must not be displayed as fresh tenant state on application startup. Until the current app session successfully connects to Microsoft Graph, show only the tenant connection row with `Not connected` and the connect action. +- A successful tenant connection is retained for the current app session across page navigation. Foundry OSD should require a new tenant sign-in only after the app process restarts or after the operator clicks `Disconnect tenant`. +- Certificate creation and certificate removal must reuse the current app-session Microsoft Graph credential created by `Connect tenant`. They must not start their own interactive sign-in flow during the same app session. +- After successful current-session tenant connection, show tenant-dependent rows: tenant readiness, certificate actions, provisioned certificates, boot media certificate, and optional default group tag. +- The tenant connection row shows only the connection state and action. Tenant ID, client ID, app registration state, and readiness status appear together in the tenant readiness row after connection. +- After a successful current-session connection, the connection action changes to a disconnect action that clears only the current UI session state. Persisted tenant configuration remains stored. +- After sign-in, Foundry OSD searches for the managed app registration. +- The planned app registration display name is `Foundry OSD Autopilot Registration`. +- This managed app registration is tenant-local and is the only application identity used by Foundry Deploy in WinPE for certificate-based Graph authentication. +- If the app registration does not exist, Foundry OSD creates it with the required Microsoft Graph application permissions. +- If the app registration exists and matches the persisted application object ID, Foundry OSD reuses it. +- If an app registration with the same display name exists but Foundry has no persisted application object ID, Foundry OSD must enter a repair/adoption state instead of silently taking ownership. +- Foundry OSD must persist tenant ID, application object ID, application/client ID, service principal object ID, and the certificate metadata resolved from the selected boot media PFX. +- The app registration may contain multiple certificate credentials. Tenant readiness is based on at least one valid Foundry-managed app certificate credential being present, not on a single persisted active certificate. +- Foundry must not assume exclusive ownership of the app registration and must not automatically delete, replace, or prune non-active certificate credentials. +- Extra certificate credentials on the app are tolerated and shown in a selectable certificate table, not as a blocking error. +- The certificate action buttons should be above the certificate table. +- The certificate table should show thumbprint, creation date, expiration date, and Graph certificate ID. It supports multi-row selection, and removal is enabled only when at least one certificate row is selected. +- Certificate removal must remove only the selected Graph `keyId` credentials and preserve every unselected app credential. +- The certificate row should not show a separate "valid until" message when the same expiration is already visible in the table. +- If Graph returns no certificate credentials, do not show a warning message in the certificate table area; the create certificate action is enough. +- Certificate validity text should use WinUI signal brushes: success for valid certificates, caution for certificates expiring within 30 days, and critical for expired certificates. +- Certificate creation requires selecting a validity duration from a fixed list. +- The default certificate validity is 6 months. +- Certificate validity options are fixed: + - 1 month + - 3 months + - 6 months + - 12 months +- Certificate creation produces a password-protected PFX only. PEM keys, unprotected private keys, and client secrets are not supported. +- Certificate creation requires the operator to choose a PFX output path. +- Certificate creation generates a strong PFX password. +- When Foundry OSD creates a certificate, it writes the PFX to the selected output path and shows the generated password once in a selectable, read-only field. +- The content dialog must clearly state that the operator must save both the PFX file and PFX password in a secure location before closing the dialog. +- The content dialog must provide a copy-to-clipboard action for the generated PFX password because Foundry cannot show it again after the dialog closes. +- Foundry OSD must not persist the raw PFX, PFX password, decrypted private key, exported private key material, or a DPAPI-encrypted local PFX vault in ProgramData. +- Foundry OSD may keep the PFX bytes and password in memory for the current app session only, so the operator can create the boot image immediately after certificate creation without selecting the same PFX again. +- On later application launches, the user must sign in again before Foundry OSD can inspect or manage the tenant app registration. +- After Foundry OSD restarts, before media generation, the Autopilot page requires selecting the password-protected PFX again and entering its password. The PFX leaf certificate thumbprint must match one of the non-expired app registration certificate credentials. +- The PFX input is a dedicated "Boot media certificate" row directly after the tenant certificate table. The certificate table represents credentials present in Entra; the boot media certificate row represents the local private key material selected for the generated media. +- The boot media certificate row contains a read-only PFX path field, a PFX file picker, a password box, and validation status. +- When Foundry OSD creates a certificate, the current app session automatically uses the generated PFX path and password for the boot media certificate row. +- Foundry OSD must validate that the supplied PFX leaf certificate thumbprint matches a non-expired certificate credential currently provisioned on the managed app registration before media generation. +- Foundry OSD must keep the selected PFX path, PFX password, and validation result in memory only for the current app session and must not serialize them to ProgramData. +- If the certificate is expired, Foundry OSD must show the expired status and require regenerating the certificate before the boot image can be built for hardware hash upload. +- If the selected boot media PFX certificate is missing from Graph, expired, or no longer matches any provisioned certificate credential, Foundry OSD must show a repair state before allowing hash-upload media generation. +- Removing certificates must not show a success dialog. The refreshed certificate table and readiness state are the confirmation. +- App registration permission and consent state must be explicit: + - app registration missing + - app registration created + - required Graph application permissions missing + - admin consent missing + - service principal disabled or missing + - ready for media build +- The onboarding status row should show only `Ready` or `Not ready` with WinUI signal color: success for ready and critical for not ready. A successful tenant connection should not show a success content dialog; the inline readiness row is enough. Detailed failure reasons remain available through attention/failure dialogs and Start page readiness blockers. +- Foundry OSD must block hardware hash media generation until the managed app exists, required permissions are present, admin consent is granted, the service principal is usable, at least one non-expired app registration certificate is provisioned, and the supplied PFX matches a provisioned certificate. +- The Start page must show the precise Autopilot readiness blocker instead of a generic default-profile message. Expected hardware hash blockers include missing tenant/app metadata, missing provisioned certificate, expired selected certificate, missing PFX, missing PFX password, unvalidated PFX, thumbprint mismatch, and expired boot media PFX. +- When connected to the tenant, Foundry OSD should discover existing Autopilot group tags from `GET /v1.0/deviceManagement/windowsAutopilotDeviceIdentities`, extracting `groupTag` client-side from the unfiltered response and paging through `@odata.nextLink`. +- The optional OSD group tag UX is a single `Default group tag` row with a ComboBox containing `None` plus discovered tenant group tags. `None` is selected by default because a group tag is optional for hardware hash upload. + +Foundry Deploy UX: +- Foundry Deploy should render only the selected Autopilot provisioning mode from Foundry OSD. +- JSON mode shows only the JSON/profile controls. +- Hardware hash mode shows only hardware hash upload controls. +- Hardware hash mode should attempt certificate-based Graph authentication automatically during startup/loading, before the Computer Target page becomes actionable. +- The Computer Target page should expose two mutually exclusive group tag choices: + - select the default/existing group tag supplied by Foundry OSD + - enter a custom group tag when the desired value does not exist +- If the certificate is expired in Foundry Deploy: + - do not block the OS deployment + - hide the group tag selection and custom group tag textbox + - show a clear message that Graph connection cannot be established because the certificate is expired + - tell the user to regenerate the certificate and recreate the boot image + - skip Autopilot hash upload for that deployment run +- Any tenant, certificate, token, consent, permission, Conditional Access, Intune availability, or Graph connectivity failure in Foundry Deploy must skip only the Autopilot hash upload and must not block the OS deployment. +- Any Graph import failure, duplicate-device import failure, import polling timeout, or Windows Autopilot device visibility timeout must also skip/fail only the Autopilot workflow for that deployment run and continue OS deployment with a clear warning. +- During the Autopilot provisioning step, after hash upload succeeds, Foundry Deploy must wait until the device appears in Intune Windows Autopilot devices before treating the Autopilot step as complete. +- While waiting for the device to appear, Foundry Deploy should show an indeterminate sub-progress indicator and a countdown showing the time remaining before the wait times out. +- The default Windows Autopilot device visibility wait timeout is 10 minutes. +- If the wait reaches the 10-minute timeout, Foundry Deploy should automatically continue to the next OS deployment step, mark Autopilot visibility waiting as timed out/skipped, and retain a clear warning in the deployment summary and logs. +- Waiting for import completion and Windows Autopilot device visibility is mandatory internal behavior, not a user-facing option. + +Deployment workflow placement: +- The Autopilot workflow should remain a single late deployment step after `SealRecoveryPartition` and before `FinalizeDeploymentAndWriteLogs`. +- The current JSON-specific `StageAutopilotConfigurationStep` should be renamed conceptually to `ProvisionAutopilotStep`. +- JSON mode should keep the current behavior inside this step: copy `AutopilotConfigurationFile.json` into `\Windows\Provisioning\Autopilot`. +- Hardware hash mode should run inside the same step after the applied Windows root is available: copy `PCPKsp.dll`, run OA3Tool, upload the hash, wait for Windows Autopilot device visibility, and retain diagnostics. +- Keeping both modes under one Autopilot step avoids splitting Autopilot behavior across unrelated deployment phases and keeps final artifact relocation in `FinalizeDeploymentAndWriteLogs`. + + +## Proposed Runtime Model +Add an explicit provisioning mode. + +```csharp +public enum AutopilotProvisioningMode +{ + JsonProfile, + HardwareHashUpload +} +``` + +Expert configuration should persist: +- `IsEnabled` +- `ProvisioningMode` +- existing JSON profile properties +- hardware hash upload settings + +Deploy runtime configuration should receive only the reduced settings needed by WinPE: +- `IsEnabled` +- `ProvisioningMode` +- selected JSON profile folder name when in JSON mode +- hash upload configuration when in hash mode + +Existing persisted configurations must continue to behave as JSON profile mode. + +Proposed expert model: + +```csharp +public sealed record AutopilotSettings +{ + public bool IsEnabled { get; init; } + public AutopilotProvisioningMode ProvisioningMode { get; init; } = AutopilotProvisioningMode.JsonProfile; + public string? DefaultProfileId { get; init; } + public IReadOnlyList Profiles { get; init; } = []; + public AutopilotHardwareHashUploadSettings HardwareHashUpload { get; init; } = new(); +} +``` + +Proposed hash settings: + +```csharp +public sealed record AutopilotHardwareHashUploadSettings +{ + public const string ManagedAppRegistrationDisplayName = "Foundry OSD Autopilot Registration"; + + public string TenantId { get; init; } = string.Empty; + public string ApplicationObjectId { get; init; } = string.Empty; + public string ClientId { get; init; } = string.Empty; + public string? ServicePrincipalObjectId { get; init; } + public Guid? CertificateKeyId { get; init; } + public string? CertificateThumbprint { get; init; } + public DateTimeOffset? CertificateNotAfter { get; init; } + public string? DefaultGroupTag { get; init; } + public IReadOnlyList KnownGroupTags { get; init; } = []; + public string? GroupTag { get; init; } + public string? AssignedUserPrincipalName { get; init; } +} +``` + +Validation rules: +- `IsEnabled=false`: no Autopilot settings are required. +- `IsEnabled=true` and `JsonProfile`: selected profile must exist. +- `IsEnabled=true` and `HardwareHashUpload`: tenant ID, application object ID, client ID, service principal state, active certificate key ID, active certificate thumbprint, and certificate expiration must be valid. +- Hardware hash upload always captures and uploads. There is no first-implementation capture-only mode. +- Expired certificates make Foundry OSD hardware hash media generation not ready. +- Expired certificates in Foundry Deploy skip Autopilot upload without blocking the OS deployment. +- `AssignedUserPrincipalName`, when set, must look like a UPN but should not be treated as proof that the user exists. +- `GroupTag` must not contain commas and should stay ASCII-safe for CSV compatibility. + +Deploy media generation should add encrypted `CertificatePfxSecret` and `CertificatePfxPasswordSecret` only to the generated WinPE deploy configuration for the current media build. These secret envelopes are not persisted in the normal Foundry OSD settings stored under ProgramData, and Foundry OSD does not maintain a local encrypted PFX vault. + +Phase 2 UX refinements: +- Settings cards include concise descriptions for row-level intent. +- JSON profile download and hardware hash tenant onboarding use the same tenant operation dialog runner for Microsoft Graph sign-in, progress, and cancellation. +- Canceling the tenant operation dialog returns control to the Autopilot page without leaving the connect/download action disabled. +- Hardware hash certificate management uses a shared current-session Graph credential, so create/remove certificate actions do not reopen the browser sign-in after a successful tenant connection. +- Tenant details are displayed as a table with tenant ID and client ID. +- The default group tag is optional and selected from a ComboBox populated with `None` plus tenant-discovered Autopilot device group tags. +- `None` is the default selection because hardware hash upload does not require a group tag. +- Available group tags are displayed as a one-column table for scanability. +- Certificate validity remains selectable, but the inline `Validity` label is hidden to reduce duplicate text in the certificate row. +- Current-session PFX path, password, and successful validation state are retained while navigating between pages, but are still cleared on app restart. +- The Start page must show the exact hardware hash media generation blocker when the PFX path, password, thumbprint, expiration, or active certificate is invalid. +- The onboarding status row is intentionally compact: `Ready` or `Not ready`, with signal color, while detailed remediation stays in dialogs and readiness blockers. diff --git a/docs/implementation/autopilot-hash-upload/03-security-graph.md b/docs/implementation/autopilot-hash-upload/03-security-graph.md new file mode 100644 index 00000000..6ca507af --- /dev/null +++ b/docs/implementation/autopilot-hash-upload/03-security-graph.md @@ -0,0 +1,140 @@ +# Autopilot Hardware Hash Upload - Security And Graph + +Part of the [Autopilot hardware hash upload implementation plan](../autopilot-hardware-hash-upload.md). + +Implementation agents must follow the repository instructions in [AGENTS.md](../../../AGENTS.md). Add XML documentation comments for public or non-obvious C# APIs when they clarify intent, contracts, or operational constraints. + +## Authentication Recommendation +The implementation must not use PowerShell for hardware hash capture or upload actions. + +Recommended direction: +- Use direct Microsoft Graph REST calls through C# service abstractions. +- Invoke OA3Tool through the existing C# process execution patterns, not through PowerShell. +- Split OSD interactive onboarding permissions from WinPE app-only upload permissions. +- Use the official Foundry multi-tenant public client app only as the OSD interactive sign-in bootstrap: + - Display name: `Foundry OSD` + - Client ID: `83eb3a92-030d-49b7-881b-32a1eb3e110a` + - Environment override for private builds or forks: `FOUNDRY_AUTOPILOT_GRAPH_CLIENT_ID` +- Use least-privilege WinPE app-only upload permissions: + - `DeviceManagementServiceConfig.ReadWrite.All` for import and polling. +- Defer destructive permissions: + - `DeviceManagementManagedDevices.ReadWrite.All` + - `Device.ReadWrite.All` + - `GroupMember.ReadWrite.All` + +Permission split: + +| Surface | Authentication context | Required capability | Stored in boot media | +| --- | --- | --- | --- | +| Foundry OSD tenant onboarding | Interactive signed-in admin user | Create/reuse app registration, add Graph application permissions, grant or verify admin consent, add/retire app certificate credentials, read Autopilot group tags. | No | +| Foundry Deploy in WinPE | App-only certificate credential from generated media | Import the captured hardware hash and poll import/device visibility. | Yes, as encrypted PFX envelope plus media secret key | + +Two-app model: +- `Foundry OSD` is the official Foundry-owned multi-tenant public client. It is used only to obtain delegated Microsoft Graph tokens for interactive OSD admin setup. +- `Foundry OSD Autopilot Registration` is the tenant-local app registration created or reused by Foundry OSD. It is the only app identity embedded into generated WinPE media through tenant ID, client ID, and certificate-based app-only authentication. +- The `Foundry OSD` bootstrap app client ID is not a secret and must not be replaced in official builds. Private forks can override it through `FOUNDRY_AUTOPILOT_GRAPH_CLIENT_ID`. +- WinPE must never authenticate with the `Foundry OSD` bootstrap public client. WinPE uses only the tenant-local managed app and its certificate credential. + +The interactive OSD user may need broad Entra application management rights during setup, but those delegated/session permissions are not embedded into the boot image. The boot image receives only the managed app identity and certificate material needed for Autopilot import. + +OSD setup permission guidance: +- `Application.Read.All` is sufficient only for read-only discovery of an existing managed app registration and service principal. +- `Application.ReadWrite.All` is needed when Foundry OSD creates or updates the managed app registration, including required resource access and certificate credentials. +- `AppRoleAssignment.ReadWrite.All` is needed if Foundry OSD grants the Microsoft Graph application role to the managed service principal instead of only detecting that admin consent is missing. +- `DeviceManagementServiceConfig.Read.All` is needed when Foundry OSD discovers existing Windows Autopilot device identities or group tags for the setup UX. `DeviceManagementServiceConfig.ReadWrite.All` is needed only for the WinPE app-only import workflow and any setup-time validation that writes to Intune. +- `User.Read` is needed in the interactive OSD token because the onboarding flow discovers the signed-in tenant through Microsoft Graph organization metadata before configuring the tenant-local app registration. +- If the signed-in operator does not have enough rights to grant consent, Foundry OSD should show a consent-required state and block hash-upload media generation until the tenant admin completes consent. +- These setup rights belong to the signed-in OSD operator session only. They are not stored, exported, or embedded in generated media. + +Supported WinPE authentication: +- Microsoft Graph authentication inside WinPE must use certificate-based app-only auth only. +- The certificate private key material is injected into the generated boot image as an encrypted media secret. +- The injected certificate format is a password-protected PFX whose leaf certificate thumbprint matches a non-expired certificate credential on the managed app registration. +- Device code flow, client secrets, and brokered upload are not supported WinPE authentication modes for this feature. + +Private keys, client secrets, and tenant-wide destructive permissions must not be silently embedded into generated media. + +Recommended auth decision: +- Use certificate-based app-only auth for unattended or near zero-touch WinPE upload. +- Avoid client secrets for generated media. + +Secret handling rules: +- Do not write access tokens to disk. +- Do not log authorization headers, refresh tokens, client secrets, private keys, certificate raw data, or Graph request bodies containing hardware hashes unless explicitly redacted. +- Store embedded certificate private key material with the same envelope concept used by Foundry Connect personal Wi-Fi secrets. +- Accept only password-protected PFX input for media generation. +- Reject unprotected PFX files, PEM private keys, and PFX files whose leaf certificate thumbprint does not match a non-expired managed app certificate credential. +- Require explicit user confirmation before embedding certificate private key material and document that the generated media becomes tenant-sensitive. +- Do not add a "remember this PFX" option or store a DPAPI-encrypted copy in ProgramData. +- Keep operator-provided PFX bytes and PFX password in memory only for the current app session, then clear them when media generation finishes or the app closes. +- Treat media encryption as plaintext avoidance and integrity protection, not as a strong security boundary, because the decrypt key must also be available in the boot image. +- Zero decrypted private key bytes and media secret key bytes as soon as they are no longer needed. +- Never write a decrypted PFX, PFX password, PEM private key, access token, or refresh token to disk except for the operator-selected PFX output created during certificate export. + +Existing Foundry Connect pattern: +- Foundry Core generates a random 32-byte media secret key with `RandomNumberGenerator`. +- Personal Wi-Fi passphrases are serialized as a `SecretEnvelope` with: + - `kind`: `encrypted` + - `algorithm`: `aes-gcm-v1` + - `keyId`: `media` + - base64url `nonce`, `tag`, and `ciphertext` +- The raw passphrase is omitted from `foundry.connect.config.json`. +- `WinPeMountedImageAssetProvisioningService` writes the 32-byte key to `X:\Foundry\Config\Secrets\media-secrets.key` only when encrypted secrets exist. +- Foundry Connect reads the key, decrypts the envelope, then zeroes the key bytes. + +Autopilot certificate private key plan: +- Generalize the Connect-only secret envelope code into a shared Core/Deploy media secret protector. +- Reuse the same `aes-gcm-v1` envelope shape unless a binary envelope variant is required for PFX bytes. +- Store the encrypted certificate material in the Deploy Autopilot hash upload configuration, not as a plaintext PFX file. +- Reuse `X:\Foundry\Config\Secrets\media-secrets.key` for all encrypted media secrets in the same generated image, or rename it to a generic media secret key only if the path migration is handled cleanly. +- Add media provisioning validation so encrypted Autopilot secrets require a media secret key, and a media secret key cannot be written without at least one encrypted secret. +- Foundry Deploy should decrypt the certificate material in memory, create the Graph credential, and avoid writing decrypted key material back to disk. + + +## Microsoft Graph Import Shape +Use Microsoft Graph `v1.0`: + +```http +POST /deviceManagement/importedWindowsAutopilotDeviceIdentities/import +``` + +Device payload fields: +- `serialNumber` +- `hardwareIdentifier` +- `groupTag` +- `assignedUserPrincipalName` +- `importId` + +Import state polling should handle: +- `unknown` +- `pending` +- `partial` +- `complete` +- `error` + +After the import state reaches `complete`, Foundry Deploy should poll Windows Autopilot device identities until the uploaded serial number is visible in Intune. This is the operator-facing completion condition for the Autopilot provisioning step. The wait should use a 10-minute default timeout with a visible countdown. Timeout is non-blocking for OS deployment because Intune can rarely take longer than 10 minutes to surface the device. + +Minimum Graph permission matrix: + +| Capability | Permission | Implementation status | +| --- | --- | --- | +| Import Autopilot device identity | `DeviceManagementServiceConfig.ReadWrite.All` | Required. | +| Poll imported device identity state | `DeviceManagementServiceConfig.ReadWrite.All` | Required. | +| Poll Windows Autopilot device visibility | `DeviceManagementServiceConfig.ReadWrite.All` | Required. | +| Delete Autopilot device identity | `DeviceManagementServiceConfig.ReadWrite.All` | Deferred. Not automatic in the final hash upload workflow. | +| Delete Intune managed device | `DeviceManagementManagedDevices.ReadWrite.All` | Deferred. | +| Delete Entra device | `Device.ReadWrite.All` | Deferred. | +| Add device to group | `GroupMember.ReadWrite.All` | Deferred. | + +The supplied community script deletes existing records before import to force a clean re-registration path when a serial number already exists in Intune, Windows Autopilot, or Entra ID. That can be useful in a controlled technician script because it removes stale or duplicate records before importing the new hash, but it requires destructive tenant-wide permissions and can remove records the operator did not intend to delete. Foundry's final implementation should not do this automatically. It should surface duplicate/import errors, keep diagnostics, and continue OS deployment. + +Graph request rules: +- Prefer a direct HTTP client abstraction with typed request/response records. +- Keep the import client independent from OA3Tool execution. +- Include request correlation IDs in logs when available. +- Use bounded retries for transient `429`, `5xx`, and network failures. +- Do not retry deterministic validation failures. +- Treat `ImportFailed`, duplicate-device import errors, and `ImportTimedOut` as non-blocking Autopilot failures in Foundry Deploy: surface the error, retain sanitized diagnostics, and continue OS deployment. +- For application certificate management, merge the existing `keyCredentials` collection with the new certificate credential instead of replacing the collection with only Foundry's credential. +- Remove only the active Foundry-managed certificate credential identified by the persisted `keyId`, and never remove unknown or non-active certificate credentials automatically. +- Treat any certificate, tenant, token, permission, consent, Conditional Access, Intune availability, or Graph connectivity failure as a non-blocking Autopilot skip in Foundry Deploy, not as a deployment failure. diff --git a/docs/implementation/autopilot-hash-upload/04-winpe-deploy-workflow.md b/docs/implementation/autopilot-hash-upload/04-winpe-deploy-workflow.md new file mode 100644 index 00000000..3cde30de --- /dev/null +++ b/docs/implementation/autopilot-hash-upload/04-winpe-deploy-workflow.md @@ -0,0 +1,120 @@ +# Autopilot Hardware Hash Upload - WinPE And Deploy Workflow + +Part of the [Autopilot hardware hash upload implementation plan](../autopilot-hardware-hash-upload.md). + +Implementation agents must follow the repository instructions in [AGENTS.md](../../../AGENTS.md). Add XML documentation comments for public or non-obvious C# APIs when they clarify intent, contracts, or operational constraints. + +## WinPE Hash Capture Strategy +Final capture path: + +1. Stage architecture-specific `oa3tool.exe` from the local ADK for the selected WinPE architecture. +2. Run the hash upload workflow late in Foundry Deploy, after the Windows image has been applied. +3. Copy `\Windows\System32\PCPKsp.dll` to `X:\Windows\System32\PCPKsp.dll`. +4. Generate an OA3 config file and dummy input key file from C#. +5. Run OA3Tool through a C# process execution service: + +```cmd +oa3tool.exe /Report /ConfigFile=.\OA3.cfg /NoKeyCheck /LogTrace=.\OA3.log +``` + +6. Read `OA3.xml`. +7. Extract: + - hardware hash + - serial number +8. Upload through the C# Microsoft Graph import service. +9. Save diagnostics: + - `OA3.xml` + - `OA3.log` + - generated CSV + - Foundry upload result JSON + +The implementation should add `WinPE-SecureStartup` to the default WinPE optional component set, even when Autopilot hardware hash upload is disabled. The package is small, and making it default avoids a mode-specific boot image difference while improving TPM visibility for Autopilot quality. Existing media already includes WMI, NetFX, Scripting, PowerShell, WinReCfg, DismCmdlets, StorageWMI, Dot3Svc, and EnhancedStorage. PowerShell may remain present as an existing WinPE optional component, but Foundry must not use it to perform hash capture or upload. + +`PCPKsp.dll` must not be bundled in generated media. Copying it from the applied Windows image avoids redistributing the file with Foundry media and keeps the copied DLL aligned with the target OS architecture. If the file is missing, cannot be copied, or cannot be loaded by OA3Tool, the Autopilot hash upload workflow must fail as a blocking Autopilot prerequisite failure. + +Proposed WinPE paths: +- `X:\Foundry\Tools\OA3\oa3tool.exe` +- `X:\Foundry\Runtime\AutopilotHash\OA3.cfg` +- `X:\Foundry\Runtime\AutopilotHash\input.xml` +- `X:\Foundry\Runtime\AutopilotHash\OA3.xml` +- `X:\Foundry\Runtime\AutopilotHash\OA3.log` +- `X:\Foundry\Runtime\AutopilotHash\AutopilotHWID.csv` +- `X:\Foundry\Runtime\AutopilotHash\AutopilotUploadResult.json` +- `X:\Windows\System32\PCPKsp.dll` + +Proposed retained log paths after deployment: +- `\Windows\Temp\Foundry\Logs\AutopilotHash\OA3.xml` +- `\Windows\Temp\Foundry\Logs\AutopilotHash\OA3.log` +- `\Windows\Temp\Foundry\Logs\AutopilotHash\AutopilotHWID.csv` +- `\Windows\Temp\Foundry\Logs\AutopilotHash\AutopilotUploadResult.json` + +Artifact retention rules: +- Retain Autopilot troubleshooting artifacts under the existing Foundry retained-artifact root: `\Windows\Temp\Foundry\Logs\AutopilotHash`. +- This aligns with `FinalizeDeploymentAndWriteLogsStep`, which already rebinds deployment logs and state under `\Windows\Temp\Foundry` near the end of deployment. +- The retained file set is allow-listed to `OA3.xml`, `OA3.log`, `AutopilotHWID.csv`, and a sanitized `AutopilotUploadResult.json`. +- `AutopilotUploadResult.json` may contain timestamps, serial number, import identifier, active certificate thumbprint, Graph status, and operator-facing error text. +- Retained artifacts, deployment state, deployment summary, and general log files must not contain access tokens, authorization headers, raw Graph request bodies, raw Graph response bodies, PFX bytes, PFX password, decrypted private key material, encrypted secret blobs, full certificate data, or media secret keys. +- If the retained `AutopilotHash` folder inherits permissions broader than SYSTEM and Administrators, Foundry Deploy should tighten the ACL before finalization. +- No automatic purge is planned for the first implementation. Phase 8 documentation should explain that these files are retained for troubleshooting and how an operator can remove them after diagnostics are no longer needed. + +Failure taxonomy: +- `ToolMissing`: OA3Tool is not staged or cannot execute. +- `ToolFailed`: OA3Tool exits non-zero. +- `SupportLibraryMissing`: `PCPKsp.dll` is missing from the applied Windows image. +- `SupportLibraryCopyFailed`: `PCPKsp.dll` cannot be copied to `X:\Windows\System32`. +- `SupportLibraryLoadFailed`: OA3Tool cannot use the copied `PCPKsp.dll`. +- `ReportMissing`: `OA3.xml` was not created. +- `ReportInvalid`: `OA3.xml` cannot be parsed. +- `HashMissing`: `HardwareHash` is empty. +- `SerialMissing`: BIOS serial number cannot be read. +- `NetworkUnavailable`: Graph upload is requested but no network path is available. +- `CertificateExpired`: certificate-based Graph authentication cannot run because the media certificate is expired. Foundry Deploy skips Autopilot upload and continues OS deployment. +- `CertificateMismatch`: the embedded PFX leaf certificate thumbprint does not match the configured active thumbprint. +- `CertificateMissing`: the media does not contain the encrypted PFX or password required for upload. +- `PermissionMissing`: the managed app does not have the required Microsoft Graph application permissions. +- `ConsentMissing`: required Graph application permissions do not have admin consent. +- `ServicePrincipalUnavailable`: the managed service principal is missing, disabled, or unusable. +- `ConditionalAccessBlocked`: app-only token acquisition or Graph access is blocked by tenant policy. +- `IntuneUnavailable`: Intune or the Windows Autopilot Graph endpoint is unavailable for the tenant. +- `AuthenticationFailed`: token acquisition failed. +- `ImportFailed`: Graph accepted the request path but import state reports `error`. +- `ImportTimedOut`: import polling exceeded the configured timeout. +- `AutopilotDeviceTimedOut`: import completed but the device did not appear in Windows Autopilot devices before the 10-minute wait timeout. Foundry Deploy logs a warning and continues OS deployment. + +Support library failures are blocking for the hardware hash upload workflow because `PCPKsp.dll` is a prerequisite for reliable OA3Tool hash capture in this design. They must be represented as Autopilot prerequisite failures, not as non-blocking tenant/auth skips. +Import, duplicate-device, and visibility timeout failures are non-blocking Autopilot failures. Foundry Deploy should surface them clearly, retain sanitized diagnostics, and continue to the next OS deployment step. + + +## Implementation Boundaries +Foundry app owns: +- User-facing Autopilot mode selection. +- Tenant sign-in for Autopilot hardware hash onboarding. +- Managed app registration discovery and creation. +- Active certificate lifecycle management by Graph `keyId` and thumbprint. +- One-time PFX/private key material presentation. +- Autopilot group tag discovery and default selection. +- Expert configuration persistence. +- Media readiness and media generation. +- Tenant setting pre-validation where possible. + +Foundry.Core owns: +- Shared configuration records and enums. +- Deploy configuration generation. +- WinPE media asset provisioning. +- Low-level helpers that are independent of WPF/WinUI. + +Foundry.Deploy owns: +- Runtime configuration consumption. +- Deployment wizard behavior. +- Hardware hash Computer Target mode display. +- Certificate expiration detection and non-blocking Autopilot skip. +- Runtime group tag selection or custom group tag input. +- Late mode-aware Autopilot deployment step after `SealRecoveryPartition` and before `FinalizeDeploymentAndWriteLogs`. +- `PCPKsp.dll` copy from the applied Windows image to `X:\Windows\System32`. +- OA3Tool execution through C# process orchestration. +- Graph import through C# service abstractions. +- Polling until the imported device appears in Windows Autopilot devices, with a visible 10-minute countdown and non-blocking timeout. +- Deployment logs and summary artifacts. + +Foundry.Connect owns: +- Nothing for this feature. diff --git a/docs/implementation/autopilot-hash-upload/05-implementation-phases.md b/docs/implementation/autopilot-hash-upload/05-implementation-phases.md new file mode 100644 index 00000000..5157faf3 --- /dev/null +++ b/docs/implementation/autopilot-hash-upload/05-implementation-phases.md @@ -0,0 +1,495 @@ +# Autopilot Hardware Hash Upload - Implementation Phases + +Part of the [Autopilot hardware hash upload implementation plan](../autopilot-hardware-hash-upload.md). + +Implementation agents must follow the repository instructions in [AGENTS.md](../../../AGENTS.md). Add XML documentation comments for public or non-obvious C# APIs when they clarify intent, contracts, or operational constraints. + +## Phased Implementation + +### Phase 0: Foundation Branch And Research +PR title: `docs(autopilot): plan hardware hash upload from WinPE` + +Implementation progress: +- [x] Foundation worktree created. +- [x] Foundation branch created. +- [x] Planning documentation committed. +- [x] Foundation branch pushed. +- [ ] Foundation PR opened. +- [ ] Foundation PR reviewed and merged. + +- [x] Create dedicated worktree. +- [x] Create foundation branch. +- [x] Analyze supplied feasibility document. +- [x] Analyze supplied WinPE upload script. +- [x] Query current Microsoft Graph and MSAL documentation through Context7. +- [x] Record the baseline test command without running full solution tests during planning. + +Manual checks: +- [x] Confirm branch is isolated from `main`. +- [x] Confirm baseline test command is known: `dotnet test .\src\Foundry.slnx --configuration Debug /p:Platform=x64`. + +### Phase 1: Configuration Model +PR title: `feat(autopilot): add provisioning mode configuration` + +Implementation progress: +- [x] Phase branch created from `feature/autopilot-hash-upload-foundation`. +- [x] Implementation checklist complete. +- [x] Automated tests complete. +- [x] Manual checks complete or explicitly deferred. +- [x] PR opened with the planned title. +- [ ] PR merged back into `feature/autopilot-hash-upload-foundation`. + +- [x] Add `AutopilotProvisioningMode`. +- [x] Extend `AutopilotSettings` with mode and hardware hash upload settings. +- [x] Extend `DeployAutopilotSettings` with reduced runtime mode and upload settings. +- [x] Add active certificate metadata: Graph `keyId`, thumbprint, expiration, and display name. +- [x] Add tenant app registration identity, service principal identity, known group tags, and default group tag settings. +- [x] Update schema version handling if needed. +- [x] Keep old configurations backward compatible as JSON profile mode. +- [x] Update sanitization in `FoundryConfigurationStateService`. +- [x] Update `DeployConfigurationGenerator`. +- [x] Add XML documentation comments to new public configuration records, enums, and service contracts when they clarify the behavior. + +Automated tests: +- [x] Existing JSON profile config serializes and generates the same deploy output. +- [x] Enabled JSON mode requires a selected profile. +- [x] Enabled hash upload mode does not require a selected profile. +- [x] Capture-and-upload mode requires tenant ID, application object ID, client ID, active certificate `keyId`, active certificate thumbprint, and unexpired certificate metadata. +- [x] Invalid certificate settings make Autopilot configuration not ready. +- [x] Expired certificate settings make OSD media generation not ready for hardware hash upload. +- [x] Persistent OSD settings never serialize PFX bytes, PFX password, decrypted private key material, or access tokens. + +Manual checks: +- [ ] Deferred to the Phase 3 UI validation pass: start Foundry with existing user config and confirm JSON profile mode is selected. +- [ ] Deferred to the Phase 3 UI validation pass: disable Autopilot and confirm no profile or hash settings are required. + +### Phase 2: Security And Tenant Onboarding +PR title: `feat(autopilot): add secure tenant upload onboarding` + +Implementation progress: +- [x] Phase branch created from `feature/autopilot-hash-upload-foundation`. +- [x] Implementation checklist complete. +- [x] Automated tests complete. +- [x] Manual checks complete or explicitly deferred. +- [x] PR opened with the planned title. +- [ ] PR merged back into `feature/autopilot-hash-upload-foundation`. + +Scope note: +- Phase 2 went beyond the original security-only scope and pulled forward most of the OSD hardware hash UX planned for Phase 3. +- Future phases must not reimplement tenant connection, tenant readiness, certificate management, boot media PFX validation, default group tag discovery/selection, or Start page hardware hash readiness blockers unless a later review explicitly changes the UX. + +- [x] Define the permission matrix for the implementation model; user-facing documentation is handled in Phase 8. +- [x] Define tenant/app registration guidance for the OSD onboarding UX and Phase 8 documentation. +- [x] Document the two-app model: official Foundry bootstrap public client for interactive OSD sign-in, tenant-local managed app for WinPE certificate auth. +- [x] Keep the official Foundry bootstrap public client ID fixed for official builds and overrideable only for private builds or forks. +- [x] Implement managed app registration discovery/creation with display name `Foundry OSD Autopilot Registration`. +- [x] Persist tenant ID, application object ID, client ID, service principal object ID, active certificate `keyId`, active certificate thumbprint, and certificate expiration. +- [x] Implement required Graph permission checks and admin consent status checks. +- [x] Implement service principal presence/enabled checks. +- [x] Implement active certificate lifecycle management against Microsoft Graph `keyCredentials`. +- [x] Merge new certificate credentials with the existing `keyCredentials` collection and never prune unknown credentials automatically. +- [x] Implement repair/adoption state for existing display-name matches, missing active certificate credentials, and multiple Foundry-looking credentials without a persisted active certificate. +- [x] Accept only password-protected PFX material for media generation. +- [x] Require a PFX output path during certificate creation. +- [x] Keep created PFX bytes and password in memory only for the current app session. +- [x] Do not implement a ProgramData PFX vault or "remember this PFX" option. +- [x] Validate the PFX leaf certificate thumbprint against the configured active certificate thumbprint. +- [x] Define certificate app-only auth as the only supported WinPE Graph authentication path for code, XML documentation comments, and Phase 8 documentation. +- [x] Define generated media containing encrypted certificate private key material as tenant-sensitive for code warnings, UI copy, and Phase 8 documentation. +- [x] Generalize the existing Foundry Connect AES-GCM media secret envelope for Autopilot secrets. +- [x] Define device code flow, client secrets, and brokered upload as unsupported WinPE authentication modes. +- [x] Define unsupported secret embedding patterns and add test coverage for them. +- [x] Add audit-safe logging rules. +- [x] Add XML documentation comments to new public tenant onboarding, certificate, and secret-protection APIs. +- [x] Reuse the JSON profile tenant download modal sign-in pattern for hardware hash tenant onboarding. +- [x] Route JSON profile download and hardware hash tenant onboarding through one shared tenant operation dialog service. +- [x] Remove the obsolete JSON-specific tenant download dialog wrapper API. +- [x] Make tenant operation cancellation return control to the Autopilot page so the connect action can be retried. +- [x] Reuse the current-session hardware hash Microsoft Graph credential for certificate creation and removal instead of reopening interactive sign-in. +- [x] Clear the current-session hardware hash Microsoft Graph credential when the operator disconnects the tenant. +- [x] Keep interactive Microsoft Graph authentication session-only by disabling persistent MSAL token cache storage for OSD tenant operations. +- [x] Include `User.Read` in the hardware hash onboarding token scopes so Graph organization discovery can read the signed-in tenant ID. +- [x] Keep tenant-dependent OSD UI session-gated: persisted tenant metadata stays stored, but tenant readiness, certificate, and group tag rows stay hidden until a successful current-session tenant connection. +- [x] Show tenant readiness in one dedicated row instead of embedding tenant and readiness details in separate rows. +- [x] Display managed app registration state, tenant ID, client ID, and readiness status in a compact read-only table. +- [x] Add descriptions to Autopilot settings cards so users understand each configuration row. +- [x] Replace the connect action with a disconnect action after successful current-session tenant connection. +- [x] Clear stale persisted active certificate metadata when Microsoft Graph no longer returns the selected active certificate. +- [x] Split certificate management into a certificate action row and a provisioned certificates table row. +- [x] List app registration certificate credentials in a selectable table with thumbprint, creation date, expiration date, and Graph certificate ID. +- [x] Show an empty-state message in the provisioned certificates table row when the tenant app registration has no certificate credentials. +- [x] Do not display an empty-certificate warning when the app registration has no certificate credentials. +- [x] Allow multiple app registration certificates to coexist in the tenant instead of replacing the previously selected certificate during creation. +- [x] Filter the provisioned certificate table to Foundry-managed certificate credentials so unrelated app registration credentials are not shown or removable from Foundry. +- [x] Delete the generated local PFX file if Graph certificate upload fails during certificate creation. +- [x] Resolve the boot media certificate automatically by matching the selected PFX thumbprint against tenant app registration certificates. +- [x] Move certificate action buttons above the certificate table. +- [x] Remove the visible certificate validity field label while keeping the validity duration selector. +- [x] Remove the redundant active certificate "valid until" text when the same expiration is already visible in the certificate table. +- [x] Remove one or more selected certificate credentials while preserving unrelated app credentials. +- [x] Use WinUI signal brushes for certificate validity: success when valid, caution when expiring within 30 days, and critical when expired. +- [x] Add padding to the certificate expiration table cell so the validity text aligns with the other columns. +- [x] Show the generated PFX password in a selectable read-only field in the one-time certificate-created dialog. +- [x] Make the certificate-created dialog explicitly tell the operator to save both the PFX file and generated password before closing it. +- [x] Add a copy-to-clipboard action for the generated PFX password in the one-time certificate-created dialog. +- [x] Enforce Graph application certificate validity limit by offering 1, 3, 6, and 12 months only, with 6 months selected by default. +- [x] Add a dedicated boot media certificate row for selecting the local password-protected PFX and entering its password. +- [x] Automatically fill the boot media certificate row in the current app session after Foundry creates a new certificate. +- [x] Keep boot media PFX path, password, and validation result session-only and excluded from ProgramData serialization. +- [x] Preserve the boot media certificate ready message across Autopilot page navigation when the current-session PFX is still validated. +- [x] Refresh only boot media certificate status while typing the PFX password so tenant readiness details do not rebind on every keystroke. +- [x] Prioritize boot media PFX-specific readiness messages over generic active certificate metadata blockers on the Autopilot page and Start page. +- [x] Preserve current-session tenant connection, certificate table, onboarding status, and boot media PFX state across page navigation without persisting them across app restart. +- [x] Show onboarding status as compact `Ready` or `Not ready` text with WinUI signal color. +- [x] Show tenant connection state as `Connected` in success color or `Not connected` in critical color. +- [x] Suppress the tenant onboarding success content dialog; successful connection is shown inline through the readiness table. +- [x] Keep tenant readiness `Ready` when at least one valid Foundry-managed app certificate remains after removing other selected certificates. +- [x] Remove obsolete verbose onboarding status resource strings after moving detailed remediation to dialogs and readiness blockers. +- [x] Add detailed Autopilot validation codes and Start page messages for hardware hash media generation blockers. +- [x] Discover available group tags from the unfiltered `deviceManagement/windowsAutopilotDeviceIdentities` Graph endpoint and extract `groupTag` client-side. +- [x] Preserve the previously saved default group tag if group tag discovery fails during tenant connection. +- [x] Select the optional default group tag from a ComboBox populated by `None` and discovered tenant group tags. +- [x] Keep `None` selected by default because hardware hash upload does not require a group tag. +- [x] Populate the default group tag ComboBox from discovered tenant group tags without displaying a duplicate available group tag table. +- [x] Present the optional group tag configuration as one compact `Default group tag` row. +- [x] Remove obsolete certificate validity/status UI resources after moving readiness to onboarding status, certificate table colors, and boot media PFX validation. + +Automated tests: +- [x] App registration discovery uses persisted application object ID before display name. +- [x] Same display name without persisted object ID enters repair/adoption state. +- [x] Required permission missing maps to `PermissionMissing`. +- [x] Admin consent missing maps to `ConsentMissing`. +- [x] Disabled or missing service principal maps to `ServicePrincipalUnavailable`. +- [x] Adding a certificate preserves existing non-active `keyCredentials`. +- [x] App registrations with existing Foundry certificate credentials are tenant-ready without requiring a manual active certificate selection. +- [x] PFX validation can read certificate metadata without a preselected expected thumbprint. +- [x] Retiring a certificate removes only the persisted active `keyId`. +- [x] Created PFX material is not persisted in ProgramData, even with DPAPI. +- [x] Covered by manual validation: after app restart, media generation requires the operator to select the PFX again and enter its password. +- [x] PFX thumbprint mismatch blocks media generation. +- [x] Secret settings are never serialized into plain deploy config. +- [x] Tampered encrypted certificate envelopes fail without leaking ciphertext, private key material, or certificate password data. +- [x] Deferred to Phase 8 documentation/release guardrails: logs redact tokens, secrets, private key paths, certificate data, PFX bytes, and PFX password. +- [x] Foundry OSD build passes after tenant onboarding UX refinements. +- [x] Autopilot targeted tests pass after tenant onboarding UX refinements. + +Manual checks: +- [x] Create the managed app registration in a clean test tenant. +- [x] Confirm the app registration name is `Foundry OSD Autopilot Registration`. +- [x] Confirm `Connect tenant` creates an Enterprise application for the official `Foundry OSD` bootstrap client ID `83eb3a92-030d-49b7-881b-32a1eb3e110a` in the target tenant. +- [x] Confirm required API permissions and admin consent status are visible in Foundry OSD. +- [x] Add a second certificate credential outside Foundry and confirm Foundry leaves it untouched. +- [x] Create multiple Foundry certificates and confirm new certificate creation does not remove existing certificates. +- [x] Create a certificate, choose a PFX output path, and confirm the PFX exists only at the selected path. +- [x] Restart Foundry OSD and confirm it requires selecting the PFX again before media generation. +- [x] Deferred to Phase 4 media validation: review generated media contents and confirm certificate private key material is envelope-encrypted, not plaintext. +- [x] Deferred to Phase 8 release guardrails: review logs after failed auth and successful auth. +- [x] Deferred to Phase 7 Graph upload validation: confirm least-privilege app registration can import devices. +- [x] Start Foundry OSD with persisted tenant metadata and confirm only `Tenant connection`, `Not connected`, and `Connect tenant` are shown before current-session sign-in. +- [x] Click `Connect tenant`, cancel the tenant sign-in dialog, and confirm the Autopilot page remains responsive and `Connect tenant` can be clicked again. +- [x] Click JSON profile `Download from tenant`, cancel the tenant sign-in dialog, and confirm the JSON profile actions remain responsive. +- [x] Connect to the tenant and confirm tenant readiness, certificate actions, provisioned certificates, boot media certificate, and default group tag selection become visible. +- [x] After connecting once, create and remove certificates and confirm the browser sign-in prompt does not reopen during the same app session. +- [x] Confirm `Tenant connection` shows only `Connected` or `Not connected`, and the tenant ID appears only in the dedicated tenant readiness row. +- [x] Confirm tenant readiness shows managed app registration state, tenant ID, client ID, and readiness status in a compact table after connecting. +- [x] Confirm each Autopilot settings card has a concise description. +- [x] Confirm tenant readiness displays status as only `Ready` in success color or `Not ready` in critical color. +- [x] Confirm tenant connection displays `Connected` in success color or `Not connected` in critical color. +- [x] After connecting, confirm the action changes to `Disconnect tenant` and disconnecting hides tenant-dependent rows without deleting persisted configuration. +- [x] Connect to a tenant where the persisted active certificate no longer exists in Graph and confirm Foundry clears stale active certificate metadata instead of showing a valid expiration. +- [x] Connect to an app registration with no certificate credentials and confirm no empty-certificate warning text is displayed. +- [x] Connect to an app registration with no certificate credentials and confirm the provisioned certificates row shows the empty-state message. +- [x] Create a certificate and confirm the generated PFX password is selectable/copyable in the content dialog. +- [x] Create a certificate and confirm the content dialog clearly tells the operator to save both the PFX file and PFX password before closing it. +- [x] Click `Copy password` in the certificate-created dialog and confirm the generated PFX password is copied to the clipboard. +- [x] Create a second certificate and confirm the previous certificate remains present in the tenant certificate table. +- [x] Confirm the boot media certificate row is automatically filled after certificate creation and returns to empty after app restart. +- [x] Select each generated PFX with its password and confirm Foundry automatically resolves the matching tenant certificate before reaching the ready state. +- [x] Select a mismatched PFX and confirm the boot media certificate row shows a thumbprint mismatch. +- [x] Navigate away from the Autopilot page and back; confirm the tenant remains connected and tenant-dependent rows remain visible. +- [x] Restart Foundry OSD and confirm the tenant connection returns to the disconnected prompt. +- [x] In hardware hash mode with no selected boot media PFX, confirm the Start page shows the missing PFX blocker instead of the JSON profile blocker. +- [x] In hardware hash mode with a mismatched PFX, confirm the Start page shows the thumbprint mismatch blocker. +- [x] Confirm the certificate table shows thumbprint, creation date, expiration date, and certificate ID with the expected validity color. +- [x] Confirm `Certificate actions` contains only certificate validity, create certificate, and remove certificate controls. +- [x] Confirm `Provisioned certificates` contains only the certificate empty state or certificate table. +- [x] Confirm the certificate validity duration selector no longer shows a visible `Validity` label. +- [x] Confirm the certificate action buttons are shown above the certificate table. +- [x] Confirm the redundant active certificate "valid until" text is not shown when the same expiration is already visible in the certificate table. +- [x] Confirm the certificate expiration column text has the same left padding as the other certificate columns. +- [x] Confirm the remove certificate action is disabled when no certificate row is selected. +- [x] Select one or more certificate rows and remove them; confirm only the selected credentials are removed from Entra and the table refreshes. +- [x] Create multiple certificates, remove a subset, and confirm tenant readiness stays `Ready` while at least one valid certificate remains. +- [x] Connect to a ready tenant and confirm no success content dialog is shown. +- [x] Connect to a tenant with existing Autopilot device group tags and confirm they appear in the `Default group tag` ComboBox without a duplicate available group tag table. +- [x] Confirm the optional group tag area is one compact `Default group tag` row. +- [x] Confirm the default group tag ComboBox selects `None`/`Aucun` by default depending on UI language. +- [x] Select a default group tag from the ComboBox and confirm it is saved in the Foundry configuration, then select `None`/`Aucun` and confirm the setting is cleared. +- [x] Create a certificate, navigate away from Autopilot and back, and confirm the boot media certificate row still shows `Certificate ready for boot media generation.` + +### Phase 3: Foundry Deploy Autopilot UX +PR title: `feat(deploy): add autopilot hardware hash UX` + +Status note: +- Most Phase 3 UX work was completed ahead of schedule during Phase 2 in `feature/autopilot-hash-upload-security`. +- Phase 3 now focuses on Foundry Deploy Autopilot UX only. Foundry OSD Autopilot UX should be treated as already implemented unless a later review explicitly changes it. +- Runtime execution still belongs to Phase 5 and later phases. Phase 3 must not implement OA3Tool execution, Graph certificate authentication, hash import, or device visibility polling. + +Implementation progress: +- [x] Phase branch created from `feature/autopilot-hash-upload-foundation`. +- [ ] Implementation checklist complete. +- [ ] Automated tests complete. +- [ ] Manual checks complete or explicitly deferred. +- [ ] PR opened with the planned title. +- [ ] PR merged back into `feature/autopilot-hash-upload-foundation`. + +- [ ] Render only the selected Autopilot provisioning mode from the OSD-generated deploy configuration. +- [ ] Keep JSON profile mode UI unchanged except for wording that makes the selected mode explicit. +- [ ] In hardware hash mode, do not show JSON profile selection or JSON staging controls. +- [ ] In hardware hash mode, show a compact Autopilot hardware hash section on the Computer Target page. +- [ ] Show tenant/app registration summary needed for operator confidence: tenant ID, client ID, certificate thumbprint, certificate expiration, and default group tag. +- [ ] If the certificate is valid, show Autopilot hardware hash as available but not executed until the runtime phases are implemented. +- [ ] If the certificate is expired, show a clear non-blocking message telling the operator to regenerate the certificate and recreate the boot image; continue OS deployment without Autopilot. +- [ ] Add two mutually exclusive group tag choices for hardware hash mode: + - use the default group tag from Foundry OSD, including `None` + - enter a custom group tag for this deployment +- [ ] Disable or hide group tag controls when certificate authentication cannot be attempted. +- [ ] Add a pre-runtime warning that hardware hash upload is not yet implemented until Phase 5+ lands, without blocking JSON mode. +- [ ] Update deployment launch preparation UI so hash mode no longer reports a missing JSON profile blocker. +- [ ] Add localized strings in English and French resources. +- [ ] Add XML documentation comments to new public Deploy view-model members or UI service contracts when the behavior is not obvious. + +Automated tests: +- [ ] Deploy target page renders JSON profile controls in JSON mode. +- [ ] Deploy target page renders hardware hash controls in hash mode. +- [ ] Deploy target page hides JSON profile controls in hash mode. +- [ ] Hash mode does not require a selected JSON profile in launch preparation. +- [ ] Expired certificate state hides hardware hash group tag controls and leaves deployment start available. +- [ ] Default group tag selection initializes from the OSD-generated configuration. +- [ ] Custom group tag entry overrides the default group tag for the current deployment request. +- [ ] `None` group tag remains a valid selection and serializes as no group tag. +- [ ] Live hardware hash mode displays the pre-runtime unavailable/skipped state until Phase 5+ implements execution. + +Manual checks: +- [ ] In JSON mode, confirm Foundry Deploy shows only JSON/profile Autopilot controls. +- [ ] In hardware hash mode, confirm Foundry Deploy shows only hardware hash Autopilot controls. +- [ ] Confirm the Computer Target page shows tenant ID, client ID, certificate thumbprint, certificate expiration, and selected/default group tag in hash mode. +- [ ] In hash mode with a valid certificate, confirm the operator can choose the default group tag or enter a custom group tag. +- [ ] In hash mode with `None`, confirm no group tag is sent in the deployment request. +- [ ] In hash mode with an expired certificate, confirm Deploy shows the regeneration/recreate media message, hides group tag controls, and still allows OS deployment. +- [ ] Confirm hash mode does not show a missing JSON profile blocker. +- [ ] Confirm JSON mode behavior and text did not regress. + +### Phase 4: Media Build And WinPE Assets +PR title: `feat(winpe): stage autopilot hash capture assets` + +Implementation progress: +- [ ] Phase branch created from `feature/autopilot-hash-upload-foundation`. +- [ ] Implementation checklist complete. +- [ ] Automated tests complete. +- [ ] Manual checks complete or explicitly deferred. +- [ ] PR opened with the planned title. +- [ ] PR merged back into `feature/autopilot-hash-upload-foundation`. + +- [ ] Add `WinPE-SecureStartup` to the default required optional components for all generated WinPE media. +- [ ] Locate and stage architecture-specific `oa3tool.exe` from the ADK for x64 and ARM64. +- [ ] Add hash capture templates under a Foundry-owned WinPE path. +- [ ] Add hash upload runtime configuration under `X:\Foundry\Config`. +- [ ] Write encrypted Autopilot PFX and PFX password envelopes plus the media secret key through the shared media secret provisioning path. +- [ ] Keep current profile JSON staging unchanged in JSON profile mode. +- [ ] Do not stage JSON profile folders in hash upload mode unless the user also keeps profiles for another purpose. +- [ ] Do not stage `PCPKsp.dll` during media build. +- [ ] Add XML documentation comments to new public media asset provisioning APIs and secret envelope APIs. + +Automated tests: +- [ ] Media asset provisioning writes JSON profile assets only in JSON mode. +- [ ] Media asset provisioning writes hash upload assets only in hash mode. +- [ ] Missing `oa3tool.exe` produces a clear validation error. +- [ ] ADK asset resolution chooses the expected path for x64 and ARM64 media. +- [ ] Encrypted Autopilot secrets require a media secret key. +- [ ] A media secret key is rejected when no encrypted media secrets exist. +- [ ] `WinPE-SecureStartup` missing or not applicable is surfaced clearly during media preparation. + +Manual checks: +- [ ] Build x64 ISO in JSON profile mode and confirm existing profile files are present. +- [ ] Build x64 ISO in hash upload mode and confirm OA3/hash assets are present. +- [ ] Build ARM64 ISO in JSON profile mode and confirm existing profile files are present. +- [ ] Build ARM64 ISO in hash upload mode and confirm OA3/hash assets are present. +- [ ] Confirm `WinPE-SecureStartup` is present in the mounted image package list. +- [ ] Confirm no plaintext PFX, PFX password, private key, token, or client secret is written to media. + +### Phase 5: Foundry Deploy Runtime Branching +PR title: `feat(deploy): branch autopilot runtime by provisioning mode` + +Implementation progress: +- [ ] Phase branch created from `feature/autopilot-hash-upload-foundation`. +- [ ] Implementation checklist complete. +- [ ] Automated tests complete. +- [ ] Manual checks complete or explicitly deferred. +- [ ] PR opened with the planned title. +- [ ] PR merged back into `feature/autopilot-hash-upload-foundation`. + +- [ ] Load Autopilot provisioning mode from deploy config. +- [ ] Expose mode in startup snapshot, preparation view model, launch request, deployment context, and runtime state. +- [ ] Consume the hardware hash group tag choice captured by the Phase 3 Computer Target UX. +- [ ] Update `DeploymentLaunchPreparationService` validation: + - JSON mode requires selected profile. + - Hash upload mode requires valid upload settings. +- [ ] Rename or replace `StageAutopilotConfigurationStep` with a mode-aware `ProvisionAutopilotStep`. +- [ ] Update `DeploymentStepNames.All`, dependency injection registration, and sequence validation tests together when the Autopilot step is renamed or replaced. +- [ ] Keep the Autopilot provisioning step after `SealRecoveryPartition` and before `FinalizeDeploymentAndWriteLogs`. +- [ ] JSON mode copies `AutopilotConfigurationFile.json` from the mode-aware Autopilot provisioning step. +- [ ] Hash upload mode skips JSON staging and runs the hash capture/upload workflow from the same late Autopilot provisioning step. +- [ ] Update deployment summary, logs, and telemetry with mode, planned hash-upload status, and retained diagnostics path. +- [ ] Add runtime status states for hash upload warnings, skipped states, and later Graph import outcomes without requiring a live Graph call in this phase. +- [ ] Persist sanitized Autopilot diagnostics under `\Windows\Temp\Foundry\Logs\AutopilotHash`. +- [ ] Add XML documentation comments to new public deployment runtime contracts and step classes. + +Automated tests: +- [ ] JSON mode still stages the profile to `Windows\Provisioning\Autopilot`. +- [ ] Hash upload mode skips JSON staging. +- [ ] Dry run creates a hash-mode manifest without touching Graph. +- [ ] Autopilot provisioning step is ordered after `SealRecoveryPartition` and before `FinalizeDeploymentAndWriteLogs`. +- [ ] Hash upload mode runs only after the applied Windows root and target Windows `System32` are available. +- [ ] Launch preparation rejects incomplete hash upload settings. +- [ ] Expired certificate state hides hardware hash group tag controls and leaves deployment start available. +- [ ] Runtime state can represent a skipped Autopilot hash upload without failing the deployment state machine. + +Manual checks: +- [ ] Deploy dry-run in JSON mode. +- [ ] Deploy dry-run in hash upload mode. +- [ ] Confirm summary page displays the selected Autopilot method. +- [ ] In hash mode, confirm the group tag selected on Computer Target flows into the runtime launch request. +- [ ] In JSON mode, confirm no hardware hash group tag state is carried into the runtime launch request. +- [ ] In hash mode with expired certificate, confirm Deploy shows the regeneration/recreate media message and still allows OS deployment. +- [ ] Confirm logs contain mode, hash capture diagnostics path, and upload state. + +### Phase 6: Hash Capture Service +PR title: `feat(deploy): capture autopilot hardware hash in WinPE` + +Implementation progress: +- [ ] Phase branch created from `feature/autopilot-hash-upload-foundation`. +- [ ] Implementation checklist complete. +- [ ] Automated tests complete. +- [ ] Manual checks complete or explicitly deferred. +- [ ] PR opened with the planned title. +- [ ] PR merged back into `feature/autopilot-hash-upload-foundation`. + +- [ ] Add a C# service that runs OA3Tool with controlled working directory paths. +- [ ] Add a C# service that copies `PCPKsp.dll` from `\Windows\System32` to `X:\Windows\System32`. +- [ ] Validate source and destination architecture assumptions for x64 and ARM64. +- [ ] Generate `OA3.cfg` and dummy input XML internally. +- [ ] Validate `OA3.xml` exists. +- [ ] Extract serial number and hardware hash. +- [ ] Write a local CSV artifact for troubleshooting. +- [ ] Preserve OA3 logs in Foundry deployment logs. +- [ ] Return structured failure codes for missing tool, `PCPKsp.dll` copy/load failure, empty hash, invalid XML, missing serial, and OA3 exit failure. +- [ ] Add XML documentation comments to new public hash capture, OA3Tool, parser, and artifact writer APIs. + +Automated tests: +- [ ] Resolves the applied Windows `System32` source path. +- [ ] Copies `PCPKsp.dll` to `X:\Windows\System32` before OA3Tool execution. +- [ ] Parses valid `OA3.xml`. +- [ ] Rejects missing `HardwareHash`. +- [ ] Rejects invalid XML. +- [ ] Generates CSV without quotes, extra columns, or Unicode encoding. +- [ ] Sanitizes commas from group tag and serial number. + +Manual checks: +- [ ] Run on one x64 physical test device with Ethernet. +- [ ] Run on one x64 physical test device with Wi-Fi. +- [ ] Run on one ARM64 physical test device with Ethernet. +- [ ] Run on one ARM64 physical test device with Wi-Fi. +- [ ] Confirm generated hash imports manually in Intune. +- [ ] Confirm troubleshooting files are retained under `\Windows\Temp\Foundry\Logs\AutopilotHash`. +- [ ] Confirm retained files do not contain tokens, PFX bytes, PFX password, decrypted private key material, encrypted secret blobs, or raw Graph payloads. + +### Phase 7: Graph Upload Service +PR title: `feat(autopilot): import hardware hashes with Graph` + +Implementation progress: +- [ ] Phase branch created from `feature/autopilot-hash-upload-foundation`. +- [ ] Implementation checklist complete. +- [ ] Automated tests complete. +- [ ] Manual checks complete or explicitly deferred. +- [ ] PR opened with the planned title. +- [ ] PR merged back into `feature/autopilot-hash-upload-foundation`. + +- [ ] Add a minimal Graph Autopilot import client. +- [ ] Add certificate-based credential creation from decrypted in-memory certificate material. +- [ ] Reject any non-certificate authentication mode in WinPE. +- [ ] Implement import request. +- [ ] Implement polling for import completion. +- [ ] Implement polling until the uploaded serial number appears in Windows Autopilot devices. +- [ ] Add a 10-minute default timeout for Windows Autopilot device visibility polling. +- [ ] Map Graph errors to operator-readable messages. +- [ ] Add retry/backoff for transient HTTP failures. +- [ ] Treat certificate, tenant, token, consent, permission, Conditional Access, Intune availability, Graph connectivity, `ImportFailed`, duplicate import, and `ImportTimedOut` states as non-blocking Autopilot failures that continue OS deployment. +- [ ] Keep destructive cleanup out of the final hash upload workflow. +- [ ] Sanitize `AutopilotUploadResult.json` before retaining it in `Windows\Temp\Foundry`. +- [ ] Add XML documentation comments to new public Graph client, import polling, and retry-policy APIs. + +Automated tests: +- [ ] Serializes import payload correctly. +- [ ] Sends hardware identifier in the expected Graph format. +- [ ] Decrypts PFX material in memory and does not write a decrypted PFX, PFX password, or private key to disk. +- [ ] Fails clearly when tenant ID, client ID, certificate thumbprint, or encrypted certificate material is missing. +- [ ] Treats certificate, tenant, token, permission, consent, Conditional Access, Intune availability, and Graph connectivity failures as skipped Autopilot, not failed deployment. +- [ ] Treats duplicate import errors, `ImportFailed`, and `ImportTimedOut` as Autopilot warnings/failures that do not stop OS deployment. +- [ ] Handles `complete`. +- [ ] Handles imported identity completion followed by Windows Autopilot device visibility. +- [ ] Handles Windows Autopilot device visibility timeout as an automatic warning/non-blocking continuation to the next deployment step. +- [ ] Handles `error` with device error code/name. +- [ ] Times out with a clear message. +- [ ] Retries transient failures only. +- [ ] Sanitized upload result omits access tokens, authorization headers, raw request bodies, raw response bodies, PFX bytes, passwords, private key material, and full certificate data. + +Manual checks: +- [ ] Import one test device into a test tenant. +- [ ] Confirm Group Tag appears in Intune. +- [ ] Confirm deployment waits until the device appears in Windows Autopilot devices. +- [ ] Confirm the wait shows an indeterminate sub-progress indicator and countdown. +- [ ] Confirm a 10-minute visibility timeout automatically continues OS deployment and records a warning. +- [ ] Confirm assignment sync behavior is documented, even if not waited on by the final implementation. +- [ ] Confirm duplicate device behavior is clear to the operator. +- [ ] Confirm an existing duplicate device import error is surfaced clearly and does not trigger automatic cleanup. +- [ ] In hash mode with simulated auth failure, confirm Deploy shows an Autopilot warning and still continues OS deployment. + +### Phase 8: Documentation And Release Guardrails +PR title: `docs(autopilot): document WinPE hardware hash upload` + +Implementation progress: +- [ ] Phase branch created from `feature/autopilot-hash-upload-foundation`. +- [ ] Docusaurus worktree and branch created. +- [ ] Implementation checklist complete. +- [ ] Documentation build or preview complete. +- [ ] Manual checks complete or explicitly deferred. +- [ ] Foundry PR opened with the planned title. +- [ ] Docusaurus PR opened with the planned title. +- [ ] Foundry and Docusaurus PRs merged. + +- [ ] Add user documentation for hardware hash upload from WinPE. +- [ ] Update the Docusaurus documentation if the implemented behavior affects user-facing OSD, Deploy, WinPE requirements, setup, troubleshooting, permissions, or release notes. +- [ ] Use the Docusaurus repository at `E:\Github\Foundry Project\foundry-osd.github.io`. +- [ ] Create a dedicated Docusaurus worktree before editing docs. +- [ ] Create the Docusaurus branch `docs/autopilot-hash-upload` from the current documentation base branch. +- [ ] Use the Docusaurus PR title `docs(autopilot): document WinPE hardware hash upload`. +- [ ] Locate the Docusaurus documentation source inside that repository by searching for `docusaurus.config.*` or the docs package root before editing docs. +- [ ] Mark WinPE hash capture as best-effort and not the Microsoft-standard method. +- [ ] Document x64 and ARM64 scope. +- [ ] Document that Foundry copies `PCPKsp.dll` from the applied OS to `X:\Windows\System32` late in deployment. +- [ ] Document network requirements for Ethernet and Wi-Fi. +- [ ] Document retained troubleshooting artifacts under `\Windows\Temp\Foundry\Logs\AutopilotHash` and the operator cleanup process after diagnostics are no longer needed. +- [ ] Document unsupported or risky scenarios: + - self-deploying mode + - pre-provisioning + - missing TPM visibility +- [ ] Update screenshots after UI implementation. +- [ ] Update Docusaurus navigation/sidebar entries if a new Autopilot hardware hash page is added. + +Manual checks: +- [ ] Follow the docs on a clean test tenant. +- [ ] Follow the docs on a clean x64 test device. +- [ ] Follow the docs on a clean ARM64 test device. +- [ ] Confirm fallback to OOBE/full OS instructions are clear. +- [ ] Build or preview the Docusaurus docs with the command discovered from the docs package scripts if Docusaurus files are changed. diff --git a/docs/implementation/autopilot-hash-upload/06-validation-risk-docs.md b/docs/implementation/autopilot-hash-upload/06-validation-risk-docs.md new file mode 100644 index 00000000..c2faac9d --- /dev/null +++ b/docs/implementation/autopilot-hash-upload/06-validation-risk-docs.md @@ -0,0 +1,95 @@ +# Autopilot Hardware Hash Upload - Validation Risk And Documentation + +Part of the [Autopilot hardware hash upload implementation plan](../autopilot-hardware-hash-upload.md). + +Implementation agents must follow the repository instructions in [AGENTS.md](../../../AGENTS.md). Add XML documentation comments for public or non-obvious C# APIs when they clarify intent, contracts, or operational constraints. + +## Cross-Cutting Test Matrix +Automated commands for every implementation PR: + +```powershell +dotnet test .\src\Foundry.slnx --configuration Debug /p:Platform=x64 +``` + +CI must pass for: +- x64 +- ARM64 + +ARM64 CI is blocking because hash upload support is in scope for both architectures. + +Recommended focused test areas: +- `Foundry.Core.Tests` + - configuration serialization + - deploy configuration generation + - media preflight readiness + - WinPE asset provisioning + - OA3 XML and CSV helpers if implemented in Core +- `Foundry.Deploy.Tests` + - startup snapshot + - preparation view model + - launch validation + - deployment step branching + - hash capture parser/client abstractions if implemented in Deploy +- `Foundry.Telemetry.Tests` + - event property policy if new Autopilot mode telemetry properties are added + +Manual physical validation matrix: + +| Scenario | Required before release | +| --- | --- | +| x64 physical device with Ethernet, user-driven Autopilot | Yes | +| x64 physical device with Wi-Fi, user-driven Autopilot | Yes | +| x64 physical device with TPM 2.0 visible in WinPE | Yes | +| x64 device with existing Autopilot registration | Yes, expected duplicate/error behavior must be clear. | +| ARM64 physical device with Ethernet, user-driven Autopilot | Yes | +| ARM64 physical device with Wi-Fi, user-driven Autopilot | Yes | +| ARM64 physical device with TPM 2.0 visible in WinPE | Yes | +| ARM64 device with existing Autopilot registration | Yes, expected duplicate/error behavior must be clear. | +| JSON profile mode regression on x64 and ARM64 media | Yes | +| Hardware hash upload mode | Yes | +| Self-deploying/pre-provisioning | No, document as not recommended until separately validated. | + + +## Risk Register +| Risk | Impact | Mitigation | +| --- | --- | --- | +| OA3Tool produces empty or incomplete hash in WinPE | Import fails or device gets unreliable Autopilot behavior | Add `WinPE-SecureStartup`, retain OA3 diagnostics, document fallback to OOBE/full OS. | +| TPM not visible from WinPE | Self-deploying/pre-provisioning unreliable | Do not recommend those scenarios until separately validated. | +| Credentials embedded into media | Tenant compromise if generated media is lost | Encrypt certificate material with the media secret envelope, require explicit confirmation, document generated media as tenant-sensitive, and never write decrypted key material to disk. | +| Media secret key and encrypted secret are both present in the boot image | Encryption can be bypassed by anyone with full media access | Treat envelope encryption as plaintext avoidance/integrity protection, not a hard security boundary. | +| Certificate expires before deployment | Autopilot upload cannot authenticate | Show expired state in Foundry OSD, block hardware hash media generation until certificate regeneration, and let Foundry Deploy continue OS deployment while skipping Autopilot upload. | +| Operator loses one-time PFX/private key material | Existing app certificate cannot be used for new media | Require replacing the active certificate or choosing another valid active certificate by thumbprint, then rebuild boot media. | +| Broad Graph permissions copied from community script | Excessive tenant blast radius | Minimum permission matrix and no destructive final implementation flows. | +| Duplicate devices already exist | Import fails or operator confusion | Surface duplicate/import error clearly, retain sanitized diagnostics, and continue OS deployment without automatic cleanup. | +| Architecture-specific OA3Tool/support file mismatch | Runtime failure | Resolve ADK assets per selected WinPE architecture and validate both x64 and ARM64 media. | +| `PCPKsp.dll` missing from applied OS or copy fails | Autopilot hash upload cannot meet prerequisites | Copy from `\Windows\System32` after OS apply and fail the Autopilot workflow as a blocking prerequisite error if the copy/load operation fails. | +| Retained Autopilot diagnostics accumulate over time | Disk usage or stale troubleshooting data | Retain artifacts by default for debugging, keep the retained set allow-listed and sanitized, and document manual cleanup after diagnostics are no longer needed. | +| UI conflates JSON and hash mode | Invalid media or deployment launch | Explicit `ProvisioningMode` and readiness rules. | + + +## Documentation Deliverables +- Foundry app documentation: + - Autopilot provisioning modes. + - Hardware hash upload setup. + - Tenant app registration onboarding. + - Certificate creation, one-time PFX/private key material handling, expiration, repair, and replacement. + - Group tag default selection. + - Tenant permissions. + - Security warning for generated media. + - Troubleshooting. +- Foundry OSD docs site: + - New Autopilot hardware hash upload page. + - Requirements update documenting `WinPE-SecureStartup` as a default WinPE optional component. + - Product boundaries update explaining the workaround status. + - Manual test checklist. +- Docusaurus documentation: + - Use the Docusaurus repository at `E:\Github\Foundry Project\foundry-osd.github.io`. + - Create a dedicated Docusaurus worktree before editing docs. + - Create the Docusaurus branch `docs/autopilot-hash-upload` from the current documentation base branch. + - Use the Docusaurus PR title `docs(autopilot): document WinPE hardware hash upload`. + - Locate the docs source in that repository before editing by searching for `docusaurus.config.*` or the docs package root. + - Update pages, sidebars, navigation, screenshots, and release notes when user-facing behavior changes. + - Run the relevant Docusaurus build or preview command discovered from the docs package scripts if Docusaurus sources are changed. +- Release notes: + - Mark as x64 and ARM64 with Ethernet and Wi-Fi upload guidance. + - Mention unsupported or risky self-deploying/pre-provisioning status. diff --git a/src/Foundry.Core.Tests/Autopilot/AutopilotMediaSecretProtectorTests.cs b/src/Foundry.Core.Tests/Autopilot/AutopilotMediaSecretProtectorTests.cs new file mode 100644 index 00000000..6f868016 --- /dev/null +++ b/src/Foundry.Core.Tests/Autopilot/AutopilotMediaSecretProtectorTests.cs @@ -0,0 +1,60 @@ +using System.Security.Cryptography; +using System.Text; +using Foundry.Core.Services.Autopilot; + +namespace Foundry.Core.Tests.Autopilot; + +public sealed class AutopilotMediaSecretProtectorTests +{ + [Fact] + public void EncryptBytes_DecryptBytes_RoundTripsBinaryPayload() + { + byte[] key = MediaSecretEnvelopeProtector.GenerateMediaKey(); + byte[] payload = [0, 1, 2, 3, 255, 254, 128]; + + var envelope = MediaSecretEnvelopeProtector.EncryptBytes(payload, key); + byte[] decrypted = MediaSecretEnvelopeProtector.DecryptBytes(envelope, key); + + Assert.Equal(payload, decrypted); + Assert.NotEqual(Convert.ToBase64String(payload), envelope.Ciphertext); + } + + [Fact] + public void DecryptBytes_WhenEnvelopeIsTampered_ThrowsWithoutLeakingSecretMaterial() + { + byte[] key = MediaSecretEnvelopeProtector.GenerateMediaKey(); + const string secret = "PfxPassword-DoNotLeak"; + var envelope = MediaSecretEnvelopeProtector.EncryptString(secret, key) with + { + Ciphertext = "AAAA" + }; + + var exception = Assert.Throws(() => MediaSecretEnvelopeProtector.DecryptString(envelope, key)); + + Assert.DoesNotContain(secret, exception.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain(Convert.ToBase64String(Encoding.UTF8.GetBytes(secret)), exception.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void HasEncryptedSecrets_DetectsNestedEncryptedEnvelope() + { + byte[] key = MediaSecretEnvelopeProtector.GenerateMediaKey(); + var envelope = MediaSecretEnvelopeProtector.EncryptString("secret", key); + string json = $$""" + { + "autopilot": { + "certificatePasswordSecret": { + "kind": "{{envelope.Kind}}", + "algorithm": "{{envelope.Algorithm}}", + "keyId": "{{envelope.KeyId}}", + "nonce": "{{envelope.Nonce}}", + "tag": "{{envelope.Tag}}", + "ciphertext": "{{envelope.Ciphertext}}" + } + } + } + """; + + Assert.True(MediaSecretEnvelopeProtector.HasEncryptedSecrets(json)); + } +} diff --git a/src/Foundry.Core.Tests/Autopilot/AutopilotPfxCertificateValidatorTests.cs b/src/Foundry.Core.Tests/Autopilot/AutopilotPfxCertificateValidatorTests.cs new file mode 100644 index 00000000..4e4869f2 --- /dev/null +++ b/src/Foundry.Core.Tests/Autopilot/AutopilotPfxCertificateValidatorTests.cs @@ -0,0 +1,81 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Foundry.Core.Services.Autopilot; + +namespace Foundry.Core.Tests.Autopilot; + +public sealed class AutopilotPfxCertificateValidatorTests +{ + [Fact] + public void Validate_WhenPfxThumbprintMatches_ReturnsCertificateMetadata() + { + using X509Certificate2 certificate = CreateCertificate(); + byte[] pfxBytes = certificate.Export(X509ContentType.Pfx, "correct-password"); + + AutopilotPfxValidationResult result = AutopilotPfxCertificateValidator.Validate( + pfxBytes, + "correct-password", + certificate.Thumbprint); + + Assert.True(result.IsValid); + Assert.Equal(certificate.Thumbprint, result.Thumbprint); + Assert.Equal(certificate.NotAfter.ToUniversalTime(), result.ExpiresOnUtc?.UtcDateTime); + } + + [Fact] + public void Validate_WhenExpectedThumbprintIsNotProvided_ReturnsCertificateMetadata() + { + using X509Certificate2 certificate = CreateCertificate(); + byte[] pfxBytes = certificate.Export(X509ContentType.Pfx, "correct-password"); + + AutopilotPfxValidationResult result = AutopilotPfxCertificateValidator.Validate( + pfxBytes, + "correct-password"); + + Assert.True(result.IsValid); + Assert.Equal(certificate.Thumbprint, result.Thumbprint); + Assert.Equal(certificate.NotAfter.ToUniversalTime(), result.ExpiresOnUtc?.UtcDateTime); + } + + [Fact] + public void Validate_WhenPasswordIsEmpty_ReturnsPasswordRequired() + { + using X509Certificate2 certificate = CreateCertificate(); + byte[] pfxBytes = certificate.Export(X509ContentType.Pfx, string.Empty); + + AutopilotPfxValidationResult result = AutopilotPfxCertificateValidator.Validate( + pfxBytes, + string.Empty, + certificate.Thumbprint); + + Assert.False(result.IsValid); + Assert.Equal(AutopilotPfxValidationCode.PasswordRequired, result.Code); + } + + [Fact] + public void Validate_WhenThumbprintDoesNotMatch_ReturnsThumbprintMismatch() + { + using X509Certificate2 certificate = CreateCertificate(); + byte[] pfxBytes = certificate.Export(X509ContentType.Pfx, "correct-password"); + + AutopilotPfxValidationResult result = AutopilotPfxCertificateValidator.Validate( + pfxBytes, + "correct-password", + "ABCDEF123456"); + + Assert.False(result.IsValid); + Assert.Equal(AutopilotPfxValidationCode.ThumbprintMismatch, result.Code); + } + + private static X509Certificate2 CreateCertificate() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=Foundry OSD Autopilot Registration", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMonths(12)); + } +} diff --git a/src/Foundry.Core.Tests/Autopilot/AutopilotTenantOnboardingEvaluatorTests.cs b/src/Foundry.Core.Tests/Autopilot/AutopilotTenantOnboardingEvaluatorTests.cs new file mode 100644 index 00000000..957aac73 --- /dev/null +++ b/src/Foundry.Core.Tests/Autopilot/AutopilotTenantOnboardingEvaluatorTests.cs @@ -0,0 +1,214 @@ +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Autopilot; + +namespace Foundry.Core.Tests.Autopilot; + +public sealed class AutopilotTenantOnboardingEvaluatorTests +{ + private static readonly DateTimeOffset Now = new(2026, 5, 20, 0, 0, 0, TimeSpan.Zero); + + [Fact] + public void Evaluate_UsesPersistedApplicationObjectIdBeforeDisplayName() + { + AutopilotTenantOnboardingEvaluation result = AutopilotTenantOnboardingEvaluator.Evaluate(CreateSnapshot( + applications: + [ + CreateApplication("persisted-object-id", "client-id", "Different display name"), + CreateApplication("same-name-object-id", "other-client-id", AutopilotHardwareHashUploadSettings.ManagedAppRegistrationDisplayName) + ])); + + Assert.Equal(AutopilotTenantOnboardingStatus.Ready, result.Status); + Assert.Equal("persisted-object-id", result.ApplicationObjectId); + Assert.Equal("client-id", result.ClientId); + } + + [Fact] + public void Evaluate_WhenSameDisplayNameExistsWithoutPersistedObjectId_ReturnsAdoptionRequired() + { + AutopilotTenantOnboardingEvaluation result = AutopilotTenantOnboardingEvaluator.Evaluate(CreateSnapshot( + persistedApplicationObjectId: null, + applications: + [ + CreateApplication("existing-object-id", "client-id", AutopilotHardwareHashUploadSettings.ManagedAppRegistrationDisplayName) + ])); + + Assert.Equal(AutopilotTenantOnboardingStatus.AdoptionRequired, result.Status); + } + + [Fact] + public void Evaluate_WhenRequiredPermissionMissing_ReturnsPermissionMissing() + { + AutopilotTenantOnboardingEvaluation result = AutopilotTenantOnboardingEvaluator.Evaluate(CreateSnapshot( + applications: + [ + CreateApplication( + "persisted-object-id", + "client-id", + AutopilotHardwareHashUploadSettings.ManagedAppRegistrationDisplayName, + new HashSet(StringComparer.OrdinalIgnoreCase) { "DeviceManagementServiceConfig.Read.All" }) + ])); + + Assert.Equal(AutopilotTenantOnboardingStatus.PermissionMissing, result.Status); + } + + [Fact] + public void Evaluate_WhenAdminConsentMissing_ReturnsConsentMissing() + { + AutopilotTenantOnboardingEvaluation result = AutopilotTenantOnboardingEvaluator.Evaluate(CreateSnapshot( + servicePrincipal: CreateServicePrincipal(consentedPermissionValues: new HashSet(StringComparer.OrdinalIgnoreCase)))); + + Assert.Equal(AutopilotTenantOnboardingStatus.ConsentMissing, result.Status); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Evaluate_WhenServicePrincipalIsMissingOrDisabled_ReturnsServicePrincipalUnavailable(bool servicePrincipalExists) + { + AutopilotTenantOnboardingEvaluation result = AutopilotTenantOnboardingEvaluator.Evaluate(CreateSnapshot( + servicePrincipal: servicePrincipalExists ? CreateServicePrincipal(isEnabled: false) : null, + useDefaultServicePrincipal: servicePrincipalExists)); + + Assert.Equal(AutopilotTenantOnboardingStatus.ServicePrincipalUnavailable, result.Status); + } + + [Fact] + public void Evaluate_WhenActiveCertificateIsMissingFromGraphAndAnotherValidCredentialExists_ReturnsReady() + { + AutopilotTenantOnboardingEvaluation result = AutopilotTenantOnboardingEvaluator.Evaluate(CreateSnapshot( + keyCredentials: + [ + CreateKeyCredential("other-key-id", "OTHER", Now.AddMonths(12)) + ])); + + Assert.Equal(AutopilotTenantOnboardingStatus.Ready, result.Status); + Assert.Equal("other-key-id", result.ActiveCertificateCredential?.KeyId); + } + + [Fact] + public void Evaluate_WhenActiveCertificateIsMissingFromGraphAndNoValidCredentialExists_ReturnsActiveCertificateNotFound() + { + AutopilotTenantOnboardingEvaluation result = AutopilotTenantOnboardingEvaluator.Evaluate(CreateSnapshot( + keyCredentials: [])); + + Assert.Equal(AutopilotTenantOnboardingStatus.ActiveCertificateNotFound, result.Status); + } + + [Fact] + public void Evaluate_WhenFoundryCredentialsExistWithoutPersistedActiveCertificate_ReturnsReady() + { + AutopilotTenantOnboardingEvaluation result = AutopilotTenantOnboardingEvaluator.Evaluate(CreateSnapshot( + activeCertificate: null, + useDefaultActiveCertificate: false, + keyCredentials: + [ + CreateKeyCredential("key-1", "AAA", Now.AddMonths(12)), + CreateKeyCredential("key-2", "BBB", Now.AddMonths(12)) + ])); + + Assert.Equal(AutopilotTenantOnboardingStatus.Ready, result.Status); + Assert.Equal("key-1", result.ActiveCertificateCredential?.KeyId); + } + + [Fact] + public void Evaluate_WhenPersistedActiveCertificateIsExpiredButAnotherValidCredentialExists_ReturnsReady() + { + AutopilotTenantOnboardingEvaluation result = AutopilotTenantOnboardingEvaluator.Evaluate(CreateSnapshot( + keyCredentials: + [ + CreateKeyCredential("active-key-id", "ABCDEF123456", Now.AddDays(-1)), + CreateKeyCredential("other-key-id", "OTHER", Now.AddMonths(12)) + ])); + + Assert.Equal(AutopilotTenantOnboardingStatus.Ready, result.Status); + Assert.Equal("other-key-id", result.ActiveCertificateCredential?.KeyId); + } + + [Fact] + public void AddCertificateCredential_PreservesExistingCredentials() + { + AutopilotGraphKeyCredential existing = CreateKeyCredential("existing-key-id", "AAA", Now.AddMonths(12)); + AutopilotGraphKeyCredential added = CreateKeyCredential("new-key-id", "BBB", Now.AddMonths(24)); + + IReadOnlyList result = + AutopilotAppRegistrationCertificateCollection.AddCertificate([existing], added); + + Assert.Equal(["existing-key-id", "new-key-id"], result.Select(credential => credential.KeyId)); + } + + [Fact] + public void RetireActiveCertificate_RemovesOnlyPersistedActiveKeyId() + { + AutopilotGraphKeyCredential active = CreateKeyCredential("active-key-id", "AAA", Now.AddMonths(12)); + AutopilotGraphKeyCredential other = CreateKeyCredential("other-key-id", "BBB", Now.AddMonths(24)); + + IReadOnlyList result = + AutopilotAppRegistrationCertificateCollection.RetireActiveCertificate([active, other], "active-key-id"); + + Assert.Single(result); + Assert.Equal("other-key-id", result[0].KeyId); + } + + private static AutopilotTenantOnboardingSnapshot CreateSnapshot( + string? persistedApplicationObjectId = "persisted-object-id", + IReadOnlyList? applications = null, + AutopilotGraphServicePrincipal? servicePrincipal = null, + AutopilotCertificateMetadata? activeCertificate = null, + IReadOnlyList? keyCredentials = null, + bool useDefaultServicePrincipal = true, + bool useDefaultActiveCertificate = true) + { + return new AutopilotTenantOnboardingSnapshot + { + TenantId = "tenant-id", + PersistedApplicationObjectId = persistedApplicationObjectId, + ManagedAppDisplayName = AutopilotHardwareHashUploadSettings.ManagedAppRegistrationDisplayName, + Applications = applications ?? [CreateApplication("persisted-object-id", "client-id", AutopilotHardwareHashUploadSettings.ManagedAppRegistrationDisplayName)], + ServicePrincipal = servicePrincipal ?? (useDefaultServicePrincipal ? CreateServicePrincipal() : null), + ActiveCertificate = activeCertificate ?? (useDefaultActiveCertificate ? new AutopilotCertificateMetadata + { + KeyId = "active-key-id", + Thumbprint = "ABCDEF123456", + ExpiresOnUtc = Now.AddMonths(12) + } : null), + KeyCredentials = keyCredentials ?? [CreateKeyCredential("active-key-id", "ABCDEF123456", Now.AddMonths(12))], + CurrentTimeUtc = Now + }; + } + + private static AutopilotGraphApplication CreateApplication( + string objectId, + string clientId, + string displayName, + IReadOnlySet? requiredPermissionValues = null) + { + return new AutopilotGraphApplication( + objectId, + clientId, + displayName, + requiredPermissionValues ?? AutopilotGraphPermissionCatalog.RequiredWinPeApplicationPermissionValues); + } + + private static AutopilotGraphServicePrincipal CreateServicePrincipal( + bool isEnabled = true, + IReadOnlySet? consentedPermissionValues = null) + { + return new AutopilotGraphServicePrincipal( + "service-principal-object-id", + isEnabled, + consentedPermissionValues ?? AutopilotGraphPermissionCatalog.RequiredWinPeApplicationPermissionValues); + } + + private static AutopilotGraphKeyCredential CreateKeyCredential( + string keyId, + string thumbprint, + DateTimeOffset expiresOnUtc) + { + return new AutopilotGraphKeyCredential( + keyId, + AutopilotHardwareHashUploadSettings.ManagedAppRegistrationDisplayName, + thumbprint, + Now.AddDays(-1), + expiresOnUtc); + } +} diff --git a/src/Foundry.Core.Tests/Configuration/AutopilotConfigurationValidatorTests.cs b/src/Foundry.Core.Tests/Configuration/AutopilotConfigurationValidatorTests.cs new file mode 100644 index 00000000..dffe5e1c --- /dev/null +++ b/src/Foundry.Core.Tests/Configuration/AutopilotConfigurationValidatorTests.cs @@ -0,0 +1,338 @@ +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Configuration; + +namespace Foundry.Core.Tests.Configuration; + +public sealed class AutopilotConfigurationValidatorTests +{ + private static readonly DateTimeOffset EvaluationTimeUtc = new(2026, 5, 15, 0, 0, 0, TimeSpan.Zero); + + [Fact] + public void IsReady_WhenAutopilotIsDisabled_ReturnsTrueWithoutProfileOrHashSettings() + { + var settings = new AutopilotSettings + { + IsEnabled = false, + ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload + }; + + Assert.True(AutopilotConfigurationValidator.IsReady(settings, EvaluationTimeUtc)); + } + + [Fact] + public void IsReady_WhenJsonProfileModeHasSelectedProfile_ReturnsTrue() + { + var settings = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.JsonProfile, + DefaultProfileId = "profile-a", + Profiles = [CreateProfile("profile-a")] + }; + + Assert.True(AutopilotConfigurationValidator.IsReady(settings, EvaluationTimeUtc)); + } + + [Fact] + public void IsReady_WhenJsonProfileModeHasNoSelectedProfile_ReturnsFalse() + { + var settings = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.JsonProfile + }; + + Assert.False(AutopilotConfigurationValidator.IsReady(settings, EvaluationTimeUtc)); + } + + [Fact] + public void IsReady_WhenHardwareHashModeHasCompleteUnexpiredSettings_ReturnsTrue() + { + var settings = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload, + HardwareHashUpload = CreateCompleteHardwareHashSettings(EvaluationTimeUtc.AddMonths(6)) + }; + + Assert.True(AutopilotConfigurationValidator.IsReady(settings, EvaluationTimeUtc)); + } + + [Theory] + [InlineData("", "application-object-id", "client-id", "service-principal-object-id", "certificate-key-id", "ABCDEF123456")] + [InlineData("tenant-id", "", "client-id", "service-principal-object-id", "certificate-key-id", "ABCDEF123456")] + [InlineData("tenant-id", "application-object-id", "", "service-principal-object-id", "certificate-key-id", "ABCDEF123456")] + [InlineData("tenant-id", "application-object-id", "client-id", "", "certificate-key-id", "ABCDEF123456")] + [InlineData("tenant-id", "application-object-id", "client-id", "service-principal-object-id", "", "ABCDEF123456")] + [InlineData("tenant-id", "application-object-id", "client-id", "service-principal-object-id", "certificate-key-id", "")] + public void IsReady_WhenHardwareHashModeHasMissingRequiredMetadata_ReturnsFalse( + string tenantId, + string applicationObjectId, + string clientId, + string servicePrincipalObjectId, + string keyId, + string thumbprint) + { + var settings = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload, + HardwareHashUpload = CreateCompleteHardwareHashSettings( + EvaluationTimeUtc.AddMonths(6), + tenantId, + applicationObjectId, + clientId, + servicePrincipalObjectId, + keyId, + thumbprint) + }; + + Assert.False(AutopilotConfigurationValidator.IsReady(settings, EvaluationTimeUtc)); + } + + [Fact] + public void IsReady_WhenHardwareHashCertificateIsExpired_ReturnsFalse() + { + var settings = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload, + HardwareHashUpload = CreateCompleteHardwareHashSettings(EvaluationTimeUtc.AddTicks(-1)) + }; + + Assert.False(AutopilotConfigurationValidator.IsReady(settings, EvaluationTimeUtc)); + } + + [Fact] + public void Evaluate_WhenHardwareHashCertificateIsExpired_ReturnsCertificateExpired() + { + var settings = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload, + HardwareHashUpload = CreateCompleteHardwareHashSettings(EvaluationTimeUtc.AddTicks(-1)) with + { + BootMediaCertificate = new AutopilotBootMediaCertificateSettings + { + PfxPath = @"E:\Secrets\foundry-osd-autopilot-registration.pfx", + PfxPassword = "correct-password", + ValidatedThumbprint = "ABCDEF123456", + ValidatedExpiresOnUtc = EvaluationTimeUtc.AddMonths(6) + } + } + }; + + AutopilotConfigurationValidationResult result = AutopilotConfigurationValidator.Evaluate(settings, EvaluationTimeUtc); + + Assert.False(result.IsReady); + Assert.Equal(AutopilotConfigurationValidationCode.HardwareHashActiveCertificateExpired, result.Code); + } + + [Fact] + public void Evaluate_WhenHardwareHashBootMediaCertificateIsExpired_ReturnsBootMediaCertificateExpired() + { + var settings = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload, + HardwareHashUpload = CreateCompleteHardwareHashSettings(EvaluationTimeUtc.AddMonths(6)) with + { + BootMediaCertificate = new AutopilotBootMediaCertificateSettings + { + PfxPath = @"E:\Secrets\foundry-osd-autopilot-registration.pfx", + PfxPassword = "correct-password", + ValidatedThumbprint = "ABCDEF123456", + ValidatedExpiresOnUtc = EvaluationTimeUtc.AddTicks(-1) + } + } + }; + + AutopilotConfigurationValidationResult result = AutopilotConfigurationValidator.Evaluate(settings, EvaluationTimeUtc); + + Assert.False(result.IsReady); + Assert.Equal(AutopilotConfigurationValidationCode.HardwareHashBootMediaCertificateExpired, result.Code); + } + + [Fact] + public void IsReady_WhenHardwareHashBootMediaCertificateIsMissing_ReturnsFalse() + { + var settings = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload, + HardwareHashUpload = CreateCompleteHardwareHashSettings(EvaluationTimeUtc.AddMonths(6)) with + { + BootMediaCertificate = new AutopilotBootMediaCertificateSettings() + } + }; + + Assert.False(AutopilotConfigurationValidator.IsReady(settings, EvaluationTimeUtc)); + } + + [Fact] + public void Evaluate_WhenHardwareHashBootMediaCertificateIsMissing_ReturnsPfxMissing() + { + var settings = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload, + HardwareHashUpload = CreateCompleteHardwareHashSettings(EvaluationTimeUtc.AddMonths(6)) with + { + BootMediaCertificate = new AutopilotBootMediaCertificateSettings() + } + }; + + AutopilotConfigurationValidationResult result = AutopilotConfigurationValidator.Evaluate(settings, EvaluationTimeUtc); + + Assert.False(result.IsReady); + Assert.Equal(AutopilotConfigurationValidationCode.HardwareHashBootMediaPfxMissing, result.Code); + } + + [Fact] + public void Evaluate_WhenHardwareHashHasNoActiveCertificateOrPfx_ReturnsPfxMissing() + { + var settings = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload, + HardwareHashUpload = CreateCompleteHardwareHashSettings(EvaluationTimeUtc.AddMonths(6)) with + { + ActiveCertificate = null, + BootMediaCertificate = new AutopilotBootMediaCertificateSettings() + } + }; + + AutopilotConfigurationValidationResult result = AutopilotConfigurationValidator.Evaluate(settings, EvaluationTimeUtc); + + Assert.False(result.IsReady); + Assert.Equal(AutopilotConfigurationValidationCode.HardwareHashBootMediaPfxMissing, result.Code); + } + + [Fact] + public void Evaluate_WhenHardwareHashBootMediaThumbprintDoesNotMatch_ReturnsThumbprintMismatch() + { + var settings = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload, + HardwareHashUpload = CreateCompleteHardwareHashSettings(EvaluationTimeUtc.AddMonths(6)) with + { + BootMediaCertificate = new AutopilotBootMediaCertificateSettings + { + PfxPath = @"E:\Secrets\foundry-osd-autopilot-registration.pfx", + PfxPassword = "correct-password", + ValidatedThumbprint = "9876543210", + ValidatedExpiresOnUtc = EvaluationTimeUtc.AddMonths(6) + } + } + }; + + AutopilotConfigurationValidationResult result = AutopilotConfigurationValidator.Evaluate(settings, EvaluationTimeUtc); + + Assert.False(result.IsReady); + Assert.Equal(AutopilotConfigurationValidationCode.HardwareHashBootMediaCertificateThumbprintMismatch, result.Code); + } + + [Fact] + public void IsReady_WhenProvisioningModeIsUnsupported_ReturnsFalse() + { + var settings = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = (AutopilotProvisioningMode)999 + }; + + Assert.False(AutopilotConfigurationValidator.IsReady(settings, EvaluationTimeUtc)); + } + + [Fact] + public void IsReady_WhenHardwareHashUploadSettingsAreNull_ReturnsFalse() + { + var settings = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload, + HardwareHashUpload = null! + }; + + Assert.False(AutopilotConfigurationValidator.IsReady(settings, EvaluationTimeUtc)); + } + + [Fact] + public void IsReady_WhenHardwareHashTenantSettingsAreNull_ReturnsFalse() + { + var settings = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload, + HardwareHashUpload = CreateCompleteHardwareHashSettings(EvaluationTimeUtc.AddMonths(6)) with + { + Tenant = null! + } + }; + + Assert.False(AutopilotConfigurationValidator.IsReady(settings, EvaluationTimeUtc)); + } + + [Fact] + public void ThrowIfNotReady_WhenProvisioningModeIsUnsupported_ThrowsInvalidOperationException() + { + var settings = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = (AutopilotProvisioningMode)999 + }; + + InvalidOperationException exception = Assert.Throws( + () => AutopilotConfigurationValidator.ThrowIfNotReady(settings, EvaluationTimeUtc)); + Assert.Contains("unsupported", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + private static AutopilotProfileSettings CreateProfile(string id) + { + return new AutopilotProfileSettings + { + Id = id, + DisplayName = id, + FolderName = id, + Source = "import", + ImportedAtUtc = EvaluationTimeUtc, + JsonContent = "{}" + }; + } + + private static AutopilotHardwareHashUploadSettings CreateCompleteHardwareHashSettings( + DateTimeOffset expiration, + string tenantId = "tenant-id", + string applicationObjectId = "application-object-id", + string clientId = "client-id", + string servicePrincipalObjectId = "service-principal-object-id", + string keyId = "certificate-key-id", + string thumbprint = "ABCDEF123456") + { + return new AutopilotHardwareHashUploadSettings + { + Tenant = new AutopilotTenantRegistrationSettings + { + TenantId = tenantId, + ApplicationObjectId = applicationObjectId, + ClientId = clientId, + ServicePrincipalObjectId = servicePrincipalObjectId + }, + ActiveCertificate = new AutopilotCertificateMetadata + { + KeyId = keyId, + Thumbprint = thumbprint, + DisplayName = "Foundry OSD Autopilot Registration", + ExpiresOnUtc = expiration + }, + BootMediaCertificate = new AutopilotBootMediaCertificateSettings + { + PfxPath = @"E:\Secrets\foundry-osd-autopilot-registration.pfx", + PfxPassword = "correct-password", + ValidatedThumbprint = thumbprint, + ValidatedExpiresOnUtc = expiration + } + }; + } +} diff --git a/src/Foundry.Core.Tests/Configuration/ConnectConfigurationGeneratorTests.cs b/src/Foundry.Core.Tests/Configuration/ConnectConfigurationGeneratorTests.cs index 41b1e6ad..aff9af9e 100644 --- a/src/Foundry.Core.Tests/Configuration/ConnectConfigurationGeneratorTests.cs +++ b/src/Foundry.Core.Tests/Configuration/ConnectConfigurationGeneratorTests.cs @@ -25,6 +25,8 @@ public void CreateProvisioningBundle_WhenNetworkIsDefault_WritesCompleteEffectiv Assert.True(root.TryGetProperty("wifi", out _)); Assert.True(root.TryGetProperty("internetProbe", out JsonElement internetProbe)); Assert.False(capabilities.GetProperty("wifiProvisioned").GetBoolean()); + Assert.Equal(JsonValueKind.Number, root.GetProperty("dot1x").GetProperty("authenticationMode").ValueKind); + Assert.Equal(JsonValueKind.Number, root.GetProperty("wifi").GetProperty("enterpriseAuthenticationMode").ValueKind); Assert.Equal(5, internetProbe.GetProperty("timeoutSeconds").GetInt32()); Assert.NotEmpty(internetProbe.GetProperty("probeUris").EnumerateArray()); Assert.Empty(bundle.AssetFiles); diff --git a/src/Foundry.Core.Tests/Configuration/DeployConfigurationGeneratorTests.cs b/src/Foundry.Core.Tests/Configuration/DeployConfigurationGeneratorTests.cs index c8ee2a30..f91903cd 100644 --- a/src/Foundry.Core.Tests/Configuration/DeployConfigurationGeneratorTests.cs +++ b/src/Foundry.Core.Tests/Configuration/DeployConfigurationGeneratorTests.cs @@ -333,6 +333,106 @@ public void Generate_ResolvesDefaultAutopilotProfileFolder() Assert.Equal("profile-b-folder", result.Autopilot.DefaultProfileFolderName); } + [Fact] + public void Generate_WhenJsonProfileModeHasNoSelectedProfile_ThrowsInvalidOperationException() + { + var generator = new DeployConfigurationGenerator(); + var document = new FoundryConfigurationDocument + { + Autopilot = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.JsonProfile + } + }; + + InvalidOperationException exception = Assert.Throws(() => generator.Generate(document)); + Assert.Contains("JSON", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Generate_WhenHardwareHashModeIsEnabled_DoesNotRequireSelectedProfile() + { + var generator = new DeployConfigurationGenerator(); + DateTimeOffset expiration = DateTimeOffset.UtcNow.AddMonths(6); + var document = new FoundryConfigurationDocument + { + Autopilot = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload, + HardwareHashUpload = CreateCompleteHardwareHashSettings(expiration) + } + }; + + var result = generator.Generate(document); + + Assert.True(result.Autopilot.IsEnabled); + Assert.Equal(AutopilotProvisioningMode.HardwareHashUpload, result.Autopilot.ProvisioningMode); + Assert.Null(result.Autopilot.DefaultProfileFolderName); + Assert.Equal("tenant-id", result.Autopilot.HardwareHashUpload.TenantId); + Assert.Equal("client-id", result.Autopilot.HardwareHashUpload.ClientId); + Assert.Equal("certificate-key-id", result.Autopilot.HardwareHashUpload.ActiveCertificateKeyId); + Assert.Equal("ABCDEF123456", result.Autopilot.HardwareHashUpload.ActiveCertificateThumbprint); + Assert.Equal(expiration, result.Autopilot.HardwareHashUpload.ActiveCertificateExpiresOnUtc); + Assert.Equal("Sales", result.Autopilot.HardwareHashUpload.DefaultGroupTag); + } + + [Fact] + public void Generate_WhenHardwareHashCertificateIsExpired_ThrowsInvalidOperationException() + { + var generator = new DeployConfigurationGenerator(); + var document = new FoundryConfigurationDocument + { + Autopilot = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload, + HardwareHashUpload = CreateCompleteHardwareHashSettings(DateTimeOffset.UtcNow.AddDays(-1)) + } + }; + + InvalidOperationException exception = Assert.Throws(() => generator.Generate(document)); + Assert.Contains("certificate", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Generate_WhenAutopilotIsDisabledWithNullHardwareHashSettings_DoesNotThrow() + { + var generator = new DeployConfigurationGenerator(); + var document = new FoundryConfigurationDocument + { + Autopilot = new AutopilotSettings + { + IsEnabled = false, + HardwareHashUpload = null! + } + }; + + var result = generator.Generate(document); + + Assert.False(result.Autopilot.IsEnabled); + Assert.Equal(AutopilotProvisioningMode.JsonProfile, result.Autopilot.ProvisioningMode); + Assert.NotNull(result.Autopilot.HardwareHashUpload); + } + + [Fact] + public void Generate_WhenProvisioningModeIsUnsupported_ThrowsInvalidOperationException() + { + var generator = new DeployConfigurationGenerator(); + var document = new FoundryConfigurationDocument + { + Autopilot = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = (AutopilotProvisioningMode)999 + } + }; + + InvalidOperationException exception = Assert.Throws(() => generator.Generate(document)); + Assert.Contains("unsupported", exception.Message, StringComparison.OrdinalIgnoreCase); + } + private static AutopilotProfileSettings CreateProfile(string id, string folderName) { return new AutopilotProfileSettings @@ -345,4 +445,34 @@ private static AutopilotProfileSettings CreateProfile(string id, string folderNa JsonContent = "{}" }; } + + private static AutopilotHardwareHashUploadSettings CreateCompleteHardwareHashSettings(DateTimeOffset expiration) + { + return new AutopilotHardwareHashUploadSettings + { + Tenant = new AutopilotTenantRegistrationSettings + { + TenantId = "tenant-id", + ApplicationObjectId = "application-object-id", + ClientId = "client-id", + ServicePrincipalObjectId = "service-principal-object-id" + }, + ActiveCertificate = new AutopilotCertificateMetadata + { + KeyId = "certificate-key-id", + Thumbprint = "ABCDEF123456", + DisplayName = "Foundry OSD Autopilot Registration", + ExpiresOnUtc = expiration + }, + BootMediaCertificate = new AutopilotBootMediaCertificateSettings + { + PfxPath = @"E:\Secrets\foundry-osd-autopilot-registration.pfx", + PfxPassword = "correct-password", + ValidatedThumbprint = "ABCDEF123456", + ValidatedExpiresOnUtc = expiration + }, + KnownGroupTags = ["Sales", "Engineering"], + DefaultGroupTag = "Sales" + }; + } } diff --git a/src/Foundry.Core.Tests/Configuration/FoundryConfigurationServiceTests.cs b/src/Foundry.Core.Tests/Configuration/FoundryConfigurationServiceTests.cs index 5f3e9ec4..ca8f77b2 100644 --- a/src/Foundry.Core.Tests/Configuration/FoundryConfigurationServiceTests.cs +++ b/src/Foundry.Core.Tests/Configuration/FoundryConfigurationServiceTests.cs @@ -118,6 +118,71 @@ public void Deserialize_WhenJsonIsNullLiteral_ReturnsDefaultDocument() Assert.False(document.Autopilot.IsEnabled); } + [Fact] + public void Deserialize_WhenAutopilotProvisioningModeIsMissing_DefaultsToJsonProfile() + { + var service = new FoundryConfigurationService(); + + FoundryConfigurationDocument document = service.Deserialize(""" + { + "schemaVersion": 7, + "autopilot": { + "isEnabled": true + } + } + """); + + Assert.Equal(AutopilotProvisioningMode.JsonProfile, document.Autopilot.ProvisioningMode); + } + + [Fact] + public void Serialize_WhenHardwareHashSettingsArePersisted_DoesNotWritePrivateMaterial() + { + var service = new FoundryConfigurationService(); + var document = new FoundryConfigurationDocument + { + Autopilot = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload, + HardwareHashUpload = new AutopilotHardwareHashUploadSettings + { + Tenant = new AutopilotTenantRegistrationSettings + { + TenantId = "tenant-id", + ApplicationObjectId = "application-object-id", + ClientId = "client-id", + ServicePrincipalObjectId = "service-principal-object-id" + }, + ActiveCertificate = new AutopilotCertificateMetadata + { + KeyId = "certificate-key-id", + Thumbprint = "ABCDEF123456", + DisplayName = "Foundry OSD Autopilot Registration", + ExpiresOnUtc = DateTimeOffset.UtcNow.AddMonths(6) + }, + BootMediaCertificate = new AutopilotBootMediaCertificateSettings + { + PfxPath = @"E:\Secrets\foundry-osd-autopilot-registration.pfx", + PfxPassword = "PfxPassword-DoNotLeak", + ValidatedThumbprint = "ABCDEF123456", + ValidatedExpiresOnUtc = DateTimeOffset.UtcNow.AddMonths(6) + } + } + } + }; + + string json = service.Serialize(document); + + Assert.Contains("\"provisioningMode\": \"hardwareHashUpload\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("pfx", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("password", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("privateKey", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("accessToken", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("PfxPassword-DoNotLeak", json, StringComparison.Ordinal); + Assert.DoesNotContain(@"E:\Secrets", json, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void ApplyLegacyGeneralSettings_WhenAuthoringConfigHasNoGeneralSection_CopiesMediaDefaults() { diff --git a/src/Foundry.Core.Tests/WinPe/WinPeMountedImageAssetProvisioningServiceTests.cs b/src/Foundry.Core.Tests/WinPe/WinPeMountedImageAssetProvisioningServiceTests.cs index c4a9d2c7..5bab4357 100644 --- a/src/Foundry.Core.Tests/WinPe/WinPeMountedImageAssetProvisioningServiceTests.cs +++ b/src/Foundry.Core.Tests/WinPe/WinPeMountedImageAssetProvisioningServiceTests.cs @@ -244,6 +244,33 @@ public async Task ProvisionAsync_WhenMediaSecretKeyIsProvided_WritesSecretKeyUnd Assert.Equal(secretKey, await File.ReadAllBytesAsync(Path.Combine(image.MountedImagePath, "Foundry", "Config", "Secrets", "media-secrets.key"))); } + [Fact] + public async Task ProvisionAsync_WhenDeployConfigurationHasEncryptedSecret_WritesSecretKeyUnderConfigSecrets() + { + using TempMountedImage image = TempMountedImage.Create(); + string curlSourcePath = Path.Combine(image.RootPath, "curl.exe"); + File.WriteAllText(curlSourcePath, "curl"); + byte[] secretKey = Enumerable.Range(0, 32).Select(static value => (byte)value).ToArray(); + + var service = new WinPeMountedImageAssetProvisioningService(); + + WinPeResult result = await service.ProvisionAsync( + new WinPeMountedImageAssetProvisioningOptions + { + MountedImagePath = image.MountedImagePath, + Architecture = WinPeArchitecture.X64, + BootstrapScriptContent = "bootstrap", + CurlExecutableSourcePath = curlSourcePath, + IanaWindowsTimeZoneMapJson = "{}", + DeployConfigurationJson = CreateDeployConfigurationWithEncryptedSecret(), + MediaSecretsKey = secretKey + }, + CancellationToken.None); + + Assert.True(result.IsSuccess, result.Error?.Details); + Assert.Equal(secretKey, await File.ReadAllBytesAsync(Path.Combine(image.MountedImagePath, "Foundry", "Config", "Secrets", "media-secrets.key"))); + } + [Fact] public async Task ProvisionAsync_WhenMediaSecretKeyHasNoEncryptedSecret_ReturnsFailure() { @@ -412,4 +439,25 @@ private static string CreateConnectConfigurationWithEncryptedSecret() } """; } + + private static string CreateDeployConfigurationWithEncryptedSecret() + { + return """ + { + "schemaVersion": 1, + "autopilot": { + "hardwareHashUpload": { + "pfxSecret": { + "kind": "encrypted", + "algorithm": "aes-gcm-v1", + "keyId": "media", + "nonce": "AAAAAAAAAAAAAAAA", + "tag": "AAAAAAAAAAAAAAAAAAAAAA", + "ciphertext": "AAAAAAAA" + } + } + } + } + """; + } } diff --git a/src/Foundry.Core/Models/Configuration/AutopilotCertificateMetadata.cs b/src/Foundry.Core/Models/Configuration/AutopilotCertificateMetadata.cs new file mode 100644 index 00000000..1cd8c588 --- /dev/null +++ b/src/Foundry.Core/Models/Configuration/AutopilotCertificateMetadata.cs @@ -0,0 +1,27 @@ +namespace Foundry.Core.Models.Configuration; + +/// +/// Identifies the active certificate credential selected on the managed Autopilot app registration. +/// +public sealed record AutopilotCertificateMetadata +{ + /// + /// Gets the Microsoft Graph key credential identifier for the selected certificate. + /// + public string? KeyId { get; init; } + + /// + /// Gets the certificate thumbprint used to validate the operator-provided PFX during media generation. + /// + public string? Thumbprint { get; init; } + + /// + /// Gets the operator-facing certificate display name. + /// + public string? DisplayName { get; init; } + + /// + /// Gets the UTC expiration time after which media generation must reject hardware hash upload mode. + /// + public DateTimeOffset? ExpiresOnUtc { get; init; } +} diff --git a/src/Foundry.Core/Models/Configuration/AutopilotHardwareHashUploadSettings.cs b/src/Foundry.Core/Models/Configuration/AutopilotHardwareHashUploadSettings.cs new file mode 100644 index 00000000..52d19cc4 --- /dev/null +++ b/src/Foundry.Core/Models/Configuration/AutopilotHardwareHashUploadSettings.cs @@ -0,0 +1,66 @@ +namespace Foundry.Core.Models.Configuration; + +using System.Text.Json.Serialization; + +/// +/// Stores persistent metadata for WinPE Autopilot hardware hash upload without private key material. +/// +public sealed record AutopilotHardwareHashUploadSettings +{ + /// + /// Gets the display name used for the managed Foundry app registration in Microsoft Entra ID. + /// + public const string ManagedAppRegistrationDisplayName = "Foundry OSD Autopilot Registration"; + + /// + /// Gets the tenant and managed app registration identities. + /// + public AutopilotTenantRegistrationSettings Tenant { get; init; } = new(); + + /// + /// Gets the currently selected app registration certificate credential metadata. + /// + public AutopilotCertificateMetadata? ActiveCertificate { get; init; } + + /// + /// Gets the group tags discovered from Intune for default selection in Foundry.Deploy. + /// + public IReadOnlyList KnownGroupTags { get; init; } = []; + + /// + /// Gets the default Autopilot group tag selected during media generation. + /// + public string? DefaultGroupTag { get; init; } + + /// + /// Gets session-only PFX input used for the current boot media generation. + /// + [JsonIgnore] + public AutopilotBootMediaCertificateSettings BootMediaCertificate { get; init; } = new(); +} + +/// +/// Stores session-only Autopilot certificate material selected for boot media generation. +/// +public sealed record AutopilotBootMediaCertificateSettings +{ + /// + /// Gets the operator-selected password-protected PFX path. + /// + public string? PfxPath { get; init; } + + /// + /// Gets the PFX password for the current app session only. + /// + public string? PfxPassword { get; init; } + + /// + /// Gets the validated PFX leaf certificate thumbprint. + /// + public string? ValidatedThumbprint { get; init; } + + /// + /// Gets the validated PFX leaf certificate expiration. + /// + public DateTimeOffset? ValidatedExpiresOnUtc { get; init; } +} diff --git a/src/Foundry.Core/Models/Configuration/AutopilotProvisioningMode.cs b/src/Foundry.Core/Models/Configuration/AutopilotProvisioningMode.cs new file mode 100644 index 00000000..036414f1 --- /dev/null +++ b/src/Foundry.Core/Models/Configuration/AutopilotProvisioningMode.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Foundry.Core.Models.Configuration; + +/// +/// Defines how Foundry provisions Autopilot data during OS deployment. +/// +[JsonConverter(typeof(AutopilotProvisioningModeJsonConverter))] +public enum AutopilotProvisioningMode +{ + /// + /// Stages an offline Autopilot profile JSON file into the applied Windows image. + /// + JsonProfile, + + /// + /// Captures and uploads the device hardware hash from WinPE using the configured tenant app registration. + /// + HardwareHashUpload +} diff --git a/src/Foundry.Core/Models/Configuration/AutopilotProvisioningModeJsonConverter.cs b/src/Foundry.Core/Models/Configuration/AutopilotProvisioningModeJsonConverter.cs new file mode 100644 index 00000000..82d479bd --- /dev/null +++ b/src/Foundry.Core/Models/Configuration/AutopilotProvisioningModeJsonConverter.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Foundry.Core.Models.Configuration; + +/// +/// Preserves readable Autopilot provisioning mode values without changing every enum in shared configuration JSON. +/// +public sealed class AutopilotProvisioningModeJsonConverter : JsonConverter +{ + /// + public override AutopilotProvisioningMode Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out int numericValue)) + { + return Enum.IsDefined(typeof(AutopilotProvisioningMode), numericValue) + ? (AutopilotProvisioningMode)numericValue + : throw new JsonException($"Unsupported Autopilot provisioning mode value '{numericValue}'."); + } + + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("Autopilot provisioning mode must be a string or numeric enum value."); + } + + return reader.GetString() switch + { + "jsonProfile" or "JsonProfile" => AutopilotProvisioningMode.JsonProfile, + "hardwareHashUpload" or "HardwareHashUpload" => AutopilotProvisioningMode.HardwareHashUpload, + string value => throw new JsonException($"Unsupported Autopilot provisioning mode value '{value}'."), + null => throw new JsonException("Autopilot provisioning mode cannot be null.") + }; + } + + /// + public override void Write( + Utf8JsonWriter writer, + AutopilotProvisioningMode value, + JsonSerializerOptions options) + { + writer.WriteStringValue(value switch + { + AutopilotProvisioningMode.JsonProfile => "jsonProfile", + AutopilotProvisioningMode.HardwareHashUpload => "hardwareHashUpload", + _ => throw new JsonException($"Unsupported Autopilot provisioning mode value '{value}'.") + }); + } +} diff --git a/src/Foundry.Core/Models/Configuration/AutopilotSettings.cs b/src/Foundry.Core/Models/Configuration/AutopilotSettings.cs index 0f878b34..06c495da 100644 --- a/src/Foundry.Core/Models/Configuration/AutopilotSettings.cs +++ b/src/Foundry.Core/Models/Configuration/AutopilotSettings.cs @@ -1,15 +1,20 @@ namespace Foundry.Core.Models.Configuration; /// -/// Describes Autopilot profiles selected for staging into a deployed Windows image. +/// Describes the selected Autopilot provisioning method and its persistent settings. /// public sealed record AutopilotSettings { /// - /// Gets whether Autopilot staging is enabled. + /// Gets whether Autopilot provisioning is enabled. /// public bool IsEnabled { get; init; } + /// + /// Gets the provisioning method used when Autopilot is enabled. + /// + public AutopilotProvisioningMode ProvisioningMode { get; init; } = AutopilotProvisioningMode.JsonProfile; + /// /// Gets the profile ID selected as the default staged profile. /// @@ -19,4 +24,9 @@ public sealed record AutopilotSettings /// Gets the available imported or downloaded Autopilot profiles. /// public IReadOnlyList Profiles { get; init; } = []; + + /// + /// Gets tenant app registration metadata used by hardware hash upload mode. + /// + public AutopilotHardwareHashUploadSettings HardwareHashUpload { get; init; } = new(); } diff --git a/src/Foundry.Core/Models/Configuration/AutopilotTenantRegistrationSettings.cs b/src/Foundry.Core/Models/Configuration/AutopilotTenantRegistrationSettings.cs new file mode 100644 index 00000000..31f7234e --- /dev/null +++ b/src/Foundry.Core/Models/Configuration/AutopilotTenantRegistrationSettings.cs @@ -0,0 +1,27 @@ +namespace Foundry.Core.Models.Configuration; + +/// +/// Stores the tenant and managed app registration identities used for Autopilot hardware hash upload. +/// +public sealed record AutopilotTenantRegistrationSettings +{ + /// + /// Gets the Microsoft Entra tenant ID used by the managed app registration. + /// + public string? TenantId { get; init; } + + /// + /// Gets the application object ID for the managed app registration. + /// + public string? ApplicationObjectId { get; init; } + + /// + /// Gets the application client ID used for certificate-based Graph authentication in WinPE. + /// + public string? ClientId { get; init; } + + /// + /// Gets the service principal object ID created for the managed app registration. + /// + public string? ServicePrincipalObjectId { get; init; } +} diff --git a/src/Foundry.Core/Models/Configuration/Deploy/DeployAutopilotHardwareHashUploadSettings.cs b/src/Foundry.Core/Models/Configuration/Deploy/DeployAutopilotHardwareHashUploadSettings.cs new file mode 100644 index 00000000..32d9be50 --- /dev/null +++ b/src/Foundry.Core/Models/Configuration/Deploy/DeployAutopilotHardwareHashUploadSettings.cs @@ -0,0 +1,37 @@ +namespace Foundry.Core.Models.Configuration.Deploy; + +/// +/// Carries non-secret tenant and certificate metadata needed by the WinPE hardware hash upload workflow. +/// +public sealed record DeployAutopilotHardwareHashUploadSettings +{ + /// + /// Gets the Microsoft Entra tenant ID used for Graph authentication. + /// + public string? TenantId { get; init; } + + /// + /// Gets the application client ID used for certificate-based Graph authentication. + /// + public string? ClientId { get; init; } + + /// + /// Gets the selected app registration certificate credential identifier for diagnostics. + /// + public string? ActiveCertificateKeyId { get; init; } + + /// + /// Gets the selected certificate thumbprint expected from the encrypted media certificate. + /// + public string? ActiveCertificateThumbprint { get; init; } + + /// + /// Gets the selected certificate expiration time used by Deploy to skip expired media without blocking OS deployment. + /// + public DateTimeOffset? ActiveCertificateExpiresOnUtc { get; init; } + + /// + /// Gets the default group tag offered by Foundry.Deploy for hardware hash upload. + /// + public string? DefaultGroupTag { get; init; } +} diff --git a/src/Foundry.Core/Models/Configuration/Deploy/DeployAutopilotSettings.cs b/src/Foundry.Core/Models/Configuration/Deploy/DeployAutopilotSettings.cs index e4ba98ba..532540f7 100644 --- a/src/Foundry.Core/Models/Configuration/Deploy/DeployAutopilotSettings.cs +++ b/src/Foundry.Core/Models/Configuration/Deploy/DeployAutopilotSettings.cs @@ -1,17 +1,29 @@ +using Foundry.Core.Models.Configuration; + namespace Foundry.Core.Models.Configuration.Deploy; /// -/// Describes Autopilot profile staging settings consumed by Foundry.Deploy. +/// Describes the reduced Autopilot runtime settings consumed by Foundry.Deploy. /// public sealed record DeployAutopilotSettings { /// - /// Gets whether Autopilot staging is enabled. + /// Gets whether Autopilot provisioning is enabled. /// public bool IsEnabled { get; init; } + /// + /// Gets the provisioning method selected by Foundry OSD. + /// + public AutopilotProvisioningMode ProvisioningMode { get; init; } = AutopilotProvisioningMode.JsonProfile; + /// /// Gets the profile folder name selected for staging. /// public string? DefaultProfileFolderName { get; init; } + + /// + /// Gets runtime metadata required for hardware hash upload mode. + /// + public DeployAutopilotHardwareHashUploadSettings HardwareHashUpload { get; init; } = new(); } diff --git a/src/Foundry.Core/Models/Configuration/Deploy/FoundryDeployConfigurationDocument.cs b/src/Foundry.Core/Models/Configuration/Deploy/FoundryDeployConfigurationDocument.cs index 471868bb..fb36738b 100644 --- a/src/Foundry.Core/Models/Configuration/Deploy/FoundryDeployConfigurationDocument.cs +++ b/src/Foundry.Core/Models/Configuration/Deploy/FoundryDeployConfigurationDocument.cs @@ -10,7 +10,7 @@ public sealed record FoundryDeployConfigurationDocument /// /// Gets the current schema version for Foundry.Deploy configuration documents. /// - public const int CurrentSchemaVersion = 5; + public const int CurrentSchemaVersion = 6; /// /// Gets the schema version of this deployment configuration document. @@ -28,7 +28,7 @@ public sealed record FoundryDeployConfigurationDocument public DeployCustomizationSettings Customization { get; init; } = new(); /// - /// Gets Autopilot profile staging settings. + /// Gets Autopilot provisioning settings. /// public DeployAutopilotSettings Autopilot { get; init; } = new(); diff --git a/src/Foundry.Core/Models/Configuration/FoundryConfigurationDocument.cs b/src/Foundry.Core/Models/Configuration/FoundryConfigurationDocument.cs index c7368586..8dbe280f 100644 --- a/src/Foundry.Core/Models/Configuration/FoundryConfigurationDocument.cs +++ b/src/Foundry.Core/Models/Configuration/FoundryConfigurationDocument.cs @@ -10,7 +10,7 @@ public sealed record FoundryConfigurationDocument /// /// Gets the current schema version for Foundry configuration documents. /// - public const int CurrentSchemaVersion = 7; + public const int CurrentSchemaVersion = 8; /// /// Gets the schema version of this configuration document. @@ -38,7 +38,7 @@ public sealed record FoundryConfigurationDocument public CustomizationSettings Customization { get; init; } = new(); /// - /// Gets Autopilot profile settings staged for OOBE. + /// Gets Autopilot provisioning settings used during deployment. /// public AutopilotSettings Autopilot { get; init; } = new(); diff --git a/src/Foundry.Core/Services/Autopilot/AutopilotPfxCertificateValidator.cs b/src/Foundry.Core/Services/Autopilot/AutopilotPfxCertificateValidator.cs new file mode 100644 index 00000000..e1ffffcc --- /dev/null +++ b/src/Foundry.Core/Services/Autopilot/AutopilotPfxCertificateValidator.cs @@ -0,0 +1,171 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Foundry.Core.Services.Autopilot; + +/// +/// Validates operator-provided Autopilot PFX material before it is embedded into generated media. +/// +public static class AutopilotPfxCertificateValidator +{ + /// + /// Validates that a password-protected PFX contains private key material and returns its certificate metadata. + /// + /// PFX bytes provided by the operator. + /// PFX password provided by the operator. + /// Validation result describing whether the PFX can be embedded into generated media. + public static AutopilotPfxValidationResult Validate(byte[] pfxBytes, string? password) + { + return Validate(pfxBytes, password, expectedThumbprint: null, requireExpectedThumbprint: false); + } + + /// + /// Validates that a password-protected PFX contains private key material for the configured certificate. + /// + /// PFX bytes provided by the operator. + /// PFX password provided by the operator. + /// Thumbprint of the certificate configured on the managed app registration. + /// Validation result describing whether the PFX can be embedded into generated media. + public static AutopilotPfxValidationResult Validate(byte[] pfxBytes, string? password, string? expectedThumbprint) + { + return Validate(pfxBytes, password, expectedThumbprint, requireExpectedThumbprint: true); + } + + private static AutopilotPfxValidationResult Validate( + byte[] pfxBytes, + string? password, + string? expectedThumbprint, + bool requireExpectedThumbprint) + { + ArgumentNullException.ThrowIfNull(pfxBytes); + + if (pfxBytes.Length == 0) + { + return AutopilotPfxValidationResult.Failure(AutopilotPfxValidationCode.PfxRequired); + } + + if (string.IsNullOrWhiteSpace(password)) + { + return AutopilotPfxValidationResult.Failure(AutopilotPfxValidationCode.PasswordRequired); + } + + string? normalizedExpectedThumbprint = NormalizeThumbprint(expectedThumbprint); + if (requireExpectedThumbprint && string.IsNullOrWhiteSpace(normalizedExpectedThumbprint)) + { + return AutopilotPfxValidationResult.Failure(AutopilotPfxValidationCode.ExpectedThumbprintRequired); + } + + try + { + using var certificate = X509CertificateLoader.LoadPkcs12( + pfxBytes, + password, + X509KeyStorageFlags.EphemeralKeySet); + string actualThumbprint = NormalizeThumbprint(certificate.Thumbprint)!; + if (!string.IsNullOrWhiteSpace(normalizedExpectedThumbprint) && + !string.Equals(actualThumbprint, normalizedExpectedThumbprint, StringComparison.OrdinalIgnoreCase)) + { + return AutopilotPfxValidationResult.Failure(AutopilotPfxValidationCode.ThumbprintMismatch, actualThumbprint); + } + + if (!certificate.HasPrivateKey) + { + return AutopilotPfxValidationResult.Failure(AutopilotPfxValidationCode.PrivateKeyMissing, actualThumbprint); + } + + return AutopilotPfxValidationResult.Success(actualThumbprint, certificate.NotAfter.ToUniversalTime()); + } + catch (Exception ex) when (ex is CryptographicException or ArgumentException) + { + return AutopilotPfxValidationResult.Failure(AutopilotPfxValidationCode.InvalidPfx); + } + } + + private static string? NormalizeThumbprint(string? thumbprint) + { + string? normalized = thumbprint?.Replace(" ", string.Empty, StringComparison.Ordinal).Trim(); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized.ToUpperInvariant(); + } +} + +public sealed record AutopilotPfxValidationResult +{ + /// + /// Gets whether the PFX is valid for media embedding. + /// + public bool IsValid { get; init; } + + /// + /// Gets the validation outcome code. + /// + public AutopilotPfxValidationCode Code { get; init; } + + /// + /// Gets the normalized certificate thumbprint when it could be read. + /// + public string? Thumbprint { get; init; } + + /// + /// Gets the certificate expiration time when validation succeeds. + /// + public DateTimeOffset? ExpiresOnUtc { get; init; } + + /// + /// Creates a successful PFX validation result. + /// + /// Normalized certificate thumbprint. + /// Certificate expiration time. + /// Successful validation result. + public static AutopilotPfxValidationResult Success(string thumbprint, DateTimeOffset expiresOnUtc) + { + return new AutopilotPfxValidationResult + { + IsValid = true, + Code = AutopilotPfxValidationCode.Valid, + Thumbprint = thumbprint, + ExpiresOnUtc = expiresOnUtc + }; + } + + /// + /// Creates a failed PFX validation result. + /// + /// Failure code. + /// Normalized certificate thumbprint, when available. + /// Failed validation result. + public static AutopilotPfxValidationResult Failure( + AutopilotPfxValidationCode code, + string? thumbprint = null) + { + return new AutopilotPfxValidationResult + { + IsValid = false, + Code = code, + Thumbprint = thumbprint + }; + } +} + +public enum AutopilotPfxValidationCode +{ + /// The PFX is valid for media embedding. + Valid, + + /// No PFX bytes were provided. + PfxRequired, + + /// No PFX password was provided. + PasswordRequired, + + /// No expected active certificate thumbprint was configured. + ExpectedThumbprintRequired, + + /// The PFX could not be loaded. + InvalidPfx, + + /// The PFX leaf certificate thumbprint does not match the active app certificate. + ThumbprintMismatch, + + /// The PFX does not contain private key material. + PrivateKeyMissing +} diff --git a/src/Foundry.Core/Services/Autopilot/AutopilotTenantOnboardingEvaluator.cs b/src/Foundry.Core/Services/Autopilot/AutopilotTenantOnboardingEvaluator.cs new file mode 100644 index 00000000..1cee4239 --- /dev/null +++ b/src/Foundry.Core/Services/Autopilot/AutopilotTenantOnboardingEvaluator.cs @@ -0,0 +1,331 @@ +using Foundry.Core.Models.Configuration; + +namespace Foundry.Core.Services.Autopilot; + +/// +/// Evaluates the managed tenant registration state used by Autopilot hardware hash upload. +/// +public static class AutopilotTenantOnboardingEvaluator +{ + /// + /// Computes the current onboarding status from Microsoft Graph state and persisted Foundry metadata. + /// + /// Current tenant registration snapshot. + /// Evaluated onboarding status and resolved identifiers. + public static AutopilotTenantOnboardingEvaluation Evaluate(AutopilotTenantOnboardingSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + + AutopilotGraphApplication? application = FindApplication(snapshot); + if (application is null) + { + return AutopilotTenantOnboardingEvaluation.FromStatus(AutopilotTenantOnboardingStatus.AppRegistrationMissing); + } + + if (string.IsNullOrWhiteSpace(snapshot.PersistedApplicationObjectId) && + string.Equals(application.DisplayName, snapshot.ManagedAppDisplayName, StringComparison.OrdinalIgnoreCase)) + { + return AutopilotTenantOnboardingEvaluation.FromStatus( + AutopilotTenantOnboardingStatus.AdoptionRequired, + application); + } + + if (!AutopilotGraphPermissionCatalog.HasRequiredWinPeApplicationPermissions(application.RequiredPermissionValues)) + { + return AutopilotTenantOnboardingEvaluation.FromStatus( + AutopilotTenantOnboardingStatus.PermissionMissing, + application); + } + + if (snapshot.ServicePrincipal is not { IsEnabled: true }) + { + return AutopilotTenantOnboardingEvaluation.FromStatus( + AutopilotTenantOnboardingStatus.ServicePrincipalUnavailable, + application); + } + + if (!AutopilotGraphPermissionCatalog.HasRequiredWinPeApplicationPermissions(snapshot.ServicePrincipal.ConsentedPermissionValues)) + { + return AutopilotTenantOnboardingEvaluation.FromStatus( + AutopilotTenantOnboardingStatus.ConsentMissing, + application, + snapshot.ServicePrincipal); + } + + AutopilotGraphKeyCredential[] validManagedCredentials = snapshot.KeyCredentials + .Where(credential => + string.Equals(credential.DisplayName, snapshot.ManagedAppDisplayName, StringComparison.OrdinalIgnoreCase) && + credential.ExpiresOnUtc > snapshot.CurrentTimeUtc) + .OrderBy(credential => credential.ExpiresOnUtc) + .ToArray(); + AutopilotCertificateMetadata? activeCertificate = snapshot.ActiveCertificate; + if (activeCertificate is null) + { + return validManagedCredentials.Length > 0 + ? AutopilotTenantOnboardingEvaluation.FromStatus( + AutopilotTenantOnboardingStatus.Ready, + application, + snapshot.ServicePrincipal, + validManagedCredentials[0]) + : AutopilotTenantOnboardingEvaluation.FromStatus( + AutopilotTenantOnboardingStatus.ActiveCertificateMissing, + application, + snapshot.ServicePrincipal); + } + + AutopilotGraphKeyCredential? graphCredential = snapshot.KeyCredentials.FirstOrDefault(credential => + string.Equals(credential.KeyId, activeCertificate.KeyId, StringComparison.OrdinalIgnoreCase) && + string.Equals(NormalizeThumbprint(credential.Thumbprint), NormalizeThumbprint(activeCertificate.Thumbprint), StringComparison.Ordinal)); + if (graphCredential is null) + { + return validManagedCredentials.Length > 0 + ? AutopilotTenantOnboardingEvaluation.FromStatus( + AutopilotTenantOnboardingStatus.Ready, + application, + snapshot.ServicePrincipal, + validManagedCredentials[0]) + : AutopilotTenantOnboardingEvaluation.FromStatus( + AutopilotTenantOnboardingStatus.ActiveCertificateNotFound, + application, + snapshot.ServicePrincipal); + } + + if (graphCredential.ExpiresOnUtc <= snapshot.CurrentTimeUtc) + { + return validManagedCredentials.Length > 0 + ? AutopilotTenantOnboardingEvaluation.FromStatus( + AutopilotTenantOnboardingStatus.Ready, + application, + snapshot.ServicePrincipal, + validManagedCredentials[0]) + : AutopilotTenantOnboardingEvaluation.FromStatus( + AutopilotTenantOnboardingStatus.ActiveCertificateExpired, + application, + snapshot.ServicePrincipal); + } + + return AutopilotTenantOnboardingEvaluation.FromStatus( + AutopilotTenantOnboardingStatus.Ready, + application, + snapshot.ServicePrincipal, + graphCredential); + } + + private static AutopilotGraphApplication? FindApplication(AutopilotTenantOnboardingSnapshot snapshot) + { + if (!string.IsNullOrWhiteSpace(snapshot.PersistedApplicationObjectId)) + { + AutopilotGraphApplication? persisted = snapshot.Applications.FirstOrDefault(application => + string.Equals(application.ObjectId, snapshot.PersistedApplicationObjectId, StringComparison.OrdinalIgnoreCase)); + if (persisted is not null) + { + return persisted; + } + } + + return snapshot.Applications.FirstOrDefault(application => + string.Equals(application.DisplayName, snapshot.ManagedAppDisplayName, StringComparison.OrdinalIgnoreCase)); + } + + private static string? NormalizeThumbprint(string? thumbprint) + { + string? normalized = thumbprint?.Replace(" ", string.Empty, StringComparison.Ordinal).Trim(); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized.ToUpperInvariant(); + } +} + +/// +/// Represents the Microsoft Graph and persisted Foundry state required to evaluate tenant onboarding. +/// +public sealed record AutopilotTenantOnboardingSnapshot +{ + /// Gets the connected tenant ID. + public required string TenantId { get; init; } + + /// Gets the persisted managed app object ID, when Foundry already owns one. + public string? PersistedApplicationObjectId { get; init; } + + /// Gets the display name expected for the managed Foundry app registration. + public required string ManagedAppDisplayName { get; init; } + + /// Gets candidate applications discovered in Microsoft Graph. + public IReadOnlyList Applications { get; init; } = []; + + /// Gets the managed app service principal, when it exists. + public AutopilotGraphServicePrincipal? ServicePrincipal { get; init; } + + /// Gets the active certificate metadata persisted by Foundry. + public AutopilotCertificateMetadata? ActiveCertificate { get; init; } + + /// Gets certificate credentials currently present on the app registration. + public IReadOnlyList KeyCredentials { get; init; } = []; + + /// Gets the evaluation clock used for certificate expiration checks. + public DateTimeOffset CurrentTimeUtc { get; init; } = DateTimeOffset.UtcNow; +} + +/// +/// Result of evaluating the managed Autopilot app registration onboarding state. +/// +public sealed record AutopilotTenantOnboardingEvaluation +{ + /// Gets the evaluated onboarding status. + public AutopilotTenantOnboardingStatus Status { get; init; } + + /// Gets the selected application object ID, when available. + public string? ApplicationObjectId { get; init; } + + /// Gets the selected application client ID, when available. + public string? ClientId { get; init; } + + /// Gets the selected service principal object ID, when available. + public string? ServicePrincipalObjectId { get; init; } + + /// Gets the active app credential found in Microsoft Graph, when available. + public AutopilotGraphKeyCredential? ActiveCertificateCredential { get; init; } + + /// + /// Creates an evaluation result for the supplied status and resolved Graph objects. + /// + public static AutopilotTenantOnboardingEvaluation FromStatus( + AutopilotTenantOnboardingStatus status, + AutopilotGraphApplication? application = null, + AutopilotGraphServicePrincipal? servicePrincipal = null, + AutopilotGraphKeyCredential? activeCertificateCredential = null) + { + return new AutopilotTenantOnboardingEvaluation + { + Status = status, + ApplicationObjectId = application?.ObjectId, + ClientId = application?.ClientId, + ServicePrincipalObjectId = servicePrincipal?.ObjectId, + ActiveCertificateCredential = activeCertificateCredential + }; + } +} + +/// +/// Autopilot hardware hash tenant onboarding status. +/// +public enum AutopilotTenantOnboardingStatus +{ + /// The managed app registration is ready for hardware hash upload media generation. + Ready, + + /// The managed app registration does not exist. + AppRegistrationMissing, + + /// An app with the managed display name exists but is not yet adopted by persisted Foundry metadata. + AdoptionRequired, + + /// The managed app registration is missing required Graph application permissions. + PermissionMissing, + + /// The managed service principal is missing admin consent for required Graph permissions. + ConsentMissing, + + /// The managed service principal is missing or disabled. + ServicePrincipalUnavailable, + + /// No active certificate is selected in persisted Foundry metadata. + ActiveCertificateMissing, + + /// The selected active certificate was not found in the app registration credentials. + ActiveCertificateNotFound, + + /// The selected active certificate is expired. + ActiveCertificateExpired +} + +/// +/// Minimal Microsoft Graph application state required for Autopilot onboarding evaluation. +/// +public sealed record AutopilotGraphApplication( + string ObjectId, + string ClientId, + string DisplayName, + IReadOnlySet RequiredPermissionValues); + +/// +/// Minimal Microsoft Graph service principal state required for Autopilot onboarding evaluation. +/// +public sealed record AutopilotGraphServicePrincipal( + string ObjectId, + bool IsEnabled, + IReadOnlySet ConsentedPermissionValues); + +/// +/// Minimal Microsoft Graph certificate credential state required for Autopilot onboarding evaluation. +/// +public sealed record AutopilotGraphKeyCredential( + string KeyId, + string DisplayName, + string Thumbprint, + DateTimeOffset StartsOnUtc, + DateTimeOffset ExpiresOnUtc); + +/// +/// Central catalog of Graph permissions required by the WinPE app-only upload path. +/// +public static class AutopilotGraphPermissionCatalog +{ + /// + /// Graph application permission required to import and poll Windows Autopilot device identities. + /// + public const string DeviceManagementServiceConfigReadWriteAll = "DeviceManagementServiceConfig.ReadWrite.All"; + + /// + /// Gets the required Graph application permissions for WinPE app-only upload. + /// + public static readonly IReadOnlySet RequiredWinPeApplicationPermissionValues = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + DeviceManagementServiceConfigReadWriteAll + }; + + /// + /// Determines whether the supplied permission values include every permission required by WinPE upload. + /// + /// Permission values to evaluate. + /// when all required permissions are present. + public static bool HasRequiredWinPeApplicationPermissions(IReadOnlySet permissionValues) + { + ArgumentNullException.ThrowIfNull(permissionValues); + return RequiredWinPeApplicationPermissionValues.All(permissionValues.Contains); + } +} + +/// +/// Provides pure helpers for safe app registration certificate credential collection updates. +/// +public static class AutopilotAppRegistrationCertificateCollection +{ + /// + /// Adds or replaces one certificate credential without pruning unrelated credentials. + /// + public static IReadOnlyList AddCertificate( + IReadOnlyList currentCredentials, + AutopilotGraphKeyCredential credential) + { + ArgumentNullException.ThrowIfNull(currentCredentials); + ArgumentNullException.ThrowIfNull(credential); + return currentCredentials + .Where(existing => !string.Equals(existing.KeyId, credential.KeyId, StringComparison.OrdinalIgnoreCase)) + .Concat([credential]) + .ToArray(); + } + + /// + /// Removes only the persisted active certificate credential. + /// + public static IReadOnlyList RetireActiveCertificate( + IReadOnlyList currentCredentials, + string activeKeyId) + { + ArgumentNullException.ThrowIfNull(currentCredentials); + ArgumentException.ThrowIfNullOrWhiteSpace(activeKeyId); + return currentCredentials + .Where(credential => !string.Equals(credential.KeyId, activeKeyId, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } +} diff --git a/src/Foundry.Core/Services/Autopilot/MediaSecretEnvelopeProtector.cs b/src/Foundry.Core/Services/Autopilot/MediaSecretEnvelopeProtector.cs new file mode 100644 index 00000000..65adef3e --- /dev/null +++ b/src/Foundry.Core/Services/Autopilot/MediaSecretEnvelopeProtector.cs @@ -0,0 +1,281 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Foundry.Core.Models.Configuration; + +namespace Foundry.Core.Services.Autopilot; + +/// +/// Protects media-embedded secrets with the shared Foundry AES-GCM envelope format. +/// +public static class MediaSecretEnvelopeProtector +{ + /// + /// Envelope kind used for media-embedded encrypted secrets. + /// + public const string Kind = "encrypted"; + + /// + /// Encryption algorithm identifier serialized into media secret envelopes. + /// + public const string Algorithm = "aes-gcm-v1"; + + /// + /// Logical key identifier for the media secret key stored with generated boot media. + /// + public const string KeyId = "media"; + + /// + /// Required AES-256 media secret key length. + /// + public const int KeySizeBytes = 32; + + /// + /// Required AES-GCM nonce length. + /// + public const int NonceSizeBytes = 12; + + /// + /// Required AES-GCM authentication tag length. + /// + public const int TagSizeBytes = 16; + + /// + /// Creates a new random media secret key for generated boot media. + /// + /// A 32-byte media secret key. + public static byte[] GenerateMediaKey() + { + byte[] key = new byte[KeySizeBytes]; + RandomNumberGenerator.Fill(key); + return key; + } + + /// + /// Encrypts a UTF-8 string into the shared media secret envelope format. + /// + /// Plaintext value to encrypt. + /// Media secret key. + /// Encrypted secret envelope safe to serialize into generated configuration. + public static SecretEnvelope EncryptString(string plaintext, byte[] key) + { + ArgumentNullException.ThrowIfNull(plaintext); + byte[] plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + try + { + return EncryptBytes(plaintextBytes, key); + } + finally + { + CryptographicOperations.ZeroMemory(plaintextBytes); + } + } + + /// + /// Decrypts a media secret envelope into a UTF-8 string. + /// + /// Encrypted secret envelope. + /// Media secret key. + /// Decrypted string value. + public static string DecryptString(SecretEnvelope envelope, byte[] key) + { + byte[] plaintextBytes = DecryptBytes(envelope, key); + try + { + return Encoding.UTF8.GetString(plaintextBytes); + } + finally + { + CryptographicOperations.ZeroMemory(plaintextBytes); + } + } + + /// + /// Encrypts binary secret material into the shared media secret envelope format. + /// + /// Plaintext bytes to encrypt. + /// Media secret key. + /// Encrypted secret envelope safe to serialize into generated configuration. + public static SecretEnvelope EncryptBytes(byte[] plaintext, byte[] key) + { + ArgumentNullException.ThrowIfNull(plaintext); + ValidateKey(key); + + byte[] nonce = new byte[NonceSizeBytes]; + byte[] tag = new byte[TagSizeBytes]; + byte[] ciphertext = new byte[plaintext.Length]; + + RandomNumberGenerator.Fill(nonce); + using var aes = new AesGcm(key, TagSizeBytes); + aes.Encrypt(nonce, plaintext, ciphertext, tag); + + return new SecretEnvelope + { + Kind = Kind, + Algorithm = Algorithm, + KeyId = KeyId, + Nonce = Base64UrlEncode(nonce), + Tag = Base64UrlEncode(tag), + Ciphertext = Base64UrlEncode(ciphertext) + }; + } + + /// + /// Decrypts binary secret material from a media secret envelope. + /// + /// Encrypted secret envelope. + /// Media secret key. + /// Decrypted bytes. The caller is responsible for zeroing the returned buffer. + public static byte[] DecryptBytes(SecretEnvelope envelope, byte[] key) + { + ArgumentNullException.ThrowIfNull(envelope); + ValidateEnvelope(envelope); + ValidateKey(key); + + byte[] nonce = Base64UrlDecode(envelope.Nonce); + byte[] tag = Base64UrlDecode(envelope.Tag); + byte[] ciphertext = Base64UrlDecode(envelope.Ciphertext); + byte[] plaintext = new byte[ciphertext.Length]; + + if (nonce.Length != NonceSizeBytes) + { + throw new CryptographicException("Encrypted secret nonce has an invalid length."); + } + + if (tag.Length != TagSizeBytes) + { + throw new CryptographicException("Encrypted secret tag has an invalid length."); + } + + try + { + using var aes = new AesGcm(key, TagSizeBytes); + aes.Decrypt(nonce, ciphertext, tag, plaintext); + return plaintext; + } + catch (CryptographicException ex) + { + CryptographicOperations.ZeroMemory(plaintext); + throw new CryptographicException("Encrypted secret could not be decrypted.", ex); + } + } + + /// + /// Detects whether serialized JSON contains at least one shared media secret envelope. + /// + /// Serialized configuration JSON. + /// when an encrypted media secret envelope is present. + public static bool HasEncryptedSecrets(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return false; + } + + using JsonDocument document = JsonDocument.Parse(json); + return HasEncryptedSecrets(document.RootElement); + } + + /// + /// Determines whether a JSON object matches the shared media secret envelope shape. + /// + /// JSON element to inspect. + /// when the element is a complete encrypted media secret envelope. + public static bool IsEncryptedSecretEnvelope(JsonElement element) + { + return element.ValueKind == JsonValueKind.Object && + HasStringProperty(element, "kind", Kind) && + HasStringProperty(element, "algorithm", Algorithm) && + HasStringProperty(element, "keyId", KeyId) && + HasNonEmptyStringProperty(element, "nonce") && + HasNonEmptyStringProperty(element, "tag") && + HasNonEmptyStringProperty(element, "ciphertext"); + } + + private static bool HasEncryptedSecrets(JsonElement element) + { + if (IsEncryptedSecretEnvelope(element)) + { + return true; + } + + if (element.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty property in element.EnumerateObject()) + { + if (HasEncryptedSecrets(property.Value)) + { + return true; + } + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in element.EnumerateArray()) + { + if (HasEncryptedSecrets(item)) + { + return true; + } + } + } + + return false; + } + + private static void ValidateEnvelope(SecretEnvelope envelope) + { + if (!string.Equals(envelope.Kind, Kind, StringComparison.Ordinal) || + !string.Equals(envelope.Algorithm, Algorithm, StringComparison.Ordinal) || + !string.Equals(envelope.KeyId, KeyId, StringComparison.Ordinal)) + { + throw new CryptographicException("Encrypted secret envelope is not supported."); + } + + if (string.IsNullOrWhiteSpace(envelope.Nonce) || + string.IsNullOrWhiteSpace(envelope.Tag) || + string.IsNullOrWhiteSpace(envelope.Ciphertext)) + { + throw new CryptographicException("Encrypted secret envelope is incomplete."); + } + } + + private static void ValidateKey(byte[] key) + { + ArgumentNullException.ThrowIfNull(key); + if (key.Length != KeySizeBytes) + { + throw new ArgumentException($"Media secret key must be {KeySizeBytes} bytes.", nameof(key)); + } + } + + private static string Base64UrlEncode(byte[] value) + { + return Convert.ToBase64String(value) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + private static byte[] Base64UrlDecode(string value) + { + string base64 = value.Replace('-', '+').Replace('_', '/'); + int padding = (4 - base64.Length % 4) % 4; + base64 = base64.PadRight(base64.Length + padding, '='); + return Convert.FromBase64String(base64); + } + + private static bool HasStringProperty(JsonElement element, string propertyName, string expectedValue) + { + return element.TryGetProperty(propertyName, out JsonElement property) && + property.ValueKind == JsonValueKind.String && + string.Equals(property.GetString(), expectedValue, StringComparison.Ordinal); + } + + private static bool HasNonEmptyStringProperty(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out JsonElement property) && + property.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(property.GetString()); + } +} diff --git a/src/Foundry.Core/Services/Configuration/AutopilotConfigurationValidator.cs b/src/Foundry.Core/Services/Configuration/AutopilotConfigurationValidator.cs new file mode 100644 index 00000000..bbfbcacd --- /dev/null +++ b/src/Foundry.Core/Services/Configuration/AutopilotConfigurationValidator.cs @@ -0,0 +1,292 @@ +using Foundry.Core.Models.Configuration; + +namespace Foundry.Core.Services.Configuration; + +/// +/// Evaluates whether persistent Autopilot settings are complete enough to generate deployment media. +/// +public static class AutopilotConfigurationValidator +{ + /// + /// Determines whether Autopilot settings are ready for media generation. + /// + /// Autopilot settings to evaluate. + /// Current UTC time used for certificate expiration checks. + /// when the selected Autopilot mode is ready for output. + public static bool IsReady(AutopilotSettings settings, DateTimeOffset currentTimeUtc) + { + return Evaluate(settings, currentTimeUtc).IsReady; + } + + /// + /// Evaluates Autopilot media readiness and returns the precise blocking reason. + /// + /// Autopilot settings to evaluate. + /// Current UTC time used for certificate expiration checks. + /// The Autopilot validation result. + public static AutopilotConfigurationValidationResult Evaluate(AutopilotSettings settings, DateTimeOffset currentTimeUtc) + { + ArgumentNullException.ThrowIfNull(settings); + + if (!settings.IsEnabled) + { + return AutopilotConfigurationValidationResult.Ready(AutopilotConfigurationValidationCode.Disabled); + } + + return settings.ProvisioningMode switch + { + AutopilotProvisioningMode.JsonProfile => GetSelectedJsonProfile(settings) is not null + ? AutopilotConfigurationValidationResult.Ready(AutopilotConfigurationValidationCode.Ready) + : AutopilotConfigurationValidationResult.Blocked(AutopilotConfigurationValidationCode.JsonProfileMissing), + AutopilotProvisioningMode.HardwareHashUpload => EvaluateHardwareHashUpload(settings.HardwareHashUpload, currentTimeUtc), + _ => AutopilotConfigurationValidationResult.Blocked(AutopilotConfigurationValidationCode.UnsupportedProvisioningMode) + }; + } + + internal static void ThrowIfNotReady(AutopilotSettings settings, DateTimeOffset currentTimeUtc) + { + ArgumentNullException.ThrowIfNull(settings); + + if (!settings.IsEnabled) + { + return; + } + + if (settings.ProvisioningMode == AutopilotProvisioningMode.JsonProfile && GetSelectedJsonProfile(settings) is null) + { + throw new InvalidOperationException("Autopilot JSON profile mode requires a selected profile."); + } + + if (settings.ProvisioningMode == AutopilotProvisioningMode.HardwareHashUpload && + !EvaluateHardwareHashUpload(settings.HardwareHashUpload, currentTimeUtc).IsReady) + { + throw new InvalidOperationException("Autopilot hardware hash upload mode requires complete tenant metadata and a validated unexpired PFX matching the active certificate."); + } + + if (!Enum.IsDefined(settings.ProvisioningMode)) + { + throw new InvalidOperationException($"Unsupported Autopilot provisioning mode '{settings.ProvisioningMode}'."); + } + } + + private static AutopilotProfileSettings? GetSelectedJsonProfile(AutopilotSettings settings) + { + if (string.IsNullOrWhiteSpace(settings.DefaultProfileId)) + { + return null; + } + + return settings.Profiles.FirstOrDefault(profile => + string.Equals(profile.Id, settings.DefaultProfileId, StringComparison.OrdinalIgnoreCase)); + } + + private static AutopilotConfigurationValidationResult EvaluateHardwareHashUpload( + AutopilotHardwareHashUploadSettings? settings, + DateTimeOffset currentTimeUtc) + { + if (settings?.Tenant is null) + { + return AutopilotConfigurationValidationResult.Blocked(AutopilotConfigurationValidationCode.HardwareHashSettingsMissing); + } + + if (string.IsNullOrWhiteSpace(settings.Tenant.TenantId)) + { + return AutopilotConfigurationValidationResult.Blocked(AutopilotConfigurationValidationCode.HardwareHashTenantMissing); + } + + if (string.IsNullOrWhiteSpace(settings.Tenant.ApplicationObjectId)) + { + return AutopilotConfigurationValidationResult.Blocked(AutopilotConfigurationValidationCode.HardwareHashAppRegistrationMissing); + } + + if (string.IsNullOrWhiteSpace(settings.Tenant.ClientId)) + { + return AutopilotConfigurationValidationResult.Blocked(AutopilotConfigurationValidationCode.HardwareHashClientIdMissing); + } + + if (string.IsNullOrWhiteSpace(settings.Tenant.ServicePrincipalObjectId)) + { + return AutopilotConfigurationValidationResult.Blocked(AutopilotConfigurationValidationCode.HardwareHashServicePrincipalMissing); + } + + AutopilotConfigurationValidationResult bootMediaResult = EvaluateBootMediaCertificate(settings, currentTimeUtc); + if (!bootMediaResult.IsReady) + { + return bootMediaResult; + } + + if (string.IsNullOrWhiteSpace(settings.ActiveCertificate?.KeyId)) + { + return AutopilotConfigurationValidationResult.Blocked(AutopilotConfigurationValidationCode.HardwareHashActiveCertificateMissing); + } + + if (string.IsNullOrWhiteSpace(settings.ActiveCertificate.Thumbprint)) + { + return AutopilotConfigurationValidationResult.Blocked(AutopilotConfigurationValidationCode.HardwareHashActiveCertificateThumbprintMissing); + } + + if (settings.ActiveCertificate.ExpiresOnUtc is not { } expiresOnUtc) + { + return AutopilotConfigurationValidationResult.Blocked(AutopilotConfigurationValidationCode.HardwareHashActiveCertificateExpirationMissing); + } + + if (expiresOnUtc <= currentTimeUtc) + { + return AutopilotConfigurationValidationResult.Blocked(AutopilotConfigurationValidationCode.HardwareHashActiveCertificateExpired); + } + + return AutopilotConfigurationValidationResult.Ready(AutopilotConfigurationValidationCode.Ready); + } + + private static AutopilotConfigurationValidationResult EvaluateBootMediaCertificate( + AutopilotHardwareHashUploadSettings settings, + DateTimeOffset currentTimeUtc) + { + AutopilotBootMediaCertificateSettings bootMediaCertificate = settings.BootMediaCertificate; + + if (string.IsNullOrWhiteSpace(bootMediaCertificate.PfxPath)) + { + return AutopilotConfigurationValidationResult.Blocked(AutopilotConfigurationValidationCode.HardwareHashBootMediaPfxMissing); + } + + if (string.IsNullOrWhiteSpace(bootMediaCertificate.PfxPassword)) + { + return AutopilotConfigurationValidationResult.Blocked(AutopilotConfigurationValidationCode.HardwareHashBootMediaPfxPasswordMissing); + } + + if (string.IsNullOrWhiteSpace(bootMediaCertificate.ValidatedThumbprint)) + { + return AutopilotConfigurationValidationResult.Blocked(AutopilotConfigurationValidationCode.HardwareHashBootMediaCertificateNotValidated); + } + + if (!string.Equals( + NormalizeThumbprint(settings.ActiveCertificate?.Thumbprint), + NormalizeThumbprint(bootMediaCertificate.ValidatedThumbprint), + StringComparison.OrdinalIgnoreCase)) + { + return AutopilotConfigurationValidationResult.Blocked(AutopilotConfigurationValidationCode.HardwareHashBootMediaCertificateThumbprintMismatch); + } + + if (bootMediaCertificate.ValidatedExpiresOnUtc is not { } expiresOnUtc) + { + return AutopilotConfigurationValidationResult.Blocked(AutopilotConfigurationValidationCode.HardwareHashBootMediaCertificateExpirationMissing); + } + + return expiresOnUtc > currentTimeUtc + ? AutopilotConfigurationValidationResult.Ready(AutopilotConfigurationValidationCode.Ready) + : AutopilotConfigurationValidationResult.Blocked(AutopilotConfigurationValidationCode.HardwareHashBootMediaCertificateExpired); + } + + private static string? NormalizeThumbprint(string? thumbprint) + { + string? normalized = thumbprint?.Replace(" ", string.Empty, StringComparison.Ordinal).Trim(); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized.ToUpperInvariant(); + } +} + +/// +/// Describes Autopilot media readiness and the most specific blocking reason. +/// +public sealed record AutopilotConfigurationValidationResult +{ + /// + /// Gets whether the selected Autopilot mode is ready for media generation. + /// + public bool IsReady { get; init; } + + /// + /// Gets the readiness or blocking reason code. + /// + public AutopilotConfigurationValidationCode Code { get; init; } + + /// + /// Creates a ready validation result. + /// + /// Ready code. + /// A ready validation result. + public static AutopilotConfigurationValidationResult Ready(AutopilotConfigurationValidationCode code) + { + return new AutopilotConfigurationValidationResult + { + IsReady = true, + Code = code + }; + } + + /// + /// Creates a blocked validation result. + /// + /// Blocking reason code. + /// A blocked validation result. + public static AutopilotConfigurationValidationResult Blocked(AutopilotConfigurationValidationCode code) + { + return new AutopilotConfigurationValidationResult + { + IsReady = false, + Code = code + }; + } +} + +/// +/// Identifies the Autopilot readiness status or blocking reason. +/// +public enum AutopilotConfigurationValidationCode +{ + /// Autopilot is ready for media generation. + Ready, + + /// Autopilot is disabled. + Disabled, + + /// The selected Autopilot provisioning mode is unsupported. + UnsupportedProvisioningMode, + + /// JSON profile mode has no valid selected profile. + JsonProfileMissing, + + /// Hardware hash settings are missing. + HardwareHashSettingsMissing, + + /// The tenant ID is missing. + HardwareHashTenantMissing, + + /// The managed app registration object ID is missing. + HardwareHashAppRegistrationMissing, + + /// The managed app client ID is missing. + HardwareHashClientIdMissing, + + /// The managed app service principal object ID is missing. + HardwareHashServicePrincipalMissing, + + /// The active certificate key ID is missing. + HardwareHashActiveCertificateMissing, + + /// The active certificate thumbprint is missing. + HardwareHashActiveCertificateThumbprintMissing, + + /// The active certificate expiration is missing. + HardwareHashActiveCertificateExpirationMissing, + + /// The active certificate is expired. + HardwareHashActiveCertificateExpired, + + /// The boot media PFX file was not selected. + HardwareHashBootMediaPfxMissing, + + /// The boot media PFX password is missing. + HardwareHashBootMediaPfxPasswordMissing, + + /// The boot media PFX has not been validated. + HardwareHashBootMediaCertificateNotValidated, + + /// The boot media PFX thumbprint does not match the active certificate. + HardwareHashBootMediaCertificateThumbprintMismatch, + + /// The boot media PFX expiration is missing. + HardwareHashBootMediaCertificateExpirationMissing, + + /// The boot media PFX certificate is expired. + HardwareHashBootMediaCertificateExpired +} diff --git a/src/Foundry.Core/Services/Configuration/ConnectSecretEnvelopeProtector.cs b/src/Foundry.Core/Services/Configuration/ConnectSecretEnvelopeProtector.cs index 06c5ffd3..148958ea 100644 --- a/src/Foundry.Core/Services/Configuration/ConnectSecretEnvelopeProtector.cs +++ b/src/Foundry.Core/Services/Configuration/ConnectSecretEnvelopeProtector.cs @@ -1,71 +1,24 @@ -using System.Security.Cryptography; -using System.Text; using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Autopilot; namespace Foundry.Core.Services.Configuration; internal static class ConnectSecretEnvelopeProtector { - public const string Kind = "encrypted"; - public const string Algorithm = "aes-gcm-v1"; - public const string KeyId = "media"; - public const int KeySizeBytes = 32; - public const int NonceSizeBytes = 12; - public const int TagSizeBytes = 16; + public const string Kind = MediaSecretEnvelopeProtector.Kind; + public const string Algorithm = MediaSecretEnvelopeProtector.Algorithm; + public const string KeyId = MediaSecretEnvelopeProtector.KeyId; + public const int KeySizeBytes = MediaSecretEnvelopeProtector.KeySizeBytes; + public const int NonceSizeBytes = MediaSecretEnvelopeProtector.NonceSizeBytes; + public const int TagSizeBytes = MediaSecretEnvelopeProtector.TagSizeBytes; public static byte[] GenerateMediaKey() { - byte[] key = new byte[KeySizeBytes]; - RandomNumberGenerator.Fill(key); - return key; + return MediaSecretEnvelopeProtector.GenerateMediaKey(); } public static SecretEnvelope Encrypt(string plaintext, byte[] key) { - ArgumentNullException.ThrowIfNull(plaintext); - ValidateKey(key); - - byte[] nonce = new byte[NonceSizeBytes]; - byte[] tag = new byte[TagSizeBytes]; - byte[] plaintextBytes = Encoding.UTF8.GetBytes(plaintext); - byte[] ciphertext = new byte[plaintextBytes.Length]; - - try - { - RandomNumberGenerator.Fill(nonce); - using var aes = new AesGcm(key, TagSizeBytes); - aes.Encrypt(nonce, plaintextBytes, ciphertext, tag); - - return new SecretEnvelope - { - Kind = Kind, - Algorithm = Algorithm, - KeyId = KeyId, - Nonce = Base64UrlEncode(nonce), - Tag = Base64UrlEncode(tag), - Ciphertext = Base64UrlEncode(ciphertext) - }; - } - finally - { - CryptographicOperations.ZeroMemory(plaintextBytes); - } - } - - private static void ValidateKey(byte[] key) - { - ArgumentNullException.ThrowIfNull(key); - if (key.Length != KeySizeBytes) - { - throw new ArgumentException($"Media secret key must be {KeySizeBytes} bytes.", nameof(key)); - } - } - - private static string Base64UrlEncode(byte[] value) - { - return Convert.ToBase64String(value) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); + return MediaSecretEnvelopeProtector.EncryptString(plaintext, key); } } diff --git a/src/Foundry.Core/Services/Configuration/DeployConfigurationGenerator.cs b/src/Foundry.Core/Services/Configuration/DeployConfigurationGenerator.cs index 0493c34b..dccd3639 100644 --- a/src/Foundry.Core/Services/Configuration/DeployConfigurationGenerator.cs +++ b/src/Foundry.Core/Services/Configuration/DeployConfigurationGenerator.cs @@ -13,6 +13,7 @@ public sealed class DeployConfigurationGenerator : IDeployConfigurationGenerator public FoundryDeployConfigurationDocument Generate(FoundryConfigurationDocument document) { ArgumentNullException.ThrowIfNull(document); + AutopilotConfigurationValidator.ThrowIfNotReady(document.Autopilot, DateTimeOffset.UtcNow); string[] visibleLanguageCodes = CanonicalizeLanguageCodes(document.Localization.VisibleLanguageCodes); string? defaultLanguageCodeOverride = CanonicalizeOptionalLanguageCode(document.Localization.DefaultLanguageCodeOverride); @@ -55,9 +56,13 @@ public FoundryDeployConfigurationDocument Generate(FoundryConfigurationDocument Autopilot = new DeployAutopilotSettings { IsEnabled = document.Autopilot.IsEnabled, - DefaultProfileFolderName = document.Autopilot.Profiles - .FirstOrDefault(profile => string.Equals(profile.Id, document.Autopilot.DefaultProfileId, StringComparison.OrdinalIgnoreCase)) - ?.FolderName + ProvisioningMode = document.Autopilot.ProvisioningMode, + DefaultProfileFolderName = document.Autopilot.ProvisioningMode == AutopilotProvisioningMode.JsonProfile + ? document.Autopilot.Profiles + .FirstOrDefault(profile => string.Equals(profile.Id, document.Autopilot.DefaultProfileId, StringComparison.OrdinalIgnoreCase)) + ?.FolderName + : null, + HardwareHashUpload = CreateDeployHardwareHashUploadSettings(document.Autopilot.HardwareHashUpload) }, Telemetry = document.Telemetry }; @@ -97,6 +102,25 @@ private static string[] CanonicalizeLanguageCodes(IEnumerable languageCo return string.IsNullOrWhiteSpace(canonicalCode) ? null : canonicalCode; } + private static DeployAutopilotHardwareHashUploadSettings CreateDeployHardwareHashUploadSettings( + AutopilotHardwareHashUploadSettings? settings) + { + if (settings?.Tenant is null) + { + return new DeployAutopilotHardwareHashUploadSettings(); + } + + return new DeployAutopilotHardwareHashUploadSettings + { + TenantId = settings.Tenant.TenantId, + ClientId = settings.Tenant.ClientId, + ActiveCertificateKeyId = settings.ActiveCertificate?.KeyId, + ActiveCertificateThumbprint = settings.ActiveCertificate?.Thumbprint, + ActiveCertificateExpiresOnUtc = settings.ActiveCertificate?.ExpiresOnUtc, + DefaultGroupTag = settings.DefaultGroupTag + }; + } + private static DeployOobeSettings MapOobeSettings(OobeSettings settings) { if (!settings.IsEnabled) diff --git a/src/Foundry.Core/Services/Media/MediaPreflightOptions.cs b/src/Foundry.Core/Services/Media/MediaPreflightOptions.cs index 97ec0917..50672a10 100644 --- a/src/Foundry.Core/Services/Media/MediaPreflightOptions.cs +++ b/src/Foundry.Core/Services/Media/MediaPreflightOptions.cs @@ -1,3 +1,5 @@ +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Configuration; using Foundry.Core.Services.WinPe; namespace Foundry.Core.Services.Media; @@ -33,15 +35,26 @@ public sealed record MediaPreflightOptions public bool AreRequiredSecretsReady { get; init; } /// - /// Gets a value indicating whether Autopilot staging is enabled. + /// Gets a value indicating whether Autopilot provisioning is enabled. /// public bool IsAutopilotEnabled { get; init; } /// - /// Gets a value indicating whether the selected Autopilot profile is valid. + /// Gets a value indicating whether the selected Autopilot provisioning mode is valid. /// public bool IsAutopilotConfigurationReady { get; init; } = true; + /// + /// Gets the detailed Autopilot validation code for the selected provisioning mode. + /// + public AutopilotConfigurationValidationCode AutopilotConfigurationValidationCode { get; init; } = + AutopilotConfigurationValidationCode.Ready; + + /// + /// Gets the selected Autopilot provisioning mode. + /// + public AutopilotProvisioningMode AutopilotProvisioningMode { get; init; } = AutopilotProvisioningMode.JsonProfile; + /// /// Gets the selected Autopilot profile display name when available. /// diff --git a/src/Foundry.Core/Services/WinPe/WinPeMountedImageAssetProvisioningService.cs b/src/Foundry.Core/Services/WinPe/WinPeMountedImageAssetProvisioningService.cs index 50b5ba19..477ab6af 100644 --- a/src/Foundry.Core/Services/WinPe/WinPeMountedImageAssetProvisioningService.cs +++ b/src/Foundry.Core/Services/WinPe/WinPeMountedImageAssetProvisioningService.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Foundry.Core.Models.Configuration; using Foundry.Core.Models.Configuration.Deploy; +using Foundry.Core.Services.Autopilot; using Foundry.Core.Services.Configuration; namespace Foundry.Core.Services.WinPe; @@ -106,6 +107,7 @@ await File.WriteAllTextAsync( await WriteMediaSecretsKeyAsync( foundryConfigPath, connectConfigurationJson, + deployConfigurationJson, options.MediaSecretsKey, cancellationToken).ConfigureAwait(false); @@ -134,15 +136,17 @@ await File.WriteAllTextAsync( private static async Task WriteMediaSecretsKeyAsync( string foundryConfigPath, string connectConfigurationJson, + string deployConfigurationJson, byte[]? mediaSecretsKey, CancellationToken cancellationToken) { - bool hasEncryptedSecrets = HasEncryptedSecrets(connectConfigurationJson); + bool hasEncryptedSecrets = MediaSecretEnvelopeProtector.HasEncryptedSecrets(connectConfigurationJson) || + MediaSecretEnvelopeProtector.HasEncryptedSecrets(deployConfigurationJson); if (mediaSecretsKey is null || mediaSecretsKey.Length == 0) { if (hasEncryptedSecrets) { - throw new ArgumentException("Foundry Connect encrypted secrets require a media secret key."); + throw new ArgumentException("Foundry encrypted media secrets require a media secret key."); } return; @@ -150,7 +154,7 @@ private static async Task WriteMediaSecretsKeyAsync( if (!hasEncryptedSecrets) { - throw new ArgumentException("A media secret key must not be provisioned without encrypted Foundry Connect secrets."); + throw new ArgumentException("A media secret key must not be provisioned without encrypted Foundry media secrets."); } if (mediaSecretsKey.Length != 32) @@ -166,67 +170,6 @@ await File.WriteAllBytesAsync( cancellationToken).ConfigureAwait(false); } - private static bool HasEncryptedSecrets(string connectConfigurationJson) - { - using JsonDocument document = JsonDocument.Parse(connectConfigurationJson); - return HasEncryptedSecrets(document.RootElement); - } - - private static bool HasEncryptedSecrets(JsonElement element) - { - if (element.ValueKind == JsonValueKind.Object) - { - if (IsEncryptedSecretEnvelope(element)) - { - return true; - } - - foreach (JsonProperty property in element.EnumerateObject()) - { - if (HasEncryptedSecrets(property.Value)) - { - return true; - } - } - } - else if (element.ValueKind == JsonValueKind.Array) - { - foreach (JsonElement item in element.EnumerateArray()) - { - if (HasEncryptedSecrets(item)) - { - return true; - } - } - } - - return false; - } - - private static bool IsEncryptedSecretEnvelope(JsonElement element) - { - return HasStringProperty(element, "kind", "encrypted") && - HasStringProperty(element, "algorithm", "aes-gcm-v1") && - HasStringProperty(element, "keyId", "media") && - HasNonEmptyStringProperty(element, "nonce") && - HasNonEmptyStringProperty(element, "tag") && - HasNonEmptyStringProperty(element, "ciphertext"); - } - - private static bool HasStringProperty(JsonElement element, string propertyName, string expectedValue) - { - return element.TryGetProperty(propertyName, out JsonElement property) && - property.ValueKind == JsonValueKind.String && - string.Equals(property.GetString(), expectedValue, StringComparison.Ordinal); - } - - private static bool HasNonEmptyStringProperty(JsonElement element, string propertyName) - { - return element.TryGetProperty(propertyName, out JsonElement property) && - property.ValueKind == JsonValueKind.String && - !string.IsNullOrWhiteSpace(property.GetString()); - } - private static void CopyConnectAssetFiles( string mountedImagePath, IReadOnlyList assetFiles) diff --git a/src/Foundry.Deploy.Tests/DeployConfigurationModelTests.cs b/src/Foundry.Deploy.Tests/DeployConfigurationModelTests.cs index b9098cbd..40181de3 100644 --- a/src/Foundry.Deploy.Tests/DeployConfigurationModelTests.cs +++ b/src/Foundry.Deploy.Tests/DeployConfigurationModelTests.cs @@ -61,4 +61,67 @@ public void Deserialize_WhenTelemetryIsConfigured_PreservesTelemetrySettings() Assert.Equal("project-token", document.Telemetry.ProjectToken); Assert.Equal(TelemetryRuntimePayloadSources.Release, document.Telemetry.RuntimePayloadSource); } + + [Fact] + public void Deserialize_WhenAutopilotProvisioningModeIsMissing_DefaultsToJsonProfile() + { + const string json = """ + { + "schemaVersion": 2, + "autopilot": { + "isEnabled": true + } + } + """; + + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + FoundryDeployConfigurationDocument? document = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(document); + Assert.Equal(AutopilotProvisioningMode.JsonProfile, document.Autopilot.ProvisioningMode); + } + + [Fact] + public void Deserialize_WhenHardwareHashSettingsAreConfigured_PreservesRuntimeMetadata() + { + const string json = """ + { + "schemaVersion": 2, + "autopilot": { + "isEnabled": true, + "provisioningMode": "hardwareHashUpload", + "hardwareHashUpload": { + "tenantId": "tenant-id", + "clientId": "client-id", + "activeCertificateKeyId": "certificate-key-id", + "activeCertificateThumbprint": "ABCDEF123456", + "activeCertificateExpiresOnUtc": "2026-12-01T00:00:00+00:00", + "defaultGroupTag": "Sales" + } + } + } + """; + + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + FoundryDeployConfigurationDocument? document = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(document); + Assert.Equal(AutopilotProvisioningMode.HardwareHashUpload, document.Autopilot.ProvisioningMode); + Assert.Equal("tenant-id", document.Autopilot.HardwareHashUpload.TenantId); + Assert.Equal("client-id", document.Autopilot.HardwareHashUpload.ClientId); + Assert.Equal("certificate-key-id", document.Autopilot.HardwareHashUpload.ActiveCertificateKeyId); + Assert.Equal("ABCDEF123456", document.Autopilot.HardwareHashUpload.ActiveCertificateThumbprint); + Assert.Equal(DateTimeOffset.Parse("2026-12-01T00:00:00+00:00"), document.Autopilot.HardwareHashUpload.ActiveCertificateExpiresOnUtc); + Assert.Equal("Sales", document.Autopilot.HardwareHashUpload.DefaultGroupTag); + } } diff --git a/src/Foundry.Deploy.Tests/DeploymentLaunchPreparationServiceTests.cs b/src/Foundry.Deploy.Tests/DeploymentLaunchPreparationServiceTests.cs index 42e36e29..dfee4491 100644 --- a/src/Foundry.Deploy.Tests/DeploymentLaunchPreparationServiceTests.cs +++ b/src/Foundry.Deploy.Tests/DeploymentLaunchPreparationServiceTests.cs @@ -119,6 +119,62 @@ public void Prepare_WhenRequestIsValidAndConfirmed_ReturnsDeploymentContext() Assert.Same(aiComponentRemoval, result.Context?.AiComponentRemoval); } + [Fact] + public void Prepare_WhenHardwareHashUploadModeHasNoJsonProfile_ReturnsDeploymentContext() + { + var shell = new FakeApplicationShellService { ConfirmationResult = true }; + var service = new DeploymentLaunchPreparationService(shell); + + DeploymentLaunchPreparationResult result = service.Prepare( + CreateRequest( + selectedTargetDisk: CreateDisk(), + isAutopilotEnabled: true, + autopilotProvisioningMode: AutopilotProvisioningMode.HardwareHashUpload, + selectedAutopilotProfile: null, + isDryRun: true)); + + Assert.True(result.IsReadyToStart); + Assert.Equal(AutopilotProvisioningMode.HardwareHashUpload, result.Context?.AutopilotProvisioningMode); + Assert.Null(result.Context?.SelectedAutopilotProfile); + } + + [Fact] + public void Prepare_WhenLiveHardwareHashUploadModeIsSelected_FailsBeforeConfirmation() + { + var shell = new FakeApplicationShellService(); + var service = new DeploymentLaunchPreparationService(shell); + + DeploymentLaunchPreparationResult result = service.Prepare( + CreateRequest( + selectedTargetDisk: CreateDisk(), + isAutopilotEnabled: true, + autopilotProvisioningMode: AutopilotProvisioningMode.HardwareHashUpload, + selectedAutopilotProfile: null, + isDryRun: false)); + + Assert.False(result.IsReadyToStart); + Assert.Contains("hardware hash upload is not available", result.StatusMessage, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, shell.ConfirmationCallCount); + } + + [Fact] + public void Prepare_WhenJsonProfileModeHasNoProfile_FailsValidation() + { + var shell = new FakeApplicationShellService(); + var service = new DeploymentLaunchPreparationService(shell); + + DeploymentLaunchPreparationResult result = service.Prepare( + CreateRequest( + selectedTargetDisk: CreateDisk(), + isAutopilotEnabled: true, + autopilotProvisioningMode: AutopilotProvisioningMode.JsonProfile, + selectedAutopilotProfile: null)); + + Assert.False(result.IsReadyToStart); + Assert.Equal("Select an Autopilot profile or disable Autopilot before starting deployment.", result.StatusMessage); + Assert.Equal(0, shell.ConfirmationCallCount); + } + [Fact] public void Prepare_WhenConfirmationIsShown_UsesLocalizedWarningText() { @@ -156,6 +212,7 @@ private static DeploymentLaunchRequest CreateRequest( DriverPackSelectionKind driverPackSelectionKind = DriverPackSelectionKind.None, DriverPackCatalogItem? selectedDriverPack = null, bool isAutopilotEnabled = false, + AutopilotProvisioningMode autopilotProvisioningMode = AutopilotProvisioningMode.JsonProfile, AutopilotProfileCatalogItem? selectedAutopilotProfile = null, DeployOobeSettings? oobe = null, DeployAppxRemovalSettings? appxRemoval = null, @@ -184,6 +241,7 @@ private static DeploymentLaunchRequest CreateRequest( SelectedDriverPack = selectedDriverPack, ApplyFirmwareUpdates = false, IsAutopilotEnabled = isAutopilotEnabled, + AutopilotProvisioningMode = autopilotProvisioningMode, SelectedAutopilotProfile = selectedAutopilotProfile, Oobe = oobe ?? new DeployOobeSettings(), AppxRemoval = appxRemoval ?? new DeployAppxRemovalSettings(), diff --git a/src/Foundry.Deploy.Tests/DeploymentPreparationViewModelTests.cs b/src/Foundry.Deploy.Tests/DeploymentPreparationViewModelTests.cs index c4bedd3b..fd43440b 100644 --- a/src/Foundry.Deploy.Tests/DeploymentPreparationViewModelTests.cs +++ b/src/Foundry.Deploy.Tests/DeploymentPreparationViewModelTests.cs @@ -2,6 +2,7 @@ using Foundry.Deploy.Models.Configuration; using Foundry.Deploy.Services.Hardware; using Foundry.Deploy.Services.Localization; +using Foundry.Deploy.Services.Runtime; using Foundry.Deploy.Services.System; using Foundry.Deploy.ViewModels; using Microsoft.Extensions.Logging.Abstractions; @@ -72,6 +73,77 @@ public void ApplyAutopilotConfiguration_WhenEnabledProfileIsMissing_KeepsAutopil Assert.NotEqual(string.Empty, viewModel.AutopilotProfileHint); } + [Fact] + public void ApplyAutopilotConfiguration_WhenHardwareHashModeIsEnabled_DoesNotRequireJsonProfile() + { + using DeploymentPreparationViewModel viewModel = CreateViewModel(); + AutopilotProfileCatalogItem profile = CreateProfile("json", "JSON Profile"); + DeployAutopilotHardwareHashUploadSettings hardwareHashUpload = new() + { + TenantId = "tenant-id", + ClientId = "client-id", + ActiveCertificateThumbprint = "ABCDEF123456", + ActiveCertificateExpiresOnUtc = DateTimeOffset.UtcNow.AddMonths(1), + DefaultGroupTag = "Sales" + }; + + viewModel.ApplyAutopilotConfiguration( + new DeployAutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload, + DefaultProfileFolderName = "json", + HardwareHashUpload = hardwareHashUpload + }, + [profile]); + + Assert.True(viewModel.IsAutopilotEnabled); + Assert.Equal(AutopilotProvisioningMode.HardwareHashUpload, viewModel.AutopilotProvisioningMode); + Assert.True(viewModel.IsHardwareHashUploadControlsVisible); + Assert.False(viewModel.IsJsonProfileControlsVisible); + Assert.False(viewModel.IsAutopilotProfileSelectionEnabled); + Assert.Null(viewModel.SelectedAutopilotProfile); + Assert.Same(hardwareHashUpload, viewModel.AutopilotHardwareHashUpload); + Assert.Contains("Sales", viewModel.AutopilotHardwareHashStatusText); + } + + [Fact] + public void ApplyAutopilotConfiguration_WhenHardwareHashCertificateExpired_SurfacesExpiredState() + { + using DeploymentPreparationViewModel viewModel = CreateViewModel(); + + viewModel.ApplyAutopilotConfiguration( + new DeployAutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload, + HardwareHashUpload = new DeployAutopilotHardwareHashUploadSettings + { + ActiveCertificateExpiresOnUtc = DateTimeOffset.UtcNow.AddDays(-1) + } + }, + []); + + Assert.True(viewModel.IsHardwareHashCertificateExpired); + Assert.NotEqual(string.Empty, viewModel.AutopilotHardwareHashStatusText); + } + + [Theory] + [InlineData(DebugAutopilotMode.None, false, AutopilotProvisioningMode.JsonProfile)] + [InlineData(DebugAutopilotMode.JsonProfile, true, AutopilotProvisioningMode.JsonProfile)] + [InlineData(DebugAutopilotMode.HardwareHashUpload, true, AutopilotProvisioningMode.HardwareHashUpload)] + public void ApplyDebugAutopilotMode_OverridesAutopilotState(DebugAutopilotMode mode, bool expectedEnabled, AutopilotProvisioningMode expectedMode) + { + using DeploymentPreparationViewModel viewModel = CreateViewModel(); + + viewModel.ApplyDebugAutopilotMode(mode); + + Assert.Equal(expectedEnabled, viewModel.IsAutopilotEnabled); + Assert.Equal(expectedMode, viewModel.AutopilotProvisioningMode); + Assert.Equal(mode == DebugAutopilotMode.JsonProfile, viewModel.SelectedAutopilotProfile is not null); + Assert.Equal(mode == DebugAutopilotMode.HardwareHashUpload, viewModel.IsHardwareHashUploadControlsVisible); + } + [Fact] public void IsAutopilotProfileSelectionEnabled_FollowsAutopilotToggle() { diff --git a/src/Foundry.Deploy.Tests/StageAutopilotConfigurationStepTests.cs b/src/Foundry.Deploy.Tests/StageAutopilotConfigurationStepTests.cs new file mode 100644 index 00000000..f6e03937 --- /dev/null +++ b/src/Foundry.Deploy.Tests/StageAutopilotConfigurationStepTests.cs @@ -0,0 +1,176 @@ +using Foundry.Deploy.Models; +using Foundry.Deploy.Models.Configuration; +using Foundry.Deploy.Services.Deployment; +using Foundry.Deploy.Services.Deployment.Steps; +using Foundry.Deploy.Services.Hardware; +using Foundry.Deploy.Services.Logging; +using Foundry.Deploy.Services.Operations; + +namespace Foundry.Deploy.Tests; + +public sealed class StageAutopilotConfigurationStepTests +{ + [Fact] + public async Task ExecuteAsync_WhenLiveHardwareHashModeBypassesLaunchGuard_FailsClearly() + { + using TempDeploymentWorkspace workspace = TempDeploymentWorkspace.Create(); + StageAutopilotConfigurationStep step = new(); + DeploymentStepExecutionContext context = CreateContext( + workspace, + isDryRun: false, + provisioningMode: AutopilotProvisioningMode.HardwareHashUpload); + + DeploymentStepResult result = await step.ExecuteAsync(context, CancellationToken.None); + + Assert.Equal(DeploymentStepState.Failed, result.State); + Assert.Contains("hardware hash upload is not available", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_WhenDryRunHardwareHashMode_WritesHashManifest() + { + using TempDeploymentWorkspace workspace = TempDeploymentWorkspace.Create(); + StageAutopilotConfigurationStep step = new(); + DeploymentStepExecutionContext context = CreateContext( + workspace, + isDryRun: true, + provisioningMode: AutopilotProvisioningMode.HardwareHashUpload, + hardwareHashUpload: new DeployAutopilotHardwareHashUploadSettings + { + TenantId = "tenant-id", + ClientId = "client-id", + ActiveCertificateThumbprint = "ABCDEF123456", + ActiveCertificateExpiresOnUtc = DateTimeOffset.UtcNow.AddMonths(1), + DefaultGroupTag = "Sales" + }); + + DeploymentStepResult result = await step.ExecuteAsync(context, CancellationToken.None); + + string manifestPath = Path.Combine(workspace.TargetFoundryRootPath, "Autopilot", "autopilot-hash-upload.dryrun.json"); + Assert.Equal(DeploymentStepState.Succeeded, result.State); + Assert.True(File.Exists(manifestPath)); + Assert.Equal(manifestPath, context.RuntimeState.StagedAutopilotConfigurationPath); + Assert.Contains("hardwareHashUpload", await File.ReadAllTextAsync(manifestPath)); + } + + private static DeploymentStepExecutionContext CreateContext( + TempDeploymentWorkspace workspace, + bool isDryRun, + AutopilotProvisioningMode provisioningMode, + DeployAutopilotHardwareHashUploadSettings? hardwareHashUpload = null) + { + DeploymentContext request = new() + { + Mode = DeploymentMode.Iso, + IsDryRun = isDryRun, + CacheRootPath = workspace.RootPath, + TargetDiskNumber = 1, + TargetComputerName = "LAB01", + OperatingSystem = new OperatingSystemCatalogItem(), + DriverPackSelectionKind = DriverPackSelectionKind.None, + IsAutopilotEnabled = true, + AutopilotProvisioningMode = provisioningMode, + AutopilotHardwareHashUpload = hardwareHashUpload ?? new DeployAutopilotHardwareHashUploadSettings() + }; + DeploymentRuntimeState runtimeState = new() + { + WorkspaceRoot = workspace.RootPath, + TargetFoundryRoot = workspace.TargetFoundryRootPath + }; + + return new DeploymentStepExecutionContext( + request, + runtimeState, + [DeploymentStepNames.StageAutopilotConfiguration], + new FakeOperationProgressService(), + new FakeDeploymentLogService(), + new FakeTargetDiskService(), + _ => { }); + } + + private sealed class FakeDeploymentLogService : IDeploymentLogService + { + public DeploymentLogSession Initialize(string rootPath) + { + string logsDirectory = Path.Combine(rootPath, "Logs"); + string stateDirectory = Path.Combine(rootPath, "State"); + Directory.CreateDirectory(logsDirectory); + Directory.CreateDirectory(stateDirectory); + return new DeploymentLogSession + { + RootPath = rootPath, + LogsDirectoryPath = logsDirectory, + StateDirectoryPath = stateDirectory, + LogFilePath = Path.Combine(logsDirectory, "FoundryDeploy.log"), + StateFilePath = Path.Combine(stateDirectory, "deployment-state.json") + }; + } + + public Task AppendAsync(DeploymentLogSession session, DeploymentLogLevel level, string message, CancellationToken cancellationToken = default) + { + return File.AppendAllTextAsync(session.LogFilePath, $"{level}: {message}{Environment.NewLine}", cancellationToken); + } + + public Task SaveStateAsync(DeploymentLogSession session, TState state, CancellationToken cancellationToken = default) + { + return File.WriteAllTextAsync(session.StateFilePath, "{}", cancellationToken); + } + + public void Release(DeploymentLogSession session) + { + } + } + + private sealed class FakeOperationProgressService : IOperationProgressService + { + public bool IsOperationInProgress => false; + public int Progress => 0; + public string? Status => null; + public OperationKind? CurrentOperation => null; + public bool CanStartOperation => true; + public event EventHandler? ProgressChanged; + public bool TryStart(OperationKind kind, string initialStatus, int initialProgress = 0) => true; + public void Report(int progress, string? status = null) => ProgressChanged?.Invoke(this, EventArgs.Empty); + public void Complete(string? status = null) => ProgressChanged?.Invoke(this, EventArgs.Empty); + public void Fail(string status) => ProgressChanged?.Invoke(this, EventArgs.Empty); + public void ResetToIdle() => ProgressChanged?.Invoke(this, EventArgs.Empty); + } + + private sealed class FakeTargetDiskService : ITargetDiskService + { + public Task> GetDisksAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult>([]); + } + + public Task GetDiskNumberForPathAsync(string path, CancellationToken cancellationToken = default) + { + return Task.FromResult(null); + } + } + + private sealed class TempDeploymentWorkspace : IDisposable + { + private TempDeploymentWorkspace(string rootPath) + { + RootPath = rootPath; + TargetFoundryRootPath = Path.Combine(rootPath, "TargetFoundry"); + Directory.CreateDirectory(TargetFoundryRootPath); + } + + public string RootPath { get; } + public string TargetFoundryRootPath { get; } + + public static TempDeploymentWorkspace Create() + { + string rootPath = Path.Combine(Path.GetTempPath(), $"foundry-autopilot-step-{Guid.NewGuid():N}"); + Directory.CreateDirectory(rootPath); + return new TempDeploymentWorkspace(rootPath); + } + + public void Dispose() + { + Directory.Delete(RootPath, recursive: true); + } + } +} diff --git a/src/Foundry.Deploy/MainWindow.xaml b/src/Foundry.Deploy/MainWindow.xaml index 61a02577..df6edad4 100644 --- a/src/Foundry.Deploy/MainWindow.xaml +++ b/src/Foundry.Deploy/MainWindow.xaml @@ -5,6 +5,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="clr-namespace:Foundry.Deploy" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:runtime="clr-namespace:Foundry.Deploy.Services.Runtime" xmlns:views="clr-namespace:Foundry.Deploy.Views" Title="{Binding WindowTitle}" Icon="/Assets/Icons/app.ico" @@ -71,6 +72,27 @@ Header="{Binding Strings[Tools.RefreshTargetDisks]}" IsEnabled="{Binding Session.IsStartupReady}" /> + + + + + + diff --git a/src/Foundry.Deploy/Models/Configuration/AutopilotProvisioningMode.cs b/src/Foundry.Deploy/Models/Configuration/AutopilotProvisioningMode.cs new file mode 100644 index 00000000..421da6f6 --- /dev/null +++ b/src/Foundry.Deploy/Models/Configuration/AutopilotProvisioningMode.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Foundry.Deploy.Models.Configuration; + +/// +/// Defines how Foundry.Deploy should provision Autopilot data during OS deployment. +/// +[JsonConverter(typeof(AutopilotProvisioningModeJsonConverter))] +public enum AutopilotProvisioningMode +{ + /// + /// Stages an offline Autopilot profile JSON file into the applied Windows image. + /// + JsonProfile, + + /// + /// Captures and uploads the device hardware hash from WinPE. + /// + HardwareHashUpload +} diff --git a/src/Foundry.Deploy/Models/Configuration/AutopilotProvisioningModeJsonConverter.cs b/src/Foundry.Deploy/Models/Configuration/AutopilotProvisioningModeJsonConverter.cs new file mode 100644 index 00000000..df00bfe9 --- /dev/null +++ b/src/Foundry.Deploy/Models/Configuration/AutopilotProvisioningModeJsonConverter.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Foundry.Deploy.Models.Configuration; + +/// +/// Reads and writes Autopilot provisioning mode strings without changing unrelated Deploy enum serialization. +/// +public sealed class AutopilotProvisioningModeJsonConverter : JsonConverter +{ + /// + public override AutopilotProvisioningMode Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out int numericValue)) + { + return Enum.IsDefined(typeof(AutopilotProvisioningMode), numericValue) + ? (AutopilotProvisioningMode)numericValue + : throw new JsonException($"Unsupported Autopilot provisioning mode value '{numericValue}'."); + } + + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("Autopilot provisioning mode must be a string or numeric enum value."); + } + + return reader.GetString() switch + { + "jsonProfile" or "JsonProfile" => AutopilotProvisioningMode.JsonProfile, + "hardwareHashUpload" or "HardwareHashUpload" => AutopilotProvisioningMode.HardwareHashUpload, + string value => throw new JsonException($"Unsupported Autopilot provisioning mode value '{value}'."), + null => throw new JsonException("Autopilot provisioning mode cannot be null.") + }; + } + + /// + public override void Write( + Utf8JsonWriter writer, + AutopilotProvisioningMode value, + JsonSerializerOptions options) + { + writer.WriteStringValue(value switch + { + AutopilotProvisioningMode.JsonProfile => "jsonProfile", + AutopilotProvisioningMode.HardwareHashUpload => "hardwareHashUpload", + _ => throw new JsonException($"Unsupported Autopilot provisioning mode value '{value}'.") + }); + } +} diff --git a/src/Foundry.Deploy/Models/Configuration/DeployAutopilotHardwareHashUploadSettings.cs b/src/Foundry.Deploy/Models/Configuration/DeployAutopilotHardwareHashUploadSettings.cs new file mode 100644 index 00000000..ab16af7c --- /dev/null +++ b/src/Foundry.Deploy/Models/Configuration/DeployAutopilotHardwareHashUploadSettings.cs @@ -0,0 +1,37 @@ +namespace Foundry.Deploy.Models.Configuration; + +/// +/// Carries non-secret tenant and certificate metadata needed by the WinPE hardware hash upload workflow. +/// +public sealed record DeployAutopilotHardwareHashUploadSettings +{ + /// + /// Gets the Microsoft Entra tenant ID used for Graph authentication. + /// + public string? TenantId { get; init; } + + /// + /// Gets the application client ID used for certificate-based Graph authentication. + /// + public string? ClientId { get; init; } + + /// + /// Gets the selected app registration certificate credential identifier for diagnostics. + /// + public string? ActiveCertificateKeyId { get; init; } + + /// + /// Gets the selected certificate thumbprint expected from the encrypted media certificate. + /// + public string? ActiveCertificateThumbprint { get; init; } + + /// + /// Gets the selected certificate expiration time used to skip expired media without blocking OS deployment. + /// + public DateTimeOffset? ActiveCertificateExpiresOnUtc { get; init; } + + /// + /// Gets the default group tag offered by Foundry.Deploy for hardware hash upload. + /// + public string? DefaultGroupTag { get; init; } +} diff --git a/src/Foundry.Deploy/Models/Configuration/DeployAutopilotSettings.cs b/src/Foundry.Deploy/Models/Configuration/DeployAutopilotSettings.cs index 54816f15..ae949769 100644 --- a/src/Foundry.Deploy/Models/Configuration/DeployAutopilotSettings.cs +++ b/src/Foundry.Deploy/Models/Configuration/DeployAutopilotSettings.cs @@ -1,7 +1,27 @@ namespace Foundry.Deploy.Models.Configuration; +/// +/// Describes the reduced Autopilot runtime settings consumed by Foundry.Deploy. +/// public sealed record DeployAutopilotSettings { + /// + /// Gets whether Autopilot provisioning is enabled. + /// public bool IsEnabled { get; init; } + + /// + /// Gets the provisioning method selected by Foundry OSD. + /// + public AutopilotProvisioningMode ProvisioningMode { get; init; } = AutopilotProvisioningMode.JsonProfile; + + /// + /// Gets the profile folder name selected for JSON profile staging. + /// public string? DefaultProfileFolderName { get; init; } + + /// + /// Gets runtime metadata required for hardware hash upload mode. + /// + public DeployAutopilotHardwareHashUploadSettings HardwareHashUpload { get; init; } = new(); } diff --git a/src/Foundry.Deploy/Models/Configuration/FoundryDeployConfigurationDocument.cs b/src/Foundry.Deploy/Models/Configuration/FoundryDeployConfigurationDocument.cs index f93e25bf..38653a80 100644 --- a/src/Foundry.Deploy/Models/Configuration/FoundryDeployConfigurationDocument.cs +++ b/src/Foundry.Deploy/Models/Configuration/FoundryDeployConfigurationDocument.cs @@ -10,7 +10,7 @@ public sealed record FoundryDeployConfigurationDocument /// /// Gets the current configuration schema version. /// - public const int CurrentSchemaVersion = 5; + public const int CurrentSchemaVersion = 6; /// /// Gets the schema version of this configuration. @@ -28,7 +28,7 @@ public sealed record FoundryDeployConfigurationDocument public DeployCustomizationSettings Customization { get; init; } = new(); /// - /// Gets Autopilot profile staging settings. + /// Gets Autopilot provisioning settings. /// public DeployAutopilotSettings Autopilot { get; init; } = new(); diff --git a/src/Foundry.Deploy/Services/Deployment/DeploymentContext.cs b/src/Foundry.Deploy/Services/Deployment/DeploymentContext.cs index 0e3b63f8..1f6169c4 100644 --- a/src/Foundry.Deploy/Services/Deployment/DeploymentContext.cs +++ b/src/Foundry.Deploy/Services/Deployment/DeploymentContext.cs @@ -58,11 +58,21 @@ public sealed record DeploymentContext /// public bool IsAutopilotEnabled { get; init; } + /// + /// Gets the selected Autopilot provisioning mode. + /// + public AutopilotProvisioningMode AutopilotProvisioningMode { get; init; } = AutopilotProvisioningMode.JsonProfile; + /// /// Gets the selected Autopilot profile staged into the offline Windows image. /// public AutopilotProfileCatalogItem? SelectedAutopilotProfile { get; init; } + /// + /// Gets non-secret metadata for hardware hash upload mode. + /// + public DeployAutopilotHardwareHashUploadSettings AutopilotHardwareHashUpload { get; init; } = new(); + /// /// Gets Windows OOBE customization settings applied to the offline installation. /// diff --git a/src/Foundry.Deploy/Services/Deployment/DeploymentLaunchPreparationService.cs b/src/Foundry.Deploy/Services/Deployment/DeploymentLaunchPreparationService.cs index bc9e65a8..b48103a9 100644 --- a/src/Foundry.Deploy/Services/Deployment/DeploymentLaunchPreparationService.cs +++ b/src/Foundry.Deploy/Services/Deployment/DeploymentLaunchPreparationService.cs @@ -1,4 +1,5 @@ using Foundry.Deploy.Models; +using Foundry.Deploy.Models.Configuration; using Foundry.Deploy.Services.ApplicationShell; using Foundry.Deploy.Services.Localization; using Foundry.Deploy.Validation; @@ -65,13 +66,24 @@ public DeploymentLaunchPreparationResult Prepare(DeploymentLaunchRequest request normalizedComputerName); } - if (request.IsAutopilotEnabled && request.SelectedAutopilotProfile is null) + if (request.IsAutopilotEnabled && + request.AutopilotProvisioningMode == AutopilotProvisioningMode.JsonProfile && + request.SelectedAutopilotProfile is null) { return DeploymentLaunchPreparationResult.Failure( "Select an Autopilot profile or disable Autopilot before starting deployment.", normalizedComputerName); } + if (request.IsAutopilotEnabled && + request.AutopilotProvisioningMode == AutopilotProvisioningMode.HardwareHashUpload && + !request.IsDryRun) + { + return DeploymentLaunchPreparationResult.Failure( + "Autopilot hardware hash upload is not available until the deployment runtime phase is implemented.", + normalizedComputerName); + } + if (!request.IsDryRun && !ConfirmDestructiveDeployment(effectiveTargetDisk, request.SelectedOperatingSystem)) { return DeploymentLaunchPreparationResult.Failure("Deployment cancelled by user.", normalizedComputerName); @@ -89,7 +101,9 @@ public DeploymentLaunchPreparationResult Prepare(DeploymentLaunchRequest request DriverPack = request.SelectedDriverPack, ApplyFirmwareUpdates = request.ApplyFirmwareUpdates, IsAutopilotEnabled = request.IsAutopilotEnabled, + AutopilotProvisioningMode = request.AutopilotProvisioningMode, SelectedAutopilotProfile = request.SelectedAutopilotProfile, + AutopilotHardwareHashUpload = request.AutopilotHardwareHashUpload, Oobe = request.Oobe, AppxRemoval = request.AppxRemoval, AiComponentRemoval = request.AiComponentRemoval, diff --git a/src/Foundry.Deploy/Services/Deployment/DeploymentLaunchRequest.cs b/src/Foundry.Deploy/Services/Deployment/DeploymentLaunchRequest.cs index fe62dca0..956eaed5 100644 --- a/src/Foundry.Deploy/Services/Deployment/DeploymentLaunchRequest.cs +++ b/src/Foundry.Deploy/Services/Deployment/DeploymentLaunchRequest.cs @@ -15,7 +15,9 @@ public sealed record DeploymentLaunchRequest public required DriverPackCatalogItem? SelectedDriverPack { get; init; } public required bool ApplyFirmwareUpdates { get; init; } public required bool IsAutopilotEnabled { get; init; } + public required AutopilotProvisioningMode AutopilotProvisioningMode { get; init; } public required AutopilotProfileCatalogItem? SelectedAutopilotProfile { get; init; } + public DeployAutopilotHardwareHashUploadSettings AutopilotHardwareHashUpload { get; init; } = new(); public DeployOobeSettings Oobe { get; init; } = new(); public DeployAppxRemovalSettings AppxRemoval { get; init; } = new(); public DeployAiComponentRemovalSettings AiComponentRemoval { get; init; } = new(); diff --git a/src/Foundry.Deploy/Services/Deployment/DeploymentRuntimeState.cs b/src/Foundry.Deploy/Services/Deployment/DeploymentRuntimeState.cs index 44edec5d..c1e62087 100644 --- a/src/Foundry.Deploy/Services/Deployment/DeploymentRuntimeState.cs +++ b/src/Foundry.Deploy/Services/Deployment/DeploymentRuntimeState.cs @@ -196,7 +196,7 @@ public sealed record DeploymentRuntimeState public string? FirmwareUpdateTitle { get; set; } /// - /// Gets or sets a value indicating whether Autopilot staging is enabled. + /// Gets or sets a value indicating whether Autopilot provisioning is enabled. /// public bool IsAutopilotEnabled { get; set; } diff --git a/src/Foundry.Deploy/Services/Deployment/Steps/StageAutopilotConfigurationStep.cs b/src/Foundry.Deploy/Services/Deployment/Steps/StageAutopilotConfigurationStep.cs index ef14ed53..d81c7229 100644 --- a/src/Foundry.Deploy/Services/Deployment/Steps/StageAutopilotConfigurationStep.cs +++ b/src/Foundry.Deploy/Services/Deployment/Steps/StageAutopilotConfigurationStep.cs @@ -1,5 +1,6 @@ using System.IO; using System.Text.Json; +using Foundry.Deploy.Models.Configuration; using Foundry.Deploy.Services.Logging; namespace Foundry.Deploy.Services.Deployment.Steps; @@ -19,6 +20,11 @@ protected override async Task ExecuteLiveAsync(DeploymentS return DeploymentStepResult.Skipped("Autopilot is disabled."); } + if (context.Request.AutopilotProvisioningMode == AutopilotProvisioningMode.HardwareHashUpload) + { + return DeploymentStepResult.Failed("Autopilot hardware hash upload is not available until the deployment runtime phase is implemented."); + } + if (context.Request.SelectedAutopilotProfile is null) { return DeploymentStepResult.Failed("Autopilot is enabled but no profile was selected."); @@ -66,6 +72,40 @@ protected override async Task ExecuteDryRunAsync(Deploymen return DeploymentStepResult.Skipped("Autopilot is disabled."); } + if (context.Request.AutopilotProvisioningMode == AutopilotProvisioningMode.HardwareHashUpload) + { + string hashUploadTargetFoundryRoot = context.EnsureTargetFoundryRoot(); + string hashUploadAutopilotRoot = Path.Combine(hashUploadTargetFoundryRoot, "Autopilot"); + Directory.CreateDirectory(hashUploadAutopilotRoot); + + string hashUploadManifestPath = Path.Combine(hashUploadAutopilotRoot, "autopilot-hash-upload.dryrun.json"); + string hashUploadManifest = JsonSerializer.Serialize(new + { + createdAtUtc = DateTimeOffset.UtcNow, + mode = "dry-run", + provisioningMode = "hardwareHashUpload", + tenantId = context.Request.AutopilotHardwareHashUpload.TenantId, + clientId = context.Request.AutopilotHardwareHashUpload.ClientId, + activeCertificateThumbprint = context.Request.AutopilotHardwareHashUpload.ActiveCertificateThumbprint, + activeCertificateExpiresOnUtc = context.Request.AutopilotHardwareHashUpload.ActiveCertificateExpiresOnUtc, + defaultGroupTag = context.Request.AutopilotHardwareHashUpload.DefaultGroupTag + }, new JsonSerializerOptions + { + WriteIndented = true + }); + + context.EmitCurrentStepIndeterminate("Preparing Autopilot hardware hash upload...", "Writing dry-run Autopilot hash manifest..."); + await File.WriteAllTextAsync(hashUploadManifestPath, hashUploadManifest, cancellationToken).ConfigureAwait(false); + context.RuntimeState.StagedAutopilotConfigurationPath = hashUploadManifestPath; + + await context.AppendLogAsync( + DeploymentLogLevel.Info, + $"[DRY-RUN] Autopilot hardware hash upload simulated: {hashUploadManifestPath}", + cancellationToken).ConfigureAwait(false); + + return DeploymentStepResult.Succeeded("Autopilot hardware hash upload prepared (simulation)."); + } + if (context.Request.SelectedAutopilotProfile is null) { return DeploymentStepResult.Failed("Autopilot is enabled but no profile was selected."); diff --git a/src/Foundry.Deploy/Services/Runtime/DebugAutopilotMode.cs b/src/Foundry.Deploy/Services/Runtime/DebugAutopilotMode.cs new file mode 100644 index 00000000..7bd0f5d8 --- /dev/null +++ b/src/Foundry.Deploy/Services/Runtime/DebugAutopilotMode.cs @@ -0,0 +1,22 @@ +namespace Foundry.Deploy.Services.Runtime; + +/// +/// Selects the in-memory Autopilot mode used by Foundry.Deploy debug safe mode. +/// +public enum DebugAutopilotMode +{ + /// + /// Disables Autopilot during the debug deployment run. + /// + None, + + /// + /// Simulates JSON profile provisioning during the debug deployment run. + /// + JsonProfile, + + /// + /// Simulates hardware hash upload provisioning during the debug deployment run. + /// + HardwareHashUpload +} diff --git a/src/Foundry.Deploy/Strings/en-US/Resources.resx b/src/Foundry.Deploy/Strings/en-US/Resources.resx index 99c7ea91..b8a21dbc 100644 --- a/src/Foundry.Deploy/Strings/en-US/Resources.resx +++ b/src/Foundry.Deploy/Strings/en-US/Resources.resx @@ -30,6 +30,10 @@ Refresh Catalogs Refresh Target Disks Open Log File + Debug Autopilot Mode + No Autopilot + JSON profile + Hardware hash upload DEBUG SAFE MODE ACTIVE: all deployment actions are simulated; no disk/image write is performed. 1. Target 2. Operating System Catalog @@ -49,6 +53,13 @@ Autopilot Profile Profiles available: {0} Autopilot is enabled, but no embedded profiles were found. + Provisioning method: JSON profile + Provisioning method: Hardware hash upload + Hardware hash upload + Certificate valid until {0:g}. Default group tag: {1}. + The embedded certificate expired on {0:g}. + Regenerate the certificate and boot image. Deployment can continue, but Autopilot hash upload will be skipped. + Hardware hash upload metadata is not configured in this boot media. Detecting hardware... Hardware detection failed. Hardware detection failed: {0} @@ -96,6 +107,7 @@ Selected Driver Pack Firmware Autopilot + Autopilot Method Autopilot Profile Target System diff --git a/src/Foundry.Deploy/Strings/fr-FR/Resources.resx b/src/Foundry.Deploy/Strings/fr-FR/Resources.resx index 39f6c1c0..5e6a280d 100644 --- a/src/Foundry.Deploy/Strings/fr-FR/Resources.resx +++ b/src/Foundry.Deploy/Strings/fr-FR/Resources.resx @@ -30,6 +30,10 @@ Actualiser les catalogues Actualiser les disques cibles Ouvrir le fichier journal + Mode Autopilot debug + Pas d'Autopilot + Profil JSON + Upload du hardware hash MODE DEBUG SÉCURISÉ ACTIF : toutes les actions de déploiement sont simulées, aucune écriture disque/image n’est effectuée. 1. Cible 2. Catalogue système @@ -49,6 +53,13 @@ Profil Autopilot Profils disponibles : {0} Autopilot est activé, mais aucun profil intégré n'a été trouvé. + Méthode de provisionnement : profil JSON + Méthode de provisionnement : upload du hardware hash + Upload du hardware hash + Certificat valide jusqu'au {0:g}. Group tag par défaut : {1}. + Le certificat intégré a expiré le {0:g}. + Régénérez le certificat et l'image de boot. Le déploiement peut continuer, mais l'upload du hash Autopilot sera ignoré. + Les métadonnées d'upload du hardware hash ne sont pas configurées dans ce média de boot. Détection du matériel... Échec de la détection matérielle. Échec de la détection matérielle : {0} @@ -96,6 +107,7 @@ Pack de pilotes sélectionné Firmware Autopilot + Méthode Autopilot Profil Autopilot Cible Système diff --git a/src/Foundry.Deploy/ViewModels/DeploymentPreparationViewModel.cs b/src/Foundry.Deploy/ViewModels/DeploymentPreparationViewModel.cs index 6b9ed6cd..bef32beb 100644 --- a/src/Foundry.Deploy/ViewModels/DeploymentPreparationViewModel.cs +++ b/src/Foundry.Deploy/ViewModels/DeploymentPreparationViewModel.cs @@ -5,6 +5,7 @@ using Foundry.Deploy.Models.Configuration; using Foundry.Deploy.Services.Hardware; using Foundry.Deploy.Services.Localization; +using Foundry.Deploy.Services.Runtime; using Foundry.Deploy.Services.System; using Foundry.Deploy.Validation; using Microsoft.Extensions.Logging; @@ -68,6 +69,12 @@ public DeploymentPreparationViewModel( [ObservableProperty] private bool isAutopilotEnabled; + [ObservableProperty] + private AutopilotProvisioningMode autopilotProvisioningMode = AutopilotProvisioningMode.JsonProfile; + + [ObservableProperty] + private DeployAutopilotHardwareHashUploadSettings autopilotHardwareHashUpload = new(); + [ObservableProperty] private AutopilotProfileCatalogItem? selectedAutopilotProfile; @@ -84,16 +91,29 @@ public DeploymentPreparationViewModel( public bool IsFirmwareUpdatesOptionEnabled => _detectedHardware?.IsVirtualMachine != true; public bool HasAutopilotProfiles => AutopilotProfiles.Count > 0; public bool IsAutopilotSectionVisible => IsAutopilotEnabled || HasAutopilotProfiles; - public bool IsAutopilotProfileSelectionEnabled => IsAutopilotEnabled && HasAutopilotProfiles; + public bool IsJsonProfileMode => AutopilotProvisioningMode == AutopilotProvisioningMode.JsonProfile; + public bool IsHardwareHashUploadMode => AutopilotProvisioningMode == AutopilotProvisioningMode.HardwareHashUpload; + public bool IsJsonProfileControlsVisible => IsAutopilotEnabled && IsJsonProfileMode; + public bool IsHardwareHashUploadControlsVisible => IsAutopilotEnabled && IsHardwareHashUploadMode; + public bool IsAutopilotProfileSelectionEnabled => IsJsonProfileControlsVisible && HasAutopilotProfiles; + public bool IsHardwareHashCertificateExpired => + AutopilotHardwareHashUpload.ActiveCertificateExpiresOnUtc is DateTimeOffset expiresOn && + expiresOn <= DateTimeOffset.UtcNow; public string TargetDiskSelectionHint => !string.IsNullOrWhiteSpace(SelectedTargetDisk?.SelectionWarning) ? SelectedTargetDisk.SelectionWarning : GetString("Preparation.TargetDiskHint"); public string AutopilotProfileHint => - HasAutopilotProfiles + !IsJsonProfileMode + ? string.Empty + : HasAutopilotProfiles ? Format("Preparation.AutopilotProfilesAvailableFormat", AutopilotProfiles.Count) : IsAutopilotEnabled ? GetString("Preparation.AutopilotProfilesMissing") : string.Empty; + public string AutopilotModeText => IsHardwareHashUploadMode + ? GetString("Preparation.AutopilotModeHardwareHashUpload") + : GetString("Preparation.AutopilotModeJsonProfile"); + public string AutopilotHardwareHashStatusText => CreateHardwareHashStatusText(); public bool HasTargetComputerNameValidationError => !string.IsNullOrWhiteSpace(TargetComputerNameValidationMessage); @@ -197,7 +217,9 @@ public void ApplyAutopilotConfiguration( OnPropertyChanged(nameof(IsAutopilotProfileSelectionEnabled)); OnPropertyChanged(nameof(AutopilotProfileHint)); - SelectedAutopilotProfile = settings.IsEnabled + AutopilotProvisioningMode = settings.ProvisioningMode; + AutopilotHardwareHashUpload = settings.HardwareHashUpload ?? new DeployAutopilotHardwareHashUploadSettings(); + SelectedAutopilotProfile = settings.IsEnabled && settings.ProvisioningMode == AutopilotProvisioningMode.JsonProfile ? ResolveDefaultAutopilotProfile(settings.DefaultProfileFolderName) : null; IsAutopilotEnabled = settings.IsEnabled; @@ -326,11 +348,73 @@ partial void OnApplyFirmwareUpdatesChanged(bool value) partial void OnIsAutopilotEnabledChanged(bool value) { OnPropertyChanged(nameof(IsAutopilotSectionVisible)); + OnPropertyChanged(nameof(IsJsonProfileControlsVisible)); + OnPropertyChanged(nameof(IsHardwareHashUploadControlsVisible)); OnPropertyChanged(nameof(IsAutopilotProfileSelectionEnabled)); OnPropertyChanged(nameof(AutopilotProfileHint)); RaiseStateChanged(); } + /// + /// Applies an in-memory Autopilot mode override for debug safe mode without changing the persisted deployment configuration. + /// + /// Debug Autopilot mode to apply. + public void ApplyDebugAutopilotMode(DebugAutopilotMode mode) + { + switch (mode) + { + case DebugAutopilotMode.None: + IsAutopilotEnabled = false; + AutopilotProvisioningMode = AutopilotProvisioningMode.JsonProfile; + SelectedAutopilotProfile = null; + break; + case DebugAutopilotMode.JsonProfile: + EnsureDebugAutopilotProfile(); + AutopilotProvisioningMode = AutopilotProvisioningMode.JsonProfile; + SelectedAutopilotProfile = AutopilotProfiles.First(); + IsAutopilotEnabled = true; + break; + case DebugAutopilotMode.HardwareHashUpload: + AutopilotProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload; + AutopilotHardwareHashUpload = new DeployAutopilotHardwareHashUploadSettings + { + TenantId = "debug-tenant-id", + ClientId = "debug-client-id", + ActiveCertificateKeyId = "debug-certificate-key-id", + ActiveCertificateThumbprint = "DEBUGTHUMBPRINT", + ActiveCertificateExpiresOnUtc = DateTimeOffset.UtcNow.AddMonths(1), + DefaultGroupTag = "Debug" + }; + SelectedAutopilotProfile = null; + IsAutopilotEnabled = true; + break; + default: + throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported debug Autopilot mode."); + } + + RaiseStateChanged(); + } + + partial void OnAutopilotProvisioningModeChanged(AutopilotProvisioningMode value) + { + OnPropertyChanged(nameof(IsJsonProfileMode)); + OnPropertyChanged(nameof(IsHardwareHashUploadMode)); + OnPropertyChanged(nameof(IsJsonProfileControlsVisible)); + OnPropertyChanged(nameof(IsHardwareHashUploadControlsVisible)); + OnPropertyChanged(nameof(IsAutopilotProfileSelectionEnabled)); + OnPropertyChanged(nameof(AutopilotProfileHint)); + OnPropertyChanged(nameof(AutopilotModeText)); + OnPropertyChanged(nameof(AutopilotHardwareHashStatusText)); + RaiseStateChanged(); + } + + partial void OnAutopilotHardwareHashUploadChanged(DeployAutopilotHardwareHashUploadSettings value) + { + OnPropertyChanged(nameof(IsHardwareHashCertificateExpired)); + OnPropertyChanged(nameof(AutopilotHardwareHashStatusText)); + RaiseStateChanged(); + } + partial void OnSelectedAutopilotProfileChanged(AutopilotProfileCatalogItem? value) { OnPropertyChanged(nameof(IsAutopilotProfileSelectionEnabled)); @@ -488,6 +572,25 @@ private static string NormalizeAutoGeneratedComputerNameSuffix(string? value) return null; } + private void EnsureDebugAutopilotProfile() + { + if (AutopilotProfiles.Count > 0) + { + return; + } + + AutopilotProfiles.Add(new AutopilotProfileCatalogItem + { + FolderName = "DebugAutopilotProfile", + DisplayName = "Debug Autopilot Profile", + ConfigurationFilePath = @"X:\Foundry\Debug\Autopilot\AutopilotConfigurationFile.json" + }); + OnPropertyChanged(nameof(HasAutopilotProfiles)); + OnPropertyChanged(nameof(IsAutopilotSectionVisible)); + OnPropertyChanged(nameof(IsAutopilotProfileSelectionEnabled)); + OnPropertyChanged(nameof(AutopilotProfileHint)); + } + private void RaiseStateChanged() { StateChanged?.Invoke(this, EventArgs.Empty); @@ -519,6 +622,8 @@ private void OnLocalizationLanguageChanged(object? sender, EventArgs e) TargetComputerNameValidationMessage = ResolveComputerNameValidationMessage(TargetComputerName); OnPropertyChanged(nameof(AutopilotProfileHint)); + OnPropertyChanged(nameof(AutopilotModeText)); + OnPropertyChanged(nameof(AutopilotHardwareHashStatusText)); OnPropertyChanged(nameof(TargetDiskSelectionHint)); OnPropertyChanged(nameof(TargetDisks)); OnPropertyChanged(nameof(SelectedTargetDisk)); @@ -542,6 +647,34 @@ private string Format(string key, params object[] args) return string.Format(LocalizationService.CurrentCulture, GetString(key), args); } + private string CreateHardwareHashStatusText() + { + if (!IsHardwareHashUploadMode) + { + return string.Empty; + } + + if (IsHardwareHashCertificateExpired) + { + return Format( + "Preparation.AutopilotHardwareHashCertificateExpiredFormat", + AutopilotHardwareHashUpload.ActiveCertificateExpiresOnUtc!.Value.LocalDateTime); + } + + if (AutopilotHardwareHashUpload.ActiveCertificateExpiresOnUtc is DateTimeOffset expiresOn) + { + string groupTag = string.IsNullOrWhiteSpace(AutopilotHardwareHashUpload.DefaultGroupTag) + ? GetString("Common.None") + : AutopilotHardwareHashUpload.DefaultGroupTag!; + return Format( + "Preparation.AutopilotHardwareHashReadyFormat", + expiresOn.LocalDateTime, + groupTag); + } + + return GetString("Preparation.AutopilotHardwareHashNotConfigured"); + } + private bool CanRefreshTargetDisks() { return !IsTargetDiskLoading; diff --git a/src/Foundry.Deploy/ViewModels/MainWindowViewModel.cs b/src/Foundry.Deploy/ViewModels/MainWindowViewModel.cs index 443d15d2..bc6677b6 100644 --- a/src/Foundry.Deploy/ViewModels/MainWindowViewModel.cs +++ b/src/Foundry.Deploy/ViewModels/MainWindowViewModel.cs @@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input; using Foundry.Deploy; using Foundry.Deploy.Models; +using Foundry.Deploy.Models.Configuration; using Foundry.Deploy.Services.Catalog; using Foundry.Deploy.Services.Deployment; using Foundry.Deploy.Services.Operations; @@ -39,6 +40,7 @@ public partial class MainWindowViewModel : LocalizedViewModelBase private readonly Dispatcher _dispatcher; private readonly DeploymentRuntimeContext _deploymentRuntimeContext; private readonly DeploymentWizardContext _wizardContext; + private DebugAutopilotMode _debugAutopilotMode = DebugAutopilotMode.None; private bool _isInitialized; private bool _isDisposed; private Task? _initializationTask; @@ -61,6 +63,7 @@ public partial class MainWindowViewModel : LocalizedViewModelBase [NotifyCanExecuteChangedFor(nameof(ShowDebugProgressPageCommand))] [NotifyCanExecuteChangedFor(nameof(ShowDebugSuccessPageCommand))] [NotifyCanExecuteChangedFor(nameof(ShowDebugErrorPageCommand))] + [NotifyCanExecuteChangedFor(nameof(SetDebugAutopilotModeCommand))] private bool isDeploymentRunning; public DeploymentPreparationViewModel Preparation { get; } @@ -83,7 +86,11 @@ public partial class MainWindowViewModel : LocalizedViewModelBase : new Converters.OperatingSystemSummaryConverter().Convert(SelectedOperatingSystem, typeof(string), string.Empty, LocalizationService.CurrentCulture)?.ToString() ?? GetString("Summary.NoSelection"); public string SummaryFirmwareText => Preparation.ApplyFirmwareUpdates ? GetString("Common.Enabled") : GetString("Common.Disabled"); public string SummaryAutopilotEnabledText => Preparation.IsAutopilotEnabled ? GetString("Common.Yes") : GetString("Common.No"); + public string SummaryAutopilotModeText => Preparation.AutopilotModeText; public string SummaryAutopilotProfileText => Preparation.SelectedAutopilotProfile?.DisplayName ?? GetString("Common.None"); + public bool IsDebugAutopilotNoneMode => IsDebugSafeMode && _debugAutopilotMode == DebugAutopilotMode.None; + public bool IsDebugAutopilotJsonProfileMode => IsDebugSafeMode && _debugAutopilotMode == DebugAutopilotMode.JsonProfile; + public bool IsDebugAutopilotHardwareHashUploadMode => IsDebugSafeMode && _debugAutopilotMode == DebugAutopilotMode.HardwareHashUpload; public MainWindowViewModel( ILocalizationService localizationService, @@ -200,6 +207,26 @@ private void ShowAbout() _applicationShellService.ShowAbout(); } + [RelayCommand(CanExecute = nameof(CanUseDebugTools))] + private void SetDebugAutopilotMode(DebugAutopilotMode mode) + { + if (!IsDebugSafeMode) + { + return; + } + + _debugAutopilotMode = mode; + Preparation.ApplyDebugAutopilotMode(mode); + OnPropertyChanged(nameof(IsDebugAutopilotNoneMode)); + OnPropertyChanged(nameof(IsDebugAutopilotJsonProfileMode)); + OnPropertyChanged(nameof(IsDebugAutopilotHardwareHashUploadMode)); + OnPropertyChanged(nameof(SummaryAutopilotEnabledText)); + OnPropertyChanged(nameof(SummaryAutopilotModeText)); + OnPropertyChanged(nameof(SummaryAutopilotProfileText)); + NextWizardStepCommand.NotifyCanExecuteChanged(); + StartDeploymentCommand.NotifyCanExecuteChanged(); + } + [RelayCommand(CanExecute = nameof(CanRefreshCatalogs))] private async Task RefreshCatalogsAsync() { @@ -280,7 +307,9 @@ private async Task StartDeploymentAsync() SelectedDriverPack = effectiveDriverPack, ApplyFirmwareUpdates = Preparation.ApplyFirmwareUpdates, IsAutopilotEnabled = Preparation.IsAutopilotEnabled, + AutopilotProvisioningMode = Preparation.AutopilotProvisioningMode, SelectedAutopilotProfile = Preparation.SelectedAutopilotProfile, + AutopilotHardwareHashUpload = Preparation.AutopilotHardwareHashUpload, Oobe = _wizardContext.Oobe, AppxRemoval = _wizardContext.AppxRemoval, AiComponentRemoval = _wizardContext.AiComponentRemoval, @@ -364,6 +393,7 @@ private void OnWizardContextStateChanged(object? sender, EventArgs e) OnPropertyChanged(nameof(SummaryOperatingSystemText)); OnPropertyChanged(nameof(SummaryFirmwareText)); OnPropertyChanged(nameof(SummaryAutopilotEnabledText)); + OnPropertyChanged(nameof(SummaryAutopilotModeText)); OnPropertyChanged(nameof(SummaryAutopilotProfileText)); NextWizardStepCommand.NotifyCanExecuteChanged(); StartDeploymentCommand.NotifyCanExecuteChanged(); @@ -392,6 +422,11 @@ private bool CanShowDebugPages() return IsDebugSafeMode && !IsDeploymentRunning; } + private bool CanUseDebugTools() + { + return IsDebugSafeMode && !IsDeploymentRunning; + } + private bool CanRefreshCatalogs() { return Session.IsStartupReady && !IsCatalogLoading && !IsDeploymentRunning; @@ -436,7 +471,10 @@ private DeploymentWizardStateSnapshot BuildWizardStateSnapshot() HasTargetDiskSelection = Preparation.SelectedTargetDisk is not null, IsSelectedTargetDiskSelectable = Preparation.SelectedTargetDisk?.IsSelectable ?? false, HasValidDriverPackSelection = HasValidDriverPackSelection(), - HasValidAutopilotSelection = !Preparation.IsAutopilotEnabled || Preparation.SelectedAutopilotProfile is not null, + HasValidAutopilotSelection = + !Preparation.IsAutopilotEnabled || + Preparation.AutopilotProvisioningMode == AutopilotProvisioningMode.HardwareHashUpload || + Preparation.SelectedAutopilotProfile is not null, IsOperatingSystemCatalogReadyForNavigation = !IsCatalogLoading && OperatingSystemCatalog.IsReadyForNavigation() }; } @@ -504,6 +542,7 @@ private void OnLocalizationLanguageChanged(object? sender, EventArgs e) OnPropertyChanged(nameof(SummaryOperatingSystemText)); OnPropertyChanged(nameof(SummaryFirmwareText)); OnPropertyChanged(nameof(SummaryAutopilotEnabledText)); + OnPropertyChanged(nameof(SummaryAutopilotModeText)); OnPropertyChanged(nameof(SummaryAutopilotProfileText)); }); } diff --git a/src/Foundry.Deploy/Views/Wizard/SummaryStepView.xaml b/src/Foundry.Deploy/Views/Wizard/SummaryStepView.xaml index c25bd012..5210cbb9 100644 --- a/src/Foundry.Deploy/Views/Wizard/SummaryStepView.xaml +++ b/src/Foundry.Deploy/Views/Wizard/SummaryStepView.xaml @@ -121,6 +121,8 @@ + + @@ -149,14 +151,26 @@ + + + diff --git a/src/Foundry.Deploy/Views/Wizard/TargetStepView.xaml b/src/Foundry.Deploy/Views/Wizard/TargetStepView.xaml index 1fae0a39..3283509d 100644 --- a/src/Foundry.Deploy/Views/Wizard/TargetStepView.xaml +++ b/src/Foundry.Deploy/Views/Wizard/TargetStepView.xaml @@ -135,7 +135,15 @@ - + + + + + + + + + diff --git a/src/Foundry/DependencyInjection/ServiceCollectionExtensions.cs b/src/Foundry/DependencyInjection/ServiceCollectionExtensions.cs index f6e1995f..f7e06717 100644 --- a/src/Foundry/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Foundry/DependencyInjection/ServiceCollectionExtensions.cs @@ -89,8 +89,12 @@ public static IServiceCollection AddFoundryApplicationServices(this IServiceColl services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Foundry/Services/Autopilot/AutopilotCertificateCreationResult.cs b/src/Foundry/Services/Autopilot/AutopilotCertificateCreationResult.cs new file mode 100644 index 00000000..c589009e --- /dev/null +++ b/src/Foundry/Services/Autopilot/AutopilotCertificateCreationResult.cs @@ -0,0 +1,30 @@ +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Autopilot; + +namespace Foundry.Services.Autopilot; + +/// +/// Represents a created app registration certificate and the one-time generated PFX password. +/// +public sealed record AutopilotCertificateCreationResult +{ + /// + /// Gets updated hardware hash upload settings with the created certificate selected as active. + /// + public AutopilotHardwareHashUploadSettings Settings { get; init; } = new(); + + /// + /// Gets the generated password for the exported PFX. This must not be persisted by Foundry. + /// + public string GeneratedPassword { get; init; } = string.Empty; + + /// + /// Gets the certificate metadata selected as active after creation. + /// + public AutopilotCertificateMetadata Certificate { get; init; } = new(); + + /// + /// Gets the app registration certificate credentials after creation. + /// + public IReadOnlyList Certificates { get; init; } = []; +} diff --git a/src/Foundry/Services/Autopilot/AutopilotCertificateDialogService.cs b/src/Foundry/Services/Autopilot/AutopilotCertificateDialogService.cs new file mode 100644 index 00000000..caccb7cb --- /dev/null +++ b/src/Foundry/Services/Autopilot/AutopilotCertificateDialogService.cs @@ -0,0 +1,116 @@ +using Foundry.Services.Localization; +using Windows.ApplicationModel.DataTransfer; + +namespace Foundry.Services.Autopilot; + +public sealed class AutopilotCertificateDialogService( + IApplicationLocalizationService localizationService) : IAutopilotCertificateDialogService +{ + public async Task ShowCreatedAsync(string pfxOutputPath, string password) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pfxOutputPath); + ArgumentException.ThrowIfNullOrWhiteSpace(password); + + var dialog = new ContentDialog + { + XamlRoot = App.MainWindow.Content.XamlRoot, + Title = localizationService.GetString("Autopilot.HardwareHashCertificateCreatedTitle"), + Content = CreateContent(pfxOutputPath, password), + CloseButtonText = localizationService.GetString("Common.Close"), + DefaultButton = ContentDialogButton.Close + }; + + await dialog.ShowAsync(); + } + + private FrameworkElement CreateContent(string pfxOutputPath, string password) + { + return new StackPanel + { + MinWidth = 480, + MaxWidth = 560, + Spacing = 12, + Children = + { + new TextBlock + { + Text = localizationService.GetString("Autopilot.HardwareHashCertificateCreatedInstruction"), + TextWrapping = TextWrapping.Wrap, + IsTextSelectionEnabled = true + }, + new TextBlock + { + Text = localizationService.GetString("Autopilot.HardwareHashCertificateCreatedPathLabel"), + Style = (Style)Microsoft.UI.Xaml.Application.Current.Resources["BodyStrongTextBlockStyle"] + }, + new TextBlock + { + Text = pfxOutputPath, + TextWrapping = TextWrapping.Wrap, + IsTextSelectionEnabled = true + }, + new TextBlock + { + Text = localizationService.GetString("Autopilot.HardwareHashCertificateCreatedPasswordLabel"), + Style = (Style)Microsoft.UI.Xaml.Application.Current.Resources["BodyStrongTextBlockStyle"], + Margin = new Thickness(0, 8, 0, 0) + }, + CreatePasswordRow(password) + } + }; + } + + private FrameworkElement CreatePasswordRow(string password) + { + var copiedTextBlock = new TextBlock + { + Text = localizationService.GetString("Autopilot.HardwareHashCertificateCreatedPasswordCopied"), + Foreground = (Microsoft.UI.Xaml.Media.Brush)Microsoft.UI.Xaml.Application.Current.Resources["SystemFillColorSuccessBrush"], + Visibility = Visibility.Collapsed + }; + + var copyButton = new Button + { + Content = localizationService.GetString("Autopilot.HardwareHashCertificateCreatedCopyPasswordButton"), + MinWidth = 128 + }; + Grid.SetColumn(copyButton, 1); + + copyButton.Click += (_, _) => + { + var dataPackage = new DataPackage(); + dataPackage.SetText(password); + Clipboard.SetContent(dataPackage); + copiedTextBlock.Visibility = Visibility.Visible; + }; + + return new StackPanel + { + Spacing = 8, + Children = + { + new Grid + { + ColumnSpacing = 8, + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + new ColumnDefinition { Width = GridLength.Auto } + }, + Children = + { + new Microsoft.UI.Xaml.Controls.TextBox + { + Text = password, + IsReadOnly = true, + TextWrapping = TextWrapping.NoWrap, + FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas") + }, + copyButton + } + }, + copiedTextBlock + } + }; + } +} diff --git a/src/Foundry/Services/Autopilot/AutopilotCertificateRemovalResult.cs b/src/Foundry/Services/Autopilot/AutopilotCertificateRemovalResult.cs new file mode 100644 index 00000000..486c8714 --- /dev/null +++ b/src/Foundry/Services/Autopilot/AutopilotCertificateRemovalResult.cs @@ -0,0 +1,20 @@ +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Autopilot; + +namespace Foundry.Services.Autopilot; + +/// +/// Represents app registration certificate state after removing a selected credential. +/// +public sealed record AutopilotCertificateRemovalResult +{ + /// + /// Gets updated hardware hash upload settings after certificate removal. + /// + public AutopilotHardwareHashUploadSettings Settings { get; init; } = new(); + + /// + /// Gets the app registration certificate credentials after removal. + /// + public IReadOnlyList Certificates { get; init; } = []; +} diff --git a/src/Foundry/Services/Autopilot/AutopilotGraphAuthenticationDefaults.cs b/src/Foundry/Services/Autopilot/AutopilotGraphAuthenticationDefaults.cs new file mode 100644 index 00000000..e6406d09 --- /dev/null +++ b/src/Foundry/Services/Autopilot/AutopilotGraphAuthenticationDefaults.cs @@ -0,0 +1,26 @@ +namespace Foundry.Services.Autopilot; + +/// +/// Defines the Foundry-owned Microsoft Graph public client used for interactive Autopilot tenant onboarding. +/// +internal static class AutopilotGraphAuthenticationDefaults +{ + /// + /// Official multi-tenant public client ID for the Foundry bootstrap app. + /// This app is used only for interactive OSD admin sign-in and is not embedded into generated WinPE media. + /// + public const string FoundryBootstrapClientId = "83eb3a92-030d-49b7-881b-32a1eb3e110a"; + + /// + /// Optional override for private builds or forks that use their own bootstrap public client. + /// + public const string ClientIdEnvironmentVariableName = "FOUNDRY_AUTOPILOT_GRAPH_CLIENT_ID"; + + /// + /// Optional tenant override for development and controlled validation scenarios. + /// + public const string TenantIdEnvironmentVariableName = "FOUNDRY_AUTOPILOT_GRAPH_TENANT_ID"; + + public const string DefaultTenantId = "common"; + public const string RedirectUri = "http://localhost"; +} diff --git a/src/Foundry/Services/Autopilot/AutopilotHardwareHashGraphSessionService.cs b/src/Foundry/Services/Autopilot/AutopilotHardwareHashGraphSessionService.cs new file mode 100644 index 00000000..fdaeaddb --- /dev/null +++ b/src/Foundry/Services/Autopilot/AutopilotHardwareHashGraphSessionService.cs @@ -0,0 +1,80 @@ +using Azure.Core; +using Azure.Identity; + +namespace Foundry.Services.Autopilot; + +/// +/// Owns the session-scoped interactive Microsoft Graph credential used for Autopilot hardware hash onboarding. +/// +public sealed class AutopilotHardwareHashGraphSessionService : IAutopilotHardwareHashGraphSessionService +{ + private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromMinutes(5); + + private static readonly string[] GraphScopes = + [ + "Application.ReadWrite.All", + "AppRoleAssignment.ReadWrite.All", + "DeviceManagementServiceConfig.Read.All", + "User.Read" + ]; + + private TokenCredential? credential; + private AccessToken? currentToken; + + /// + public async Task ConnectAsync(CancellationToken cancellationToken = default) + { + credential = CreateCredential(); + try + { + currentToken = await credential.GetTokenAsync(new TokenRequestContext(GraphScopes), cancellationToken) + .ConfigureAwait(false); + return currentToken.Value.Token; + } + catch + { + Disconnect(); + throw; + } + } + + /// + public async Task GetAccessTokenAsync(CancellationToken cancellationToken = default) + { + if (currentToken is { } cachedToken && + cachedToken.ExpiresOn > DateTimeOffset.UtcNow.Add(TokenRefreshSkew)) + { + return cachedToken.Token; + } + + if (credential is null) + { + throw new InvalidOperationException("Connect to the tenant before managing Autopilot certificates."); + } + + currentToken = await credential.GetTokenAsync(new TokenRequestContext(GraphScopes), cancellationToken) + .ConfigureAwait(false); + return currentToken.Value.Token; + } + + /// + public void Disconnect() + { + credential = null; + currentToken = null; + } + + private static TokenCredential CreateCredential() + { + string clientId = Environment.GetEnvironmentVariable(AutopilotGraphAuthenticationDefaults.ClientIdEnvironmentVariableName)?.Trim() + ?? AutopilotGraphAuthenticationDefaults.FoundryBootstrapClientId; + string? tenantId = Environment.GetEnvironmentVariable(AutopilotGraphAuthenticationDefaults.TenantIdEnvironmentVariableName); + + return new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions + { + ClientId = clientId, + TenantId = string.IsNullOrWhiteSpace(tenantId) ? AutopilotGraphAuthenticationDefaults.DefaultTenantId : tenantId.Trim(), + RedirectUri = new Uri(AutopilotGraphAuthenticationDefaults.RedirectUri, UriKind.Absolute) + }); + } +} diff --git a/src/Foundry/Services/Autopilot/AutopilotHardwareHashSessionState.cs b/src/Foundry/Services/Autopilot/AutopilotHardwareHashSessionState.cs new file mode 100644 index 00000000..30b29555 --- /dev/null +++ b/src/Foundry/Services/Autopilot/AutopilotHardwareHashSessionState.cs @@ -0,0 +1,23 @@ +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Autopilot; + +namespace Foundry.Services.Autopilot; + +internal sealed class AutopilotHardwareHashSessionState : IAutopilotHardwareHashSessionState +{ + public bool HasConnectedTenant { get; set; } + + public AutopilotTenantOnboardingStatus? TenantOnboardingStatus { get; set; } + + public AutopilotBootMediaCertificateSettings BootMediaCertificate { get; set; } = new(); + + public IReadOnlyList Certificates { get; set; } = []; + + public void ClearTenantConnection() + { + HasConnectedTenant = false; + TenantOnboardingStatus = null; + Certificates = []; + BootMediaCertificate = new AutopilotBootMediaCertificateSettings(); + } +} diff --git a/src/Foundry/Services/Autopilot/AutopilotTenantDownloadDialogService.cs b/src/Foundry/Services/Autopilot/AutopilotTenantDownloadDialogService.cs deleted file mode 100644 index d47f80f2..00000000 --- a/src/Foundry/Services/Autopilot/AutopilotTenantDownloadDialogService.cs +++ /dev/null @@ -1,120 +0,0 @@ -using Foundry.Core.Models.Configuration; -using Foundry.Services.Localization; - -namespace Foundry.Services.Autopilot; - -public sealed class AutopilotTenantDownloadDialogService( - IApplicationLocalizationService localizationService) : IAutopilotTenantDownloadDialogService -{ - public async Task?> DownloadAsync( - Func>> downloadProfilesAsync) - { - ArgumentNullException.ThrowIfNull(downloadProfilesAsync); - - using var cancellationTokenSource = new CancellationTokenSource(); - ContentDialog dialog = CreateDialog(cancellationTokenSource); - TaskCompletionSource dialogOpenedTask = new(TaskCreationOptions.RunContinuationsAsynchronously); - dialog.Opened += OnDialogOpened; - Task dialogTask = dialog.ShowAsync().AsTask(); - - Task completedBeforeOpenTask = await Task.WhenAny(dialogOpenedTask.Task, dialogTask); - dialog.Opened -= OnDialogOpened; - if (completedBeforeOpenTask == dialogTask) - { - cancellationTokenSource.Cancel(); - return null; - } - - Task> downloadTask = Task.Run( - () => downloadProfilesAsync(cancellationTokenSource.Token), - cancellationTokenSource.Token); - Task completedTask = await Task.WhenAny(downloadTask, dialogTask); - if (completedTask == dialogTask) - { - cancellationTokenSource.Cancel(); - _ = ObserveDownloadTaskAsync(downloadTask); - return null; - } - - IReadOnlyList profiles; - try - { - profiles = await downloadTask; - } - catch - { - await CloseDialogAsync(dialog, dialogTask); - throw; - } - - await CloseDialogAsync(dialog, dialogTask); - return profiles; - - void OnDialogOpened(ContentDialog sender, ContentDialogOpenedEventArgs args) - { - dialogOpenedTask.TrySetResult(); - } - } - - private ContentDialog CreateDialog(CancellationTokenSource cancellationTokenSource) - { - var dialog = new ContentDialog - { - XamlRoot = App.MainWindow.Content.XamlRoot, - Title = localizationService.GetString("Autopilot.TenantDownloadDialogTitle"), - Content = CreateDialogContent(), - CloseButtonText = localizationService.GetString("Autopilot.TenantDownloadDialogCancel"), - DefaultButton = ContentDialogButton.Close - }; - - dialog.CloseButtonClick += (_, _) => cancellationTokenSource.Cancel(); - return dialog; - } - - private FrameworkElement CreateDialogContent() - { - return new StackPanel - { - MinWidth = 360, - Spacing = 16, - Children = - { - new Microsoft.UI.Xaml.Controls.ProgressRing - { - Width = 48, - Height = 48, - IsActive = true, - HorizontalAlignment = HorizontalAlignment.Center - }, - new TextBlock - { - Text = localizationService.GetString("Autopilot.TenantDownloadDialogMessage"), - TextWrapping = TextWrapping.Wrap, - TextAlignment = TextAlignment.Center - } - } - }; - } - - private static async Task ObserveDownloadTaskAsync(Task> downloadTask) - { - try - { - await downloadTask.ConfigureAwait(false); - } - catch - { - // The user canceled the dialog; late task completion is intentionally ignored. - } - } - - private static async Task CloseDialogAsync(ContentDialog dialog, Task dialogTask) - { - if (!dialogTask.IsCompleted) - { - dialog.Hide(); - } - - await dialogTask; - } -} diff --git a/src/Foundry/Services/Autopilot/AutopilotTenantOnboardingResult.cs b/src/Foundry/Services/Autopilot/AutopilotTenantOnboardingResult.cs new file mode 100644 index 00000000..2f4efbf1 --- /dev/null +++ b/src/Foundry/Services/Autopilot/AutopilotTenantOnboardingResult.cs @@ -0,0 +1,25 @@ +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Autopilot; + +namespace Foundry.Services.Autopilot; + +/// +/// Represents the result of a tenant onboarding pass for the managed Autopilot app registration. +/// +public sealed record AutopilotTenantOnboardingResult +{ + /// + /// Gets the updated persistent settings. + /// + public AutopilotHardwareHashUploadSettings Settings { get; init; } = new(); + + /// + /// Gets the evaluated tenant registration status after Graph discovery and repair. + /// + public AutopilotTenantOnboardingStatus Status { get; init; } + + /// + /// Gets the app registration certificate credentials discovered from Microsoft Graph. + /// + public IReadOnlyList Certificates { get; init; } = []; +} diff --git a/src/Foundry/Services/Autopilot/AutopilotTenantOnboardingService.cs b/src/Foundry/Services/Autopilot/AutopilotTenantOnboardingService.cs new file mode 100644 index 00000000..4650f8f2 --- /dev/null +++ b/src/Foundry/Services/Autopilot/AutopilotTenantOnboardingService.cs @@ -0,0 +1,1023 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Autopilot; +using Serilog; + +namespace Foundry.Services.Autopilot; + +/// +/// Performs interactive Microsoft Graph tenant onboarding for Autopilot hardware hash upload. +/// +public sealed class AutopilotTenantOnboardingService( + IAutopilotHardwareHashGraphSessionService graphSessionService, + ILogger logger) : IAutopilotTenantOnboardingService +{ + private const string GraphAppId = "00000003-0000-0000-c000-000000000000"; + private const string OrganizationRequestPath = "v1.0/organization?$select=id"; + private const string ApplicationSelect = "$select=id,appId,displayName,requiredResourceAccess,keyCredentials"; + private const string ServicePrincipalSelect = "$select=id,appId,accountEnabled"; + private const string GroupTagRequestPath = "v1.0/deviceManagement/windowsAutopilotDeviceIdentities"; + private const int MaximumCertificateValidityMonths = 12; + + private static readonly HttpClient GraphHttpClient = new() + { + BaseAddress = new Uri("https://graph.microsoft.com/", UriKind.Absolute) + }; + + private readonly IAutopilotHardwareHashGraphSessionService graphSessionService = graphSessionService; + private readonly ILogger logger = logger.ForContext(); + + /// + public async Task ConnectAsync( + AutopilotHardwareHashUploadSettings currentSettings, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(currentSettings); + + string accessToken = await graphSessionService.ConnectAsync(cancellationToken).ConfigureAwait(false); + try + { + string tenantId = await GetTenantIdAsync(accessToken, cancellationToken).ConfigureAwait(false); + GraphAppRole requiredRole = await GetGraphAppRoleAsync( + accessToken, + AutopilotGraphPermissionCatalog.DeviceManagementServiceConfigReadWriteAll, + cancellationToken).ConfigureAwait(false); + + AutopilotGraphApplication? application = await FindApplicationAsync( + accessToken, + currentSettings.Tenant.ApplicationObjectId, + requiredRole, + cancellationToken).ConfigureAwait(false); + application ??= await FindApplicationByDisplayNameAsync(accessToken, requiredRole, cancellationToken).ConfigureAwait(false); + + if (application is null) + { + application = await CreateApplicationAsync(accessToken, requiredRole, cancellationToken).ConfigureAwait(false); + logger.Information("Created managed Autopilot app registration. ApplicationObjectId={ApplicationObjectId}", application.ObjectId); + } + else if (string.IsNullOrWhiteSpace(currentSettings.Tenant.ApplicationObjectId) && + string.Equals(application.DisplayName, AutopilotHardwareHashUploadSettings.ManagedAppRegistrationDisplayName, StringComparison.OrdinalIgnoreCase)) + { + logger.Information("Adopted existing managed Autopilot app registration. ApplicationObjectId={ApplicationObjectId}", application.ObjectId); + } + + application = await EnsureRequiredApplicationPermissionAsync( + accessToken, + application, + requiredRole, + cancellationToken).ConfigureAwait(false); + + AutopilotGraphServicePrincipal? servicePrincipal = await FindServicePrincipalAsync( + accessToken, + application.ClientId, + requiredRole, + cancellationToken).ConfigureAwait(false); + servicePrincipal ??= await CreateServicePrincipalAsync( + accessToken, + application.ClientId, + requiredRole, + cancellationToken).ConfigureAwait(false); + + servicePrincipal = await EnsureAdminConsentAsync( + accessToken, + servicePrincipal, + requiredRole, + cancellationToken).ConfigureAwait(false); + + string[]? discoveredGroupTags = await TryGetGroupTagsAsync(accessToken, cancellationToken).ConfigureAwait(false); + string[] groupTags = discoveredGroupTags ?? NormalizeGroupTags(currentSettings.KnownGroupTags); + IReadOnlyList keyCredentials = + await GetApplicationKeyCredentialsAsync(accessToken, application.ObjectId, cancellationToken).ConfigureAwait(false); + AutopilotTenantOnboardingSnapshot snapshot = new() + { + TenantId = tenantId, + PersistedApplicationObjectId = application.ObjectId, + ManagedAppDisplayName = AutopilotHardwareHashUploadSettings.ManagedAppRegistrationDisplayName, + Applications = [application], + ServicePrincipal = servicePrincipal, + ActiveCertificate = currentSettings.ActiveCertificate, + KeyCredentials = keyCredentials, + CurrentTimeUtc = DateTimeOffset.UtcNow + }; + AutopilotTenantOnboardingEvaluation evaluation = AutopilotTenantOnboardingEvaluator.Evaluate(snapshot); + AutopilotCertificateMetadata? activeCertificate = IsPersistedActiveCertificateResolved( + currentSettings.ActiveCertificate, + evaluation.ActiveCertificateCredential) + ? currentSettings.ActiveCertificate + : null; + + AutopilotHardwareHashUploadSettings updatedSettings = currentSettings with + { + Tenant = new AutopilotTenantRegistrationSettings + { + TenantId = tenantId, + ApplicationObjectId = application.ObjectId, + ClientId = application.ClientId, + ServicePrincipalObjectId = servicePrincipal.ObjectId + }, + ActiveCertificate = activeCertificate, + KnownGroupTags = groupTags, + DefaultGroupTag = discoveredGroupTags is null + ? currentSettings.DefaultGroupTag + : ResolveDefaultGroupTag(currentSettings.DefaultGroupTag, groupTags) + }; + + return new AutopilotTenantOnboardingResult + { + Settings = updatedSettings, + Status = evaluation.Status, + Certificates = keyCredentials + }; + } + catch + { + graphSessionService.Disconnect(); + throw; + } + } + + /// + public async Task CreateCertificateAsync( + AutopilotHardwareHashUploadSettings currentSettings, + string pfxOutputPath, + int validityMonths, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(currentSettings); + ArgumentException.ThrowIfNullOrWhiteSpace(pfxOutputPath); + + if (string.IsNullOrWhiteSpace(currentSettings.Tenant.ApplicationObjectId)) + { + throw new InvalidOperationException("The managed app registration must be connected before creating a certificate."); + } + + if (validityMonths is <= 0 or > MaximumCertificateValidityMonths) + { + throw new ArgumentOutOfRangeException( + nameof(validityMonths), + validityMonths, + $"Microsoft Graph application certificate credentials cannot exceed {MaximumCertificateValidityMonths} months."); + } + + string accessToken = await graphSessionService.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); + DateTimeOffset startsOnUtc = DateTimeOffset.UtcNow.AddMinutes(-5); + DateTimeOffset expiresOnUtc = startsOnUtc.AddMonths(validityMonths); + string keyId = Guid.NewGuid().ToString("D"); + string password = GeneratePfxPassword(); + + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + $"CN={AutopilotHardwareHashUploadSettings.ManagedAppRegistrationDisplayName}", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + using X509Certificate2 certificate = request.CreateSelfSigned(startsOnUtc, expiresOnUtc); + byte[] pfxBytes = certificate.Export(X509ContentType.Pfx, password); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(pfxOutputPath))!); + await File.WriteAllBytesAsync(pfxOutputPath, pfxBytes, cancellationToken).ConfigureAwait(false); + } + finally + { + CryptographicOperations.ZeroMemory(pfxBytes); + } + + string newCredentialJson = CreateKeyCredentialJson(keyId, certificate, startsOnUtc, expiresOnUtc); + try + { + await AddKeyCredentialAsync( + accessToken, + currentSettings.Tenant.ApplicationObjectId, + keyId, + newCredentialJson, + cancellationToken).ConfigureAwait(false); + } + catch + { + DeletePfxOutputAfterFailedGraphUpload(pfxOutputPath); + throw; + } + + var metadata = new AutopilotCertificateMetadata + { + KeyId = keyId, + Thumbprint = certificate.Thumbprint?.ToUpperInvariant(), + DisplayName = AutopilotHardwareHashUploadSettings.ManagedAppRegistrationDisplayName, + ExpiresOnUtc = expiresOnUtc + }; + + IReadOnlyList keyCredentials = await GetApplicationKeyCredentialsAsync( + accessToken, + currentSettings.Tenant.ApplicationObjectId, + cancellationToken).ConfigureAwait(false); + + return new AutopilotCertificateCreationResult + { + Settings = currentSettings with { ActiveCertificate = metadata }, + GeneratedPassword = password, + Certificate = metadata, + Certificates = keyCredentials + }; + } + + /// + public async Task RemoveCertificateAsync( + AutopilotHardwareHashUploadSettings currentSettings, + string certificateKeyId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(currentSettings); + ArgumentException.ThrowIfNullOrWhiteSpace(certificateKeyId); + + if (string.IsNullOrWhiteSpace(currentSettings.Tenant.ApplicationObjectId)) + { + return new AutopilotCertificateRemovalResult + { + Settings = currentSettings with { ActiveCertificate = null } + }; + } + + string accessToken = await graphSessionService.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); + await RemoveKeyCredentialAsync( + accessToken, + currentSettings.Tenant.ApplicationObjectId, + certificateKeyId, + cancellationToken).ConfigureAwait(false); + AutopilotHardwareHashUploadSettings updatedSettings = string.Equals( + currentSettings.ActiveCertificate?.KeyId, + certificateKeyId, + StringComparison.OrdinalIgnoreCase) + ? currentSettings with { ActiveCertificate = null } + : currentSettings; + IReadOnlyList keyCredentials = await GetApplicationKeyCredentialsAsync( + accessToken, + currentSettings.Tenant.ApplicationObjectId, + cancellationToken).ConfigureAwait(false); + + return new AutopilotCertificateRemovalResult + { + Settings = updatedSettings, + Certificates = keyCredentials + }; + } + + private static bool IsPersistedActiveCertificateResolved( + AutopilotCertificateMetadata? activeCertificate, + AutopilotGraphKeyCredential? resolvedCredential) + { + return activeCertificate is not null && + resolvedCredential is not null && + string.Equals(activeCertificate.KeyId, resolvedCredential.KeyId, StringComparison.OrdinalIgnoreCase) && + string.Equals( + NormalizeThumbprint(activeCertificate.Thumbprint), + NormalizeThumbprint(resolvedCredential.Thumbprint), + StringComparison.Ordinal); + } + + private static string? NormalizeThumbprint(string? thumbprint) + { + string? normalized = thumbprint?.Replace(" ", string.Empty, StringComparison.Ordinal).Trim(); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized.ToUpperInvariant(); + } + + private static async Task GetTenantIdAsync(string accessToken, CancellationToken cancellationToken) + { + using JsonDocument document = await SendGraphRequestAsync( + accessToken, + HttpMethod.Get, + OrganizationRequestPath, + null, + cancellationToken).ConfigureAwait(false); + JsonElement organization = document.RootElement.GetProperty("value").EnumerateArray().FirstOrDefault(); + return organization.GetProperty("id").GetString() + ?? throw new InvalidOperationException("Microsoft Graph did not return a tenant ID."); + } + + private static async Task GetGraphAppRoleAsync( + string accessToken, + string roleValue, + CancellationToken cancellationToken) + { + string requestPath = $"v1.0/servicePrincipals?$filter=appId eq '{GraphAppId}'&$select=id,appRoles"; + using JsonDocument document = await SendGraphRequestAsync( + accessToken, + HttpMethod.Get, + requestPath, + null, + cancellationToken).ConfigureAwait(false); + JsonElement graphServicePrincipal = document.RootElement.GetProperty("value").EnumerateArray().FirstOrDefault(); + string graphServicePrincipalId = graphServicePrincipal.GetProperty("id").GetString() + ?? throw new InvalidOperationException("Microsoft Graph service principal ID was not returned."); + + foreach (JsonElement appRole in graphServicePrincipal.GetProperty("appRoles").EnumerateArray()) + { + if (appRole.TryGetProperty("value", out JsonElement value) && + string.Equals(value.GetString(), roleValue, StringComparison.OrdinalIgnoreCase) && + appRole.TryGetProperty("id", out JsonElement id)) + { + return new GraphAppRole(graphServicePrincipalId, id.GetString()!, roleValue); + } + } + + throw new InvalidOperationException($"Microsoft Graph app role '{roleValue}' was not found."); + } + + private static async Task FindApplicationAsync( + string accessToken, + string? applicationObjectId, + GraphAppRole requiredRole, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(applicationObjectId)) + { + return null; + } + + try + { + using JsonDocument document = await SendGraphRequestAsync( + accessToken, + HttpMethod.Get, + $"v1.0/applications/{applicationObjectId}?{ApplicationSelect}", + null, + cancellationToken).ConfigureAwait(false); + return ParseApplication(document.RootElement, requiredRole); + } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + } + + private static async Task FindApplicationByDisplayNameAsync( + string accessToken, + GraphAppRole requiredRole, + CancellationToken cancellationToken) + { + string displayName = AutopilotHardwareHashUploadSettings.ManagedAppRegistrationDisplayName.Replace("'", "''", StringComparison.Ordinal); + using JsonDocument document = await SendGraphRequestAsync( + accessToken, + HttpMethod.Get, + $"v1.0/applications?$filter=displayName eq '{displayName}'&{ApplicationSelect}", + null, + cancellationToken).ConfigureAwait(false); + JsonElement application = document.RootElement.GetProperty("value").EnumerateArray().FirstOrDefault(); + return application.ValueKind == JsonValueKind.Undefined ? null : ParseApplication(application, requiredRole); + } + + private static async Task CreateApplicationAsync( + string accessToken, + GraphAppRole requiredRole, + CancellationToken cancellationToken) + { + string body = $$""" + { + "displayName": "{{AutopilotHardwareHashUploadSettings.ManagedAppRegistrationDisplayName}}", + "signInAudience": "AzureADMyOrg", + "requiredResourceAccess": [ + { + "resourceAppId": "{{GraphAppId}}", + "resourceAccess": [ + { + "id": "{{requiredRole.AppRoleId}}", + "type": "Role" + } + ] + } + ] + } + """; + + using JsonDocument document = await SendGraphRequestAsync( + accessToken, + HttpMethod.Post, + "v1.0/applications", + body, + cancellationToken).ConfigureAwait(false); + return ParseApplication(document.RootElement, requiredRole); + } + + private static async Task EnsureRequiredApplicationPermissionAsync( + string accessToken, + AutopilotGraphApplication application, + GraphAppRole requiredRole, + CancellationToken cancellationToken) + { + if (application.RequiredPermissionValues.Contains(requiredRole.Value)) + { + return application; + } + + string body = await CreateRequiredResourceAccessPatchBodyAsync( + accessToken, + application.ObjectId, + requiredRole, + cancellationToken).ConfigureAwait(false); + try + { + await SendGraphNoContentAsync( + accessToken, + HttpMethod.Patch, + $"v1.0/applications/{application.ObjectId}", + body, + cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized) + { + return application; + } + + return application with + { + RequiredPermissionValues = AutopilotGraphPermissionCatalog.RequiredWinPeApplicationPermissionValues + }; + } + + private static async Task CreateRequiredResourceAccessPatchBodyAsync( + string accessToken, + string applicationObjectId, + GraphAppRole requiredRole, + CancellationToken cancellationToken) + { + using JsonDocument document = await SendGraphRequestAsync( + accessToken, + HttpMethod.Get, + $"v1.0/applications/{applicationObjectId}?$select=requiredResourceAccess", + null, + cancellationToken).ConfigureAwait(false); + + List resources = []; + bool graphResourceFound = false; + if (document.RootElement.TryGetProperty("requiredResourceAccess", out JsonElement requiredResourceAccess)) + { + foreach (JsonElement resource in requiredResourceAccess.EnumerateArray()) + { + string? resourceAppId = resource.TryGetProperty("resourceAppId", out JsonElement resourceAppIdElement) + ? resourceAppIdElement.GetString() + : null; + if (!string.Equals(resourceAppId, GraphAppId, StringComparison.OrdinalIgnoreCase)) + { + resources.Add(resource.GetRawText()); + continue; + } + + graphResourceFound = true; + string resourceAccessJson = CreateResourceAccessJson(resource, requiredRole); + resources.Add($$""" + { + "resourceAppId": "{{GraphAppId}}", + "resourceAccess": [ + {{resourceAccessJson}} + ] + } + """); + } + } + + if (!graphResourceFound) + { + resources.Add(CreateRequiredGraphResourceAccessJson(requiredRole)); + } + + return $$""" + { + "requiredResourceAccess": [ + {{string.Join(",", resources)}} + ] + } + """; + } + + private static string CreateResourceAccessJson(JsonElement graphResource, GraphAppRole requiredRole) + { + List resourceAccess = graphResource.TryGetProperty("resourceAccess", out JsonElement resourceAccessElement) + ? resourceAccessElement.EnumerateArray().Select(access => access.GetRawText()).ToList() + : []; + bool roleExists = resourceAccessElement.ValueKind == JsonValueKind.Array && + resourceAccessElement.EnumerateArray().Any(access => + access.TryGetProperty("id", out JsonElement id) && + string.Equals(id.GetString(), requiredRole.AppRoleId, StringComparison.OrdinalIgnoreCase)); + if (!roleExists) + { + resourceAccess.Add(CreateRequiredResourceAccessEntryJson(requiredRole)); + } + + return string.Join(",", resourceAccess); + } + + private static string CreateRequiredGraphResourceAccessJson(GraphAppRole requiredRole) + { + string resourceAccessJson = CreateRequiredResourceAccessEntryJson(requiredRole); + return $$""" + { + "resourceAppId": "{{GraphAppId}}", + "resourceAccess": [ + {{resourceAccessJson}} + ] + } + """; + } + + private static string CreateRequiredResourceAccessEntryJson(GraphAppRole requiredRole) + { + return $$""" + { + "id": "{{requiredRole.AppRoleId}}", + "type": "Role" + } + """; + } + + private static async Task FindServicePrincipalAsync( + string accessToken, + string clientId, + GraphAppRole requiredRole, + CancellationToken cancellationToken) + { + using JsonDocument document = await SendGraphRequestAsync( + accessToken, + HttpMethod.Get, + $"v1.0/servicePrincipals?$filter=appId eq '{clientId}'&{ServicePrincipalSelect}", + null, + cancellationToken).ConfigureAwait(false); + JsonElement servicePrincipal = document.RootElement.GetProperty("value").EnumerateArray().FirstOrDefault(); + if (servicePrincipal.ValueKind == JsonValueKind.Undefined) + { + return null; + } + + return await ParseServicePrincipalAsync(accessToken, servicePrincipal, requiredRole, cancellationToken).ConfigureAwait(false); + } + + private static async Task CreateServicePrincipalAsync( + string accessToken, + string clientId, + GraphAppRole requiredRole, + CancellationToken cancellationToken) + { + string body = $$""" + { + "appId": "{{clientId}}" + } + """; + using JsonDocument document = await SendGraphRequestAsync( + accessToken, + HttpMethod.Post, + "v1.0/servicePrincipals", + body, + cancellationToken).ConfigureAwait(false); + return await ParseServicePrincipalAsync(accessToken, document.RootElement, requiredRole, cancellationToken).ConfigureAwait(false); + } + + private static async Task EnsureAdminConsentAsync( + string accessToken, + AutopilotGraphServicePrincipal servicePrincipal, + GraphAppRole requiredRole, + CancellationToken cancellationToken) + { + if (servicePrincipal.ConsentedPermissionValues.Contains(requiredRole.Value)) + { + return servicePrincipal; + } + + string body = $$""" + { + "principalId": "{{servicePrincipal.ObjectId}}", + "resourceId": "{{requiredRole.ResourceServicePrincipalId}}", + "appRoleId": "{{requiredRole.AppRoleId}}" + } + """; + try + { + await SendGraphNoContentAsync( + accessToken, + HttpMethod.Post, + $"v1.0/servicePrincipals/{servicePrincipal.ObjectId}/appRoleAssignments", + body, + cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized) + { + return servicePrincipal; + } + + return servicePrincipal with + { + ConsentedPermissionValues = AutopilotGraphPermissionCatalog.RequiredWinPeApplicationPermissionValues + }; + } + + private static async Task> GetApplicationKeyCredentialsAsync( + string accessToken, + string applicationObjectId, + CancellationToken cancellationToken) + { + using JsonDocument document = await SendGraphRequestAsync( + accessToken, + HttpMethod.Get, + $"v1.0/applications/{applicationObjectId}?$select=keyCredentials", + null, + cancellationToken).ConfigureAwait(false); + return ParseKeyCredentials(document.RootElement); + } + + private static async Task AddKeyCredentialAsync( + string accessToken, + string applicationObjectId, + string newKeyId, + string newCredentialJson, + CancellationToken cancellationToken) + { + using JsonDocument document = await SendGraphRequestAsync( + accessToken, + HttpMethod.Get, + $"v1.0/applications/{applicationObjectId}?$select=keyCredentials", + null, + cancellationToken).ConfigureAwait(false); + + string existingCredentialsJson = document.RootElement.TryGetProperty("keyCredentials", out JsonElement keyCredentials) + ? string.Join( + ",", + keyCredentials.EnumerateArray().Where(credential => + !credential.TryGetProperty("keyId", out JsonElement existingKeyId) || + !string.Equals(existingKeyId.GetString(), newKeyId, StringComparison.OrdinalIgnoreCase)) + .Select(credential => credential.GetRawText())) + : string.Empty; + string separator = string.IsNullOrWhiteSpace(existingCredentialsJson) ? string.Empty : ","; + string body = $$""" + { + "keyCredentials": [ + {{existingCredentialsJson}}{{separator}}{{newCredentialJson}} + ] + } + """; + + await SendGraphNoContentAsync( + accessToken, + HttpMethod.Patch, + $"v1.0/applications/{applicationObjectId}", + body, + cancellationToken).ConfigureAwait(false); + } + + private static async Task RemoveKeyCredentialAsync( + string accessToken, + string applicationObjectId, + string keyId, + CancellationToken cancellationToken) + { + using JsonDocument document = await SendGraphRequestAsync( + accessToken, + HttpMethod.Get, + $"v1.0/applications/{applicationObjectId}?$select=keyCredentials", + null, + cancellationToken).ConfigureAwait(false); + + string retainedCredentialsJson = document.RootElement.TryGetProperty("keyCredentials", out JsonElement keyCredentials) + ? string.Join( + ",", + keyCredentials.EnumerateArray().Where(credential => + !credential.TryGetProperty("keyId", out JsonElement existingKeyId) || + !string.Equals(existingKeyId.GetString(), keyId, StringComparison.OrdinalIgnoreCase)) + .Select(credential => credential.GetRawText())) + : string.Empty; + string body = $$""" + { + "keyCredentials": [ + {{retainedCredentialsJson}} + ] + } + """; + + await SendGraphNoContentAsync( + accessToken, + HttpMethod.Patch, + $"v1.0/applications/{applicationObjectId}", + body, + cancellationToken).ConfigureAwait(false); + } + + private async Task TryGetGroupTagsAsync(string accessToken, CancellationToken cancellationToken) + { + try + { + return await GetGroupTagsAsync(accessToken, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException ex) + { + logger.Warning( + "Autopilot group tag discovery failed and will be skipped. StatusCode={StatusCode}", + ex.StatusCode); + return null; + } + } + + private static async Task GetGroupTagsAsync(string accessToken, CancellationToken cancellationToken) + { + List groupTags = []; + string? requestPath = GroupTagRequestPath; + while (!string.IsNullOrWhiteSpace(requestPath)) + { + using JsonDocument document = await SendGraphRequestAsync( + accessToken, + HttpMethod.Get, + requestPath, + null, + cancellationToken).ConfigureAwait(false); + + foreach (JsonElement item in document.RootElement.GetProperty("value").EnumerateArray()) + { + if (item.TryGetProperty("groupTag", out JsonElement groupTag) && + groupTag.GetString()?.Trim() is { Length: > 0 } value) + { + groupTags.Add(value); + } + } + + requestPath = document.RootElement.TryGetProperty("@odata.nextLink", out JsonElement nextLink) + ? nextLink.GetString() + : null; + } + + return groupTags + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(groupTag => groupTag, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static async Task SendGraphRequestAsync( + string accessToken, + HttpMethod method, + string requestPath, + string? jsonBody, + CancellationToken cancellationToken) + { + using HttpResponseMessage response = await SendGraphRawAsync( + accessToken, + method, + requestPath, + jsonBody, + cancellationToken).ConfigureAwait(false); + string responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return JsonDocument.Parse(responseBody); + } + + private static async Task SendGraphNoContentAsync( + string accessToken, + HttpMethod method, + string requestPath, + string? jsonBody, + CancellationToken cancellationToken) + { + using HttpResponseMessage response = await SendGraphRawAsync( + accessToken, + method, + requestPath, + jsonBody, + cancellationToken).ConfigureAwait(false); + } + + private static async Task SendGraphRawAsync( + string accessToken, + HttpMethod method, + string requestPath, + string? jsonBody, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(method, requestPath); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + if (jsonBody is not null) + { + request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); + } + + HttpResponseMessage response = await GraphHttpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + string? graphError = await TryReadGraphErrorAsync(response, cancellationToken).ConfigureAwait(false); + string message = string.IsNullOrWhiteSpace(graphError) + ? $"Microsoft Graph request failed for '{method} {requestPath}' with status code {(int)response.StatusCode}." + : $"Microsoft Graph request failed for '{method} {requestPath}' with status code {(int)response.StatusCode}: {graphError}."; + throw new HttpRequestException(message, null, response.StatusCode); + } + + return response; + } + + private static AutopilotGraphApplication ParseApplication(JsonElement application, GraphAppRole requiredRole) + { + string objectId = application.GetProperty("id").GetString() + ?? throw new InvalidOperationException("Application object ID was not returned."); + string clientId = application.GetProperty("appId").GetString() + ?? throw new InvalidOperationException("Application client ID was not returned."); + string displayName = application.GetProperty("displayName").GetString() ?? string.Empty; + HashSet requiredPermissionValues = new(StringComparer.OrdinalIgnoreCase); + + if (application.TryGetProperty("requiredResourceAccess", out JsonElement requiredResourceAccess)) + { + foreach (JsonElement resourceAccess in requiredResourceAccess.EnumerateArray()) + { + if (resourceAccess.TryGetProperty("resourceAccess", out JsonElement permissions)) + { + foreach (JsonElement permission in permissions.EnumerateArray()) + { + if (permission.TryGetProperty("id", out JsonElement id) && + string.Equals(id.GetString(), requiredRole.AppRoleId, StringComparison.OrdinalIgnoreCase)) + { + requiredPermissionValues.Add(requiredRole.Value); + } + } + } + } + } + + return new AutopilotGraphApplication(objectId, clientId, displayName, requiredPermissionValues); + } + + private static async Task ParseServicePrincipalAsync( + string accessToken, + JsonElement servicePrincipal, + GraphAppRole requiredRole, + CancellationToken cancellationToken) + { + string objectId = servicePrincipal.GetProperty("id").GetString() + ?? throw new InvalidOperationException("Service principal object ID was not returned."); + bool isEnabled = !servicePrincipal.TryGetProperty("accountEnabled", out JsonElement accountEnabled) || + accountEnabled.GetBoolean(); + HashSet consentedPermissionValues = new(StringComparer.OrdinalIgnoreCase); + using JsonDocument assignments = await SendGraphRequestAsync( + accessToken, + HttpMethod.Get, + $"v1.0/servicePrincipals/{objectId}/appRoleAssignments?$select=appRoleId", + null, + cancellationToken).ConfigureAwait(false); + foreach (JsonElement assignment in assignments.RootElement.GetProperty("value").EnumerateArray()) + { + if (assignment.TryGetProperty("appRoleId", out JsonElement appRoleId) && + string.Equals(appRoleId.GetString(), requiredRole.AppRoleId, StringComparison.OrdinalIgnoreCase)) + { + consentedPermissionValues.Add(requiredRole.Value); + } + } + + return new AutopilotGraphServicePrincipal(objectId, isEnabled, consentedPermissionValues); + } + + private static IReadOnlyList ParseKeyCredentials(JsonElement application) + { + if (!application.TryGetProperty("keyCredentials", out JsonElement keyCredentials)) + { + return []; + } + + List credentials = []; + foreach (JsonElement credential in keyCredentials.EnumerateArray()) + { + string keyId = credential.GetProperty("keyId").GetString() ?? string.Empty; + string displayName = credential.TryGetProperty("displayName", out JsonElement displayNameElement) + ? displayNameElement.GetString() ?? string.Empty + : string.Empty; + string thumbprint = credential.TryGetProperty("customKeyIdentifier", out JsonElement customKeyIdentifier) && + customKeyIdentifier.GetString() is { Length: > 0 } base64Thumbprint + ? Convert.ToHexString(Convert.FromBase64String(base64Thumbprint)) + : string.Empty; + DateTimeOffset startsOnUtc = credential.TryGetProperty("startDateTime", out JsonElement startDateTime) && + startDateTime.GetDateTimeOffset() is { } start + ? start + : DateTimeOffset.MinValue; + DateTimeOffset expiresOnUtc = credential.TryGetProperty("endDateTime", out JsonElement endDateTime) && + endDateTime.GetDateTimeOffset() is { } end + ? end + : DateTimeOffset.MinValue; + credentials.Add(new AutopilotGraphKeyCredential(keyId, displayName, thumbprint, startsOnUtc, expiresOnUtc)); + } + + return credentials; + } + + private static string? ResolveDefaultGroupTag(string? currentDefaultGroupTag, string[] groupTags) + { + if (!string.IsNullOrWhiteSpace(currentDefaultGroupTag) && + groupTags.Contains(currentDefaultGroupTag.Trim(), StringComparer.OrdinalIgnoreCase)) + { + return currentDefaultGroupTag.Trim(); + } + + return null; + } + + private static string[] NormalizeGroupTags(IReadOnlyList groupTags) + { + return groupTags + .Where(groupTag => !string.IsNullOrWhiteSpace(groupTag)) + .Select(groupTag => groupTag.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(groupTag => groupTag, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private void DeletePfxOutputAfterFailedGraphUpload(string pfxOutputPath) + { + try + { + if (File.Exists(pfxOutputPath)) + { + File.Delete(pfxOutputPath); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.Warning(ex, "Failed to remove generated PFX after Graph certificate upload failed."); + } + } + + private static string CreateKeyCredentialJson( + string keyId, + X509Certificate2 certificate, + DateTimeOffset startsOnUtc, + DateTimeOffset expiresOnUtc) + { + string certificateRawData = Convert.ToBase64String(certificate.RawData); + string customKeyIdentifier = Convert.ToBase64String(certificate.GetCertHash()); + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + writer.WriteString("customKeyIdentifier", customKeyIdentifier); + writer.WriteString("displayName", AutopilotHardwareHashUploadSettings.ManagedAppRegistrationDisplayName); + writer.WriteString("endDateTime", FormatGraphUtcDateTime(expiresOnUtc)); + writer.WriteString("key", certificateRawData); + writer.WriteString("keyId", keyId); + writer.WriteString("startDateTime", FormatGraphUtcDateTime(startsOnUtc)); + writer.WriteString("type", "AsymmetricX509Cert"); + writer.WriteString("usage", "Verify"); + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + + private static string FormatGraphUtcDateTime(DateTimeOffset value) + { + return value.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture); + } + + private static async Task TryReadGraphErrorAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + string responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(responseBody)) + { + return null; + } + + try + { + using JsonDocument document = JsonDocument.Parse(responseBody); + if (document.RootElement.TryGetProperty("error", out JsonElement error)) + { + string? code = error.TryGetProperty("code", out JsonElement codeElement) + ? codeElement.GetString() + : null; + string? message = error.TryGetProperty("message", out JsonElement messageElement) + ? messageElement.GetString() + : null; + + return string.Join( + ": ", + new[] { code, message } + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value!.Trim())); + } + } + catch (JsonException) + { + return responseBody.Length <= 500 + ? responseBody + : responseBody[..500]; + } + + return null; + } + + private static string GeneratePfxPassword() + { + byte[] bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + private sealed record GraphAppRole( + string ResourceServicePrincipalId, + string AppRoleId, + string Value); +} diff --git a/src/Foundry/Services/Autopilot/AutopilotTenantOperationDialogService.cs b/src/Foundry/Services/Autopilot/AutopilotTenantOperationDialogService.cs new file mode 100644 index 00000000..d9e11416 --- /dev/null +++ b/src/Foundry/Services/Autopilot/AutopilotTenantOperationDialogService.cs @@ -0,0 +1,174 @@ +using Foundry.Services.Localization; + +namespace Foundry.Services.Autopilot; + +/// +/// Coordinates shared Microsoft Graph tenant operation progress and cancellation dialogs. +/// +public sealed class AutopilotTenantOperationDialogService( + IApplicationLocalizationService localizationService) : IAutopilotTenantOperationDialogService +{ + /// + public Task RunAsync( + string title, + string message, + Func> operationAsync) + where TResult : class + { + ArgumentException.ThrowIfNullOrWhiteSpace(title); + ArgumentException.ThrowIfNullOrWhiteSpace(message); + ArgumentNullException.ThrowIfNull(operationAsync); + + return RunWithDialogAsync( + title, + message, + localizationService.GetString("Common.Cancel"), + operationAsync); + } + + private static async Task RunWithDialogAsync( + string title, + string message, + string cancelText, + Func> operationAsync) + where TResult : class + { + using var cancellationTokenSource = new CancellationTokenSource(); + ContentDialog dialog = CreateDialog(title, message, cancelText, cancellationTokenSource); + TaskCompletionSource dialogOpenedTask = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource dialogCanceledTask = new(TaskCreationOptions.RunContinuationsAsynchronously); + dialog.Opened += OnDialogOpened; + dialog.Closing += OnDialogClosing; + + try + { + Task dialogTask = dialog.ShowAsync().AsTask(); + Task completedBeforeOpenTask = await Task.WhenAny(dialogOpenedTask.Task, dialogCanceledTask.Task, dialogTask); + if (completedBeforeOpenTask != dialogOpenedTask.Task) + { + cancellationTokenSource.Cancel(); + await ObserveDialogTaskAsync(dialogTask); + return default; + } + + Task operationTask = Task.Run( + () => operationAsync(cancellationTokenSource.Token), + cancellationTokenSource.Token); + Task completedTask = await Task.WhenAny(operationTask, dialogCanceledTask.Task, dialogTask); + if (completedTask != operationTask) + { + cancellationTokenSource.Cancel(); + _ = ObserveTaskAsync(operationTask); + await CloseDialogWithoutBlockingAsync(dialog, dialogTask); + return default; + } + + try + { + TResult result = await operationTask; + await CloseDialogWithoutBlockingAsync(dialog, dialogTask); + return result; + } + catch (OperationCanceledException) when (cancellationTokenSource.IsCancellationRequested) + { + await CloseDialogWithoutBlockingAsync(dialog, dialogTask); + return default; + } + catch + { + await CloseDialogWithoutBlockingAsync(dialog, dialogTask); + throw; + } + } + finally + { + dialog.Opened -= OnDialogOpened; + dialog.Closing -= OnDialogClosing; + } + + void OnDialogOpened(ContentDialog sender, ContentDialogOpenedEventArgs args) + { + dialogOpenedTask.TrySetResult(); + } + + void OnDialogClosing(ContentDialog sender, ContentDialogClosingEventArgs args) + { + cancellationTokenSource.Cancel(); + dialogCanceledTask.TrySetResult(); + } + } + + private static ContentDialog CreateDialog(string title, string message, string cancelText, CancellationTokenSource cancellationTokenSource) + { + var dialog = new ContentDialog + { + XamlRoot = App.MainWindow.Content.XamlRoot, + Title = title, + Content = CreateDialogContent(message), + CloseButtonText = cancelText, + DefaultButton = ContentDialogButton.Close + }; + + dialog.CloseButtonClick += (_, _) => cancellationTokenSource.Cancel(); + return dialog; + } + + private static FrameworkElement CreateDialogContent(string message) + { + return new StackPanel + { + MinWidth = 360, + Spacing = 16, + Children = + { + new Microsoft.UI.Xaml.Controls.ProgressRing + { + Width = 48, + Height = 48, + IsActive = true, + HorizontalAlignment = HorizontalAlignment.Center + }, + new TextBlock + { + Text = message, + TextWrapping = TextWrapping.Wrap, + TextAlignment = TextAlignment.Center + } + } + }; + } + + private static async Task ObserveTaskAsync(Task task) + { + try + { + await task.ConfigureAwait(false); + } + catch + { + // The user canceled the dialog; late task completion is intentionally ignored. + } + } + + private static async Task ObserveDialogTaskAsync(Task dialogTask) + { + try + { + await dialogTask; + } + catch + { + // Dialog teardown after cancellation should not keep the tenant operation alive. + } + } + + private static async Task CloseDialogWithoutBlockingAsync(ContentDialog dialog, Task dialogTask) + { + if (!dialogTask.IsCompleted) + { + dialog.Hide(); + } + + await Task.WhenAny(dialogTask, Task.Delay(TimeSpan.FromSeconds(2))); + } +} diff --git a/src/Foundry/Services/Autopilot/AutopilotTenantProfileService.cs b/src/Foundry/Services/Autopilot/AutopilotTenantProfileService.cs index 58ecb480..15bef8db 100644 --- a/src/Foundry/Services/Autopilot/AutopilotTenantProfileService.cs +++ b/src/Foundry/Services/Autopilot/AutopilotTenantProfileService.cs @@ -15,11 +15,6 @@ namespace Foundry.Services.Autopilot; /// public sealed class AutopilotTenantProfileService(ILogger logger) : IAutopilotTenantProfileService { - private const string DefaultClientId = "83eb3a92-030d-49b7-881b-32a1eb3e110a"; - private const string ClientIdEnvironmentVariableName = "FOUNDRY_AUTOPILOT_GRAPH_CLIENT_ID"; - private const string TenantIdEnvironmentVariableName = "FOUNDRY_AUTOPILOT_GRAPH_TENANT_ID"; - private const string DefaultTenantId = "common"; - private const string DefaultRedirectUri = "http://localhost"; private const string OrganizationRequestPath = "v1.0/organization?$select=id,verifiedDomains"; private const string AutopilotProfilesRequestPath = "beta/deviceManagement/windowsAutopilotDeploymentProfiles"; private const string TenantDownloadSource = "Tenant download"; @@ -133,20 +128,15 @@ public async Task> DownloadFromTenantAsy private static TokenCredential CreateCredential() { - string clientId = Environment.GetEnvironmentVariable(ClientIdEnvironmentVariableName)?.Trim() - ?? DefaultClientId; - string? tenantId = Environment.GetEnvironmentVariable(TenantIdEnvironmentVariableName); + string clientId = Environment.GetEnvironmentVariable(AutopilotGraphAuthenticationDefaults.ClientIdEnvironmentVariableName)?.Trim() + ?? AutopilotGraphAuthenticationDefaults.FoundryBootstrapClientId; + string? tenantId = Environment.GetEnvironmentVariable(AutopilotGraphAuthenticationDefaults.TenantIdEnvironmentVariableName); return new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions { ClientId = clientId, - TenantId = string.IsNullOrWhiteSpace(tenantId) ? DefaultTenantId : tenantId.Trim(), - RedirectUri = new Uri(DefaultRedirectUri, UriKind.Absolute), - TokenCachePersistenceOptions = new TokenCachePersistenceOptions - { - // Keep Graph auth reusable for Foundry without sharing a token cache name with unrelated tools. - Name = "FoundryAutopilotGraph" - } + TenantId = string.IsNullOrWhiteSpace(tenantId) ? AutopilotGraphAuthenticationDefaults.DefaultTenantId : tenantId.Trim(), + RedirectUri = new Uri(AutopilotGraphAuthenticationDefaults.RedirectUri, UriKind.Absolute) }); } diff --git a/src/Foundry/Services/Autopilot/IAutopilotCertificateDialogService.cs b/src/Foundry/Services/Autopilot/IAutopilotCertificateDialogService.cs new file mode 100644 index 00000000..80dd7101 --- /dev/null +++ b/src/Foundry/Services/Autopilot/IAutopilotCertificateDialogService.cs @@ -0,0 +1,14 @@ +namespace Foundry.Services.Autopilot; + +/// +/// Shows Autopilot certificate dialogs that require richer selectable content. +/// +public interface IAutopilotCertificateDialogService +{ + /// + /// Shows the one-time generated PFX password after certificate creation. + /// + /// Operator-selected PFX output path. + /// Generated PFX password. + Task ShowCreatedAsync(string pfxOutputPath, string password); +} diff --git a/src/Foundry/Services/Autopilot/IAutopilotHardwareHashGraphSessionService.cs b/src/Foundry/Services/Autopilot/IAutopilotHardwareHashGraphSessionService.cs new file mode 100644 index 00000000..60052923 --- /dev/null +++ b/src/Foundry/Services/Autopilot/IAutopilotHardwareHashGraphSessionService.cs @@ -0,0 +1,26 @@ +namespace Foundry.Services.Autopilot; + +/// +/// Stores the current app-session Microsoft Graph credential used by Autopilot hardware hash onboarding actions. +/// +public interface IAutopilotHardwareHashGraphSessionService +{ + /// + /// Starts or refreshes the interactive Microsoft Graph session. + /// + /// Token that cancels authentication. + /// A Microsoft Graph access token for the configured hardware hash onboarding scopes. + Task ConnectAsync(CancellationToken cancellationToken = default); + + /// + /// Gets an access token from the current app session without creating a new tenant connection. + /// + /// Token that cancels token refresh. + /// A Microsoft Graph access token for the configured hardware hash onboarding scopes. + Task GetAccessTokenAsync(CancellationToken cancellationToken = default); + + /// + /// Clears the current app-session credential and cached token. + /// + void Disconnect(); +} diff --git a/src/Foundry/Services/Autopilot/IAutopilotHardwareHashSessionState.cs b/src/Foundry/Services/Autopilot/IAutopilotHardwareHashSessionState.cs new file mode 100644 index 00000000..2b35ad40 --- /dev/null +++ b/src/Foundry/Services/Autopilot/IAutopilotHardwareHashSessionState.cs @@ -0,0 +1,35 @@ +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Autopilot; + +namespace Foundry.Services.Autopilot; + +/// +/// Stores Autopilot hardware hash state that is valid only for the current app session. +/// +public interface IAutopilotHardwareHashSessionState +{ + /// + /// Gets or sets whether the tenant was connected during the current app session. + /// + bool HasConnectedTenant { get; set; } + + /// + /// Gets or sets the last tenant onboarding status from the current app session. + /// + AutopilotTenantOnboardingStatus? TenantOnboardingStatus { get; set; } + + /// + /// Gets or sets the session-only PFX selected for boot media generation. + /// + AutopilotBootMediaCertificateSettings BootMediaCertificate { get; set; } + + /// + /// Gets or sets the certificate credentials discovered from Graph in the current app session. + /// + IReadOnlyList Certificates { get; set; } + + /// + /// Clears tenant, certificate table, and boot media PFX state without touching persisted settings. + /// + void ClearTenantConnection(); +} diff --git a/src/Foundry/Services/Autopilot/IAutopilotTenantDownloadDialogService.cs b/src/Foundry/Services/Autopilot/IAutopilotTenantDownloadDialogService.cs deleted file mode 100644 index fa879dff..00000000 --- a/src/Foundry/Services/Autopilot/IAutopilotTenantDownloadDialogService.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Foundry.Core.Models.Configuration; - -namespace Foundry.Services.Autopilot; - -/// -/// Shows the tenant profile download dialog and coordinates cancellation with the download operation. -/// -public interface IAutopilotTenantDownloadDialogService -{ - /// - /// Runs a profile download through a modal dialog. - /// - /// Delegate that downloads profiles using the dialog-owned cancellation token. - /// Downloaded profiles, or when the dialog is canceled. - Task?> DownloadAsync( - Func>> downloadProfilesAsync); -} diff --git a/src/Foundry/Services/Autopilot/IAutopilotTenantOnboardingService.cs b/src/Foundry/Services/Autopilot/IAutopilotTenantOnboardingService.cs new file mode 100644 index 00000000..e3fafdfa --- /dev/null +++ b/src/Foundry/Services/Autopilot/IAutopilotTenantOnboardingService.cs @@ -0,0 +1,46 @@ +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Autopilot; + +namespace Foundry.Services.Autopilot; + +/// +/// Coordinates interactive tenant onboarding for Autopilot hardware hash upload. +/// +public interface IAutopilotTenantOnboardingService +{ + /// + /// Connects to Microsoft Graph, creates or reuses the managed app registration, and returns the updated settings. + /// + /// Current persisted hardware hash upload settings. + /// Token that cancels Graph requests. + /// The tenant onboarding result and sanitized settings to persist. + Task ConnectAsync( + AutopilotHardwareHashUploadSettings currentSettings, + CancellationToken cancellationToken = default); + + /// + /// Creates a password-protected PFX certificate, adds its public credential to the managed app, and selects it as active. + /// + /// Current persisted hardware hash upload settings. + /// Operator-selected PFX output path. + /// Certificate validity duration in months. + /// Token that cancels Graph requests. + /// The certificate creation result and updated persistent settings. + Task CreateCertificateAsync( + AutopilotHardwareHashUploadSettings currentSettings, + string pfxOutputPath, + int validityMonths, + CancellationToken cancellationToken = default); + + /// + /// Removes the selected certificate credential from the managed app registration. + /// + /// Current persisted hardware hash upload settings. + /// Microsoft Graph key credential identifier to remove. + /// Token that cancels authentication and Graph requests. + /// Updated settings and app registration certificate credentials. + Task RemoveCertificateAsync( + AutopilotHardwareHashUploadSettings currentSettings, + string certificateKeyId, + CancellationToken cancellationToken = default); +} diff --git a/src/Foundry/Services/Autopilot/IAutopilotTenantOperationDialogService.cs b/src/Foundry/Services/Autopilot/IAutopilotTenantOperationDialogService.cs new file mode 100644 index 00000000..faf891f7 --- /dev/null +++ b/src/Foundry/Services/Autopilot/IAutopilotTenantOperationDialogService.cs @@ -0,0 +1,21 @@ +namespace Foundry.Services.Autopilot; + +/// +/// Shows the shared tenant Microsoft Graph operation dialog and coordinates cancellation with the operation. +/// +public interface IAutopilotTenantOperationDialogService +{ + /// + /// Runs a tenant Graph operation through the shared tenant sign-in dialog. + /// + /// Operation result type. + /// Dialog title. + /// Dialog message. + /// Delegate that runs using the dialog-owned cancellation token. + /// The operation result, or when the dialog is canceled. + Task RunAsync( + string title, + string message, + Func> operationAsync) + where TResult : class; +} diff --git a/src/Foundry/Services/Configuration/FoundryConfigurationStateService.cs b/src/Foundry/Services/Configuration/FoundryConfigurationStateService.cs index 7a8ed185..096e6672 100644 --- a/src/Foundry/Services/Configuration/FoundryConfigurationStateService.cs +++ b/src/Foundry/Services/Configuration/FoundryConfigurationStateService.cs @@ -1,6 +1,7 @@ using Foundry.Core.Models.Configuration; using Foundry.Core.Services.Configuration; using Foundry.Core.Services.WinPe; +using Foundry.Services.Autopilot; using Foundry.Telemetry; using Serilog; using AppSettingsService = Foundry.Services.Settings.IAppSettingsService; @@ -20,6 +21,7 @@ internal sealed class FoundryConfigurationStateService : IFoundryConfigurationSt private readonly IDeployConfigurationGenerator deployConfigurationGenerator; private readonly IConnectConfigurationGenerator connectConfigurationGenerator; private readonly INetworkSecretStateService networkSecretStateService; + private readonly IAutopilotHardwareHashSessionState autopilotHardwareHashSessionState; private readonly AppSettingsService appSettingsService; private readonly ILogger logger; @@ -28,6 +30,7 @@ public FoundryConfigurationStateService( IDeployConfigurationGenerator deployConfigurationGenerator, IConnectConfigurationGenerator connectConfigurationGenerator, INetworkSecretStateService networkSecretStateService, + IAutopilotHardwareHashSessionState autopilotHardwareHashSessionState, AppSettingsService appSettingsService, ILogger logger) { @@ -35,6 +38,7 @@ public FoundryConfigurationStateService( this.deployConfigurationGenerator = deployConfigurationGenerator; this.connectConfigurationGenerator = connectConfigurationGenerator; this.networkSecretStateService = networkSecretStateService; + this.autopilotHardwareHashSessionState = autopilotHardwareHashSessionState; this.appSettingsService = appSettingsService; this.logger = logger.ForContext(); Current = SanitizeForPersistence(Load()); @@ -78,15 +82,24 @@ public bool IsDeployConfigurationReady public bool IsAutopilotEnabled => Current.Autopilot.IsEnabled; /// - public bool IsAutopilotConfigurationReady => !Current.Autopilot.IsEnabled || GetSelectedAutopilotProfile() is not null; + public bool IsAutopilotConfigurationReady => AutopilotConfigurationValidation.IsReady; /// - public string? SelectedAutopilotProfileDisplayName => Current.Autopilot.IsEnabled + public AutopilotConfigurationValidationResult AutopilotConfigurationValidation => + AutopilotConfigurationValidator.Evaluate(CreateAutopilotSettingsForValidation(Current.Autopilot), DateTimeOffset.UtcNow); + + /// + public AutopilotProvisioningMode AutopilotProvisioningMode => Current.Autopilot.ProvisioningMode; + + /// + public string? SelectedAutopilotProfileDisplayName => Current.Autopilot.IsEnabled && + Current.Autopilot.ProvisioningMode == AutopilotProvisioningMode.JsonProfile ? GetSelectedAutopilotProfile()?.DisplayName : null; /// - public string? SelectedAutopilotProfileFolderName => Current.Autopilot.IsEnabled + public string? SelectedAutopilotProfileFolderName => Current.Autopilot.IsEnabled && + Current.Autopilot.ProvisioningMode == AutopilotProvisioningMode.JsonProfile ? GetSelectedAutopilotProfile()?.FolderName : null; @@ -148,9 +161,7 @@ public void UpdateTelemetry(TelemetrySettings settings) /// public string GenerateDeployConfigurationJson(TelemetrySettings? telemetryOverride = null) { - FoundryConfigurationDocument document = telemetryOverride is null - ? Current - : Current with { Telemetry = telemetryOverride }; + FoundryConfigurationDocument document = CreateDocumentForDeployGeneration(telemetryOverride); return deployConfigurationGenerator.Serialize(deployConfigurationGenerator.Generate(document)); } @@ -189,6 +200,26 @@ private FoundryConfigurationDocument Load() } } + private FoundryConfigurationDocument CreateDocumentForDeployGeneration(TelemetrySettings? telemetryOverride) + { + return Current with + { + Autopilot = CreateAutopilotSettingsForValidation(Current.Autopilot), + Telemetry = telemetryOverride ?? Current.Telemetry + }; + } + + private AutopilotSettings CreateAutopilotSettingsForValidation(AutopilotSettings settings) + { + return settings with + { + HardwareHashUpload = settings.HardwareHashUpload with + { + BootMediaCertificate = autopilotHardwareHashSessionState.BootMediaCertificate + } + }; + } + private static FoundryConfigurationDocument CreateDefaultDocument() { return new FoundryConfigurationDocument @@ -248,7 +279,45 @@ private static AutopilotSettings SanitizeAutopilotForPersistence(AutopilotSettin return settings with { DefaultProfileId = defaultProfileId, - Profiles = profiles + Profiles = profiles, + HardwareHashUpload = SanitizeHardwareHashUploadSettings(settings.HardwareHashUpload) + }; + } + + private static AutopilotHardwareHashUploadSettings SanitizeHardwareHashUploadSettings( + AutopilotHardwareHashUploadSettings? settings) + { + if (settings?.Tenant is null) + { + return new AutopilotHardwareHashUploadSettings(); + } + + string[] knownGroupTags = (settings.KnownGroupTags ?? []) + .Select(groupTag => groupTag.Trim()) + .Where(groupTag => !string.IsNullOrWhiteSpace(groupTag)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(groupTag => groupTag, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return settings with + { + Tenant = new AutopilotTenantRegistrationSettings + { + TenantId = NormalizeOptional(settings.Tenant.TenantId), + ApplicationObjectId = NormalizeOptional(settings.Tenant.ApplicationObjectId), + ClientId = NormalizeOptional(settings.Tenant.ClientId), + ServicePrincipalObjectId = NormalizeOptional(settings.Tenant.ServicePrincipalObjectId) + }, + ActiveCertificate = settings.ActiveCertificate is null + ? null + : settings.ActiveCertificate with + { + KeyId = NormalizeOptional(settings.ActiveCertificate.KeyId), + Thumbprint = NormalizeOptional(settings.ActiveCertificate.Thumbprint)?.ToUpperInvariant(), + DisplayName = NormalizeOptional(settings.ActiveCertificate.DisplayName) + }, + KnownGroupTags = knownGroupTags, + DefaultGroupTag = NormalizeOptional(settings.DefaultGroupTag) }; } @@ -277,6 +346,12 @@ private static CustomizationSettings SanitizeCustomizationForPersistence(Customi }; } + private static string? NormalizeOptional(string? value) + { + string? trimmed = value?.Trim(); + return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; + } + private static OobeSettings SanitizeOobeForPersistence(OobeSettings settings) { return settings.IsEnabled diff --git a/src/Foundry/Services/Configuration/IFoundryConfigurationStateService.cs b/src/Foundry/Services/Configuration/IFoundryConfigurationStateService.cs index 988d0fba..da65fe49 100644 --- a/src/Foundry/Services/Configuration/IFoundryConfigurationStateService.cs +++ b/src/Foundry/Services/Configuration/IFoundryConfigurationStateService.cs @@ -1,4 +1,5 @@ using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Configuration; using Foundry.Telemetry; namespace Foundry.Services.Configuration; @@ -43,7 +44,7 @@ public interface IFoundryConfigurationStateService bool AreRequiredSecretsReady { get; } /// - /// Gets a value indicating whether Autopilot staging is enabled. + /// Gets a value indicating whether Autopilot provisioning is enabled. /// bool IsAutopilotEnabled { get; } @@ -52,6 +53,16 @@ public interface IFoundryConfigurationStateService /// bool IsAutopilotConfigurationReady { get; } + /// + /// Gets the detailed Autopilot readiness status for the selected provisioning mode. + /// + AutopilotConfigurationValidationResult AutopilotConfigurationValidation { get; } + + /// + /// Gets the selected Autopilot provisioning mode. + /// + AutopilotProvisioningMode AutopilotProvisioningMode { get; } + /// /// Gets the selected Autopilot profile display name when a single profile is selected. /// diff --git a/src/Foundry/Strings/en-US/Resources.resw b/src/Foundry/Strings/en-US/Resources.resw index f981bff0..ae2fbba2 100644 --- a/src/Foundry/Strings/en-US/Resources.resw +++ b/src/Foundry/Strings/en-US/Resources.resw @@ -169,7 +169,7 @@ Autopilot - Import and select offline Autopilot profiles for deployment media. + Choose the Autopilot provisioning method for deployment media: JSON profile staging or WinPE hardware hash upload. Enable Autopilot @@ -186,6 +186,9 @@ Profile actions + + Import local JSON profiles or download them from Intune. + Importing Autopilot profile... @@ -213,9 +216,15 @@ Default profile + + Profile staged by default in the applied Windows image. + Imported profiles + + Offline Autopilot profiles available for JSON profile provisioning. + No Autopilot profiles imported. @@ -246,9 +255,6 @@ Complete sign-in in the browser to download Autopilot profiles. - - Cancel - No Autopilot profiles found @@ -282,6 +288,261 @@ {0} of {1} profile(s) selected + + JSON profile provisioning + + + Stage an offline Autopilot profile in the applied Windows image. + + + Use JSON profile provisioning + + + Hardware hash upload + + + Upload the device hardware hash to Intune from Foundry Deploy in WinPE. + + + Use hardware hash upload + + + Connect tenant + + + Disconnect tenant + + + Connecting tenant... + + + Sign in to Microsoft Graph + + + Complete sign-in in the browser to configure Autopilot hardware hash upload. + + + Tenant connection + + + Connect to Microsoft Graph for this app session to manage hash upload settings. + + + Not connected + + + Connected + + + Tenant readiness + + + Tenant, app registration, and readiness information for hardware hash upload. + + + Name + + + Value + + + Tenant ID + + + Client ID + + + Managed app registration + + + Foundry OSD Autopilot Registration is not configured. + + + {0} is configured. + + + Status + + + Ready + + + Not ready + + + Certificate actions + + + Create or remove app registration certificate credentials. + + + Provisioned certificates + + + App registration certificates trusted for WinPE Graph authentication. + + + No certificates are provisioned. + + + Create certificate + + + Remove certificate + + + Save Autopilot certificate PFX + + + PFX certificate + + + Certificate ready + + + Save this PFX file and password now in a secure location. Foundry does not store the password and cannot show it again after this dialog closes. You will need both values to generate boot media for hardware hash upload. + + + PFX file + + + PFX password + + + Copy password + + + Password copied. + + + Certificate creation failed + + + Foundry could not create the app registration certificate. {0} + + + Remove selected certificate? + + + Foundry will remove only the selected certificate from the managed app registration. Other app credentials will be preserved. + + + Foundry will remove only the {0} selected certificates from the managed app registration. Other app credentials will be preserved. + + + Remove certificate + + + Certificate removal failed + + + Foundry could not remove the selected app registration certificate. {0} + + + Thumbprint + + + Created + + + Expiration + + + Certificate ID + + + Boot media certificate + + + Password-protected PFX copied into boot media for hardware hash upload. + + + PFX file + + + PFX password + + + Select PFX + + + Select Autopilot certificate PFX + + + Select a PFX that matches an app registration certificate. + + + The selected certificate is expired. Create or select a valid certificate before creating boot media. + + + Select the matching password-protected PFX before creating boot media. + + + The selected PFX file no longer exists. + + + Enter the PFX password. + + + The PFX certificate thumbprint does not match the selected app registration certificate. + + + The selected PFX does not contain private key material. + + + The selected PFX could not be opened with the provided password. + + + Certificate ready for boot media generation. + + + Default group tag + + + Optional group tag preselected in Foundry Deploy during hardware hash upload. + + + No default group tag selected. + + + None + + + None + + + Tenant onboarding requires attention + + + Tenant onboarding failed + + + Foundry could not configure the Autopilot app registration. {0} + + + The managed app registration could not be found. Reconnect to let Foundry create or adopt it. + + + An existing app registration with the Foundry name was found. Reconnect to adopt it into the Foundry configuration. + + + The managed app registration is missing the required Microsoft Graph application permission. Reconnect with an account that can update app registrations. + + + The managed service principal is missing admin consent for the required Microsoft Graph permission. Grant admin consent and reconnect. + + + The managed service principal is missing or disabled. Reconnect with an account that can create or manage enterprise applications. + + + The previously selected certificate no longer exists in the managed app registration. Select a valid PFX that matches a provisioned certificate. + + + The selected certificate is expired. Create a new certificate, save its PFX and password, then select it for boot media. + + + The managed app registration is not ready. Review tenant readiness and reconnect. + Customization @@ -1137,8 +1398,59 @@ Enabled: {0} ({1}) - - Enabled; default profile missing + + Enabled: hardware hash upload + + + Autopilot uses an unsupported provisioning mode. + + + Autopilot JSON profile mode is enabled but no valid default profile is selected. + + + Hardware hash upload is enabled but settings are missing. + + + Hardware hash upload is enabled but the tenant connection is missing. Connect to the tenant from Autopilot. + + + Hardware hash upload is enabled but the app registration is not configured. Connect to the tenant from Autopilot. + + + Hardware hash upload is enabled but the app client ID is missing. Reconnect to the tenant from Autopilot. + + + Hardware hash upload is enabled but the app service principal is missing or not ready. + + + Hardware hash upload is enabled but the selected PFX does not match an app registration certificate. + + + Hardware hash upload is enabled but the selected certificate thumbprint is missing. + + + Hardware hash upload is enabled but the selected certificate expiration is missing. + + + Hardware hash upload is enabled but the selected certificate is expired. Select a valid certificate before creating boot media. + + + Hardware hash upload is enabled but no boot media PFX is selected. + + + Hardware hash upload is enabled but the boot media PFX password is missing. + + + Hardware hash upload is enabled but the boot media PFX has not been validated. + + + Hardware hash upload is enabled but the selected PFX does not match the active certificate. + + + Hardware hash upload is enabled but the boot media PFX expiration could not be validated. + + + Hardware hash upload is enabled but the selected boot media PFX is expired. ADK or WinPE Add-on is not ready. diff --git a/src/Foundry/Strings/fr-FR/Resources.resw b/src/Foundry/Strings/fr-FR/Resources.resw index c95e476b..ca877a0d 100644 --- a/src/Foundry/Strings/fr-FR/Resources.resw +++ b/src/Foundry/Strings/fr-FR/Resources.resw @@ -169,7 +169,7 @@ Autopilot - Importer et sélectionner des profils Autopilot hors ligne pour le média de déploiement. + Choisir la méthode de provisionnement Autopilot pour le média de déploiement : profil JSON ou upload du hardware hash depuis WinPE. Activer Autopilot @@ -186,6 +186,9 @@ Actions de profil + + Importe des profils JSON locaux ou les télécharge depuis Intune. + Import du profil Autopilot... @@ -213,9 +216,15 @@ Profil par défaut + + Profil ajouté par défaut dans l'image Windows appliquée. + Profils importés + + Profils Autopilot hors ligne disponibles pour le provisionnement par profil JSON. + Aucun profil Autopilot importé. @@ -246,9 +255,6 @@ Terminez l'authentification dans le navigateur pour télécharger les profils Autopilot. - - Annuler - Aucun profil Autopilot trouvé @@ -282,6 +288,261 @@ {0} profil(s) sélectionné(s) sur {1} + + Provisionnement par profil JSON + + + Ajoute un profil Autopilot hors ligne dans l'image Windows appliquée. + + + Utiliser le provisionnement par profil JSON + + + Upload du hardware hash + + + Upload le hardware hash de l'appareil vers Intune depuis Foundry Deploy dans WinPE. + + + Utiliser l'upload du hardware hash + + + Se connecter au tenant + + + Se déconnecter du tenant + + + Connexion au tenant... + + + Connexion à Microsoft Graph + + + Terminez la connexion dans le navigateur pour configurer l'upload du hardware hash Autopilot. + + + Connexion tenant + + + Connecte Microsoft Graph pour cette session afin de gérer l'upload du hardware hash. + + + Non connecté + + + Connecté + + + État du tenant + + + Informations du tenant, de l'app registration et de l'état de préparation pour l'upload du hardware hash. + + + Nom + + + Valeur + + + ID du tenant + + + ID client + + + App registration managée + + + Foundry OSD Autopilot Registration n'est pas configurée. + + + {0} est configurée. + + + État + + + Prêt + + + Non prêt + + + Actions certificat + + + Créer ou supprimer les identifiants de certificat de l'app registration. + + + Certificats provisionnés + + + Certificats de l'app registration autorisés pour l'authentification Graph dans WinPE. + + + Aucun certificat n'est provisionné. + + + Créer le certificat + + + Supprimer le certificat + + + Enregistrer le PFX Autopilot + + + Certificat PFX + + + Certificat prêt + + + Enregistrez maintenant ce fichier PFX et ce mot de passe dans un emplacement sécurisé. Foundry ne stocke pas le mot de passe et ne pourra plus l'afficher après la fermeture de cette fenêtre. Ces deux éléments seront nécessaires pour générer le média de boot avec upload du hardware hash. + + + Fichier PFX + + + Mot de passe PFX + + + Copier le mot de passe + + + Mot de passe copié. + + + Échec de création du certificat + + + Foundry n'a pas pu créer le certificat de l'app registration. {0} + + + Supprimer le certificat sélectionné ? + + + Foundry supprimera uniquement le certificat sélectionné dans l'app registration managée. Les autres identifiants de l'app seront conservés. + + + Foundry supprimera uniquement les {0} certificats sélectionnés dans l'app registration managée. Les autres identifiants de l'app seront conservés. + + + Supprimer le certificat + + + Échec de suppression du certificat + + + Foundry n'a pas pu supprimer le certificat sélectionné de l'app registration. {0} + + + Empreinte + + + Création + + + Expiration + + + ID du certificat + + + Certificat du média de démarrage + + + PFX protégé par mot de passe copié dans le média de démarrage pour l'upload du hardware hash. + + + Fichier PFX + + + Mot de passe PFX + + + Sélectionner le PFX + + + Sélectionner le PFX du certificat Autopilot + + + Sélectionnez un PFX qui correspond à un certificat de l'app registration. + + + Le certificat sélectionné est expiré. Créez ou sélectionnez un certificat valide avant de créer le média de démarrage. + + + Sélectionnez le PFX protégé par mot de passe correspondant avant de créer le média de démarrage. + + + Le fichier PFX sélectionné n'existe plus. + + + Renseignez le mot de passe du PFX. + + + L'empreinte du certificat PFX ne correspond pas au certificat sélectionné dans l'app registration. + + + Le PFX sélectionné ne contient pas la clé privée. + + + Le PFX sélectionné ne peut pas être ouvert avec le mot de passe fourni. + + + Le certificat est prêt pour la génération du média de démarrage. + + + Group tag par défaut + + + Group tag optionnel présélectionné dans Foundry Deploy pendant l'upload du hardware hash. + + + Aucun group tag par défaut sélectionné. + + + None + + + Aucun + + + L'onboarding tenant demande une action + + + Échec de l'onboarding tenant + + + Foundry n'a pas pu configurer l'app registration Autopilot. {0} + + + L'app registration managée est introuvable. Reconnectez-vous pour laisser Foundry la créer ou l'adopter. + + + Une app registration existante avec le nom Foundry a été trouvée. Reconnectez-vous pour l'adopter dans la configuration Foundry. + + + Il manque l'autorisation d'application Microsoft Graph requise sur l'app registration managée. Reconnectez-vous avec un compte autorisé à modifier les app registrations. + + + Le service principal managé n'a pas le consentement administrateur pour l'autorisation Microsoft Graph requise. Accordez le consentement administrateur puis reconnectez-vous. + + + Le service principal managé est absent ou désactivé. Reconnectez-vous avec un compte autorisé à créer ou gérer les applications d'entreprise. + + + Le certificat précédemment sélectionné n'existe plus dans l'app registration managée. Sélectionnez un PFX valide correspondant à un certificat provisionné. + + + Le certificat sélectionné est expiré. Créez un nouveau certificat, conservez son PFX et son mot de passe, puis sélectionnez-le pour le média de démarrage. + + + L'app registration managée n'est pas prête. Vérifiez l'état du tenant puis reconnectez-vous. + Personnalisation @@ -1137,8 +1398,59 @@ Activé : {0} ({1}) - - Activé ; profil par défaut manquant + + Activé : upload du hardware hash + + + Autopilot utilise un mode de provisionnement non supporté. + + + Le mode profil JSON Autopilot est activé mais aucun profil par défaut valide n'est sélectionné. + + + L'upload du hardware hash est activé mais les paramètres sont manquants. + + + L'upload du hardware hash est activé mais la connexion au tenant est manquante. Connectez-vous au tenant depuis Autopilot. + + + L'upload du hardware hash est activé mais l'app registration n'est pas configurée. Connectez-vous au tenant depuis Autopilot. + + + L'upload du hardware hash est activé mais l'ID client de l'app est manquant. Reconnectez-vous au tenant depuis Autopilot. + + + L'upload du hardware hash est activé mais le service principal de l'app est manquant ou pas prêt. + + + L'upload du hardware hash est activé mais le PFX sélectionné ne correspond à aucun certificat de l'app registration. + + + L'upload du hardware hash est activé mais l'empreinte du certificat sélectionné est manquante. + + + L'upload du hardware hash est activé mais l'expiration du certificat sélectionné est manquante. + + + L'upload du hardware hash est activé mais le certificat sélectionné est expiré. Sélectionnez un certificat valide avant de créer le média de démarrage. + + + L'upload du hardware hash est activé mais aucun PFX de média de démarrage n'est sélectionné. + + + L'upload du hardware hash est activé mais le mot de passe du PFX de média de démarrage est manquant. + + + L'upload du hardware hash est activé mais le PFX de média de démarrage n'a pas été validé. + + + L'upload du hardware hash est activé mais le PFX sélectionné ne correspond pas au certificat actif. + + + L'upload du hardware hash est activé mais l'expiration du PFX de média de démarrage n'a pas pu être validée. + + + L'upload du hardware hash est activé mais le PFX de média de démarrage sélectionné est expiré. L'ADK ou le module complémentaire WinPE n'est pas prêt. diff --git a/src/Foundry/ViewModels/AutopilotCertificateEntryViewModel.cs b/src/Foundry/ViewModels/AutopilotCertificateEntryViewModel.cs new file mode 100644 index 00000000..45dbc887 --- /dev/null +++ b/src/Foundry/ViewModels/AutopilotCertificateEntryViewModel.cs @@ -0,0 +1,47 @@ +using System.Globalization; +using Foundry.Core.Services.Autopilot; +using Microsoft.UI.Xaml.Media; + +namespace Foundry.ViewModels; + +/// +/// Represents an app registration certificate credential displayed in the Autopilot page. +/// +public sealed record AutopilotCertificateEntryViewModel( + string KeyId, + string Thumbprint, + DateTimeOffset StartsOnUtc, + DateTimeOffset ExpiresOnUtc) +{ + private static readonly TimeSpan ExpirationWarningThreshold = TimeSpan.FromDays(30); + + public string StartsOnDisplay => StartsOnUtc.ToLocalTime().ToString("g", CultureInfo.CurrentCulture); + + public string ExpiresOnDisplay => ExpiresOnUtc.ToLocalTime().ToString("g", CultureInfo.CurrentCulture); + + public Brush ValidityForeground => (Brush)Application.Current.Resources[ResolveValidityBrushKey()]; + + public static AutopilotCertificateEntryViewModel FromGraphCredential(AutopilotGraphKeyCredential credential) + { + ArgumentNullException.ThrowIfNull(credential); + + return new AutopilotCertificateEntryViewModel( + credential.KeyId, + credential.Thumbprint, + credential.StartsOnUtc, + credential.ExpiresOnUtc); + } + + private string ResolveValidityBrushKey() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + if (ExpiresOnUtc <= now) + { + return "SystemFillColorCriticalBrush"; + } + + return ExpiresOnUtc - now <= ExpirationWarningThreshold + ? "SystemFillColorCautionBrush" + : "SystemFillColorSuccessBrush"; + } +} diff --git a/src/Foundry/ViewModels/AutopilotConfigurationViewModel.cs b/src/Foundry/ViewModels/AutopilotConfigurationViewModel.cs index 3fbfe66c..3cd49b15 100644 --- a/src/Foundry/ViewModels/AutopilotConfigurationViewModel.cs +++ b/src/Foundry/ViewModels/AutopilotConfigurationViewModel.cs @@ -2,12 +2,14 @@ using System.Text.Json; using Azure.Identity; using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Autopilot; using Foundry.Core.Services.Application; using Foundry.Core.Services.Configuration; using Foundry.Services.Autopilot; using Foundry.Services.Configuration; using Foundry.Services.Localization; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; using Serilog; namespace Foundry.ViewModels; @@ -20,21 +22,34 @@ public sealed partial class AutopilotConfigurationViewModel : ObservableObject, private readonly IFoundryConfigurationStateService configurationStateService; private readonly IAutopilotProfileImportService autopilotProfileImportService; private readonly IAutopilotTenantProfileService autopilotTenantProfileService; - private readonly IAutopilotTenantDownloadDialogService tenantDownloadDialogService; + private readonly IAutopilotTenantOnboardingService autopilotTenantOnboardingService; + private readonly IAutopilotHardwareHashGraphSessionService hardwareHashGraphSessionService; + private readonly IAutopilotTenantOperationDialogService tenantOperationDialogService; + private readonly IAutopilotCertificateDialogService certificateDialogService; private readonly IAutopilotProfileSelectionDialogService profileSelectionDialogService; + private readonly IAutopilotHardwareHashSessionState hardwareHashSessionState; private readonly IFilePickerService filePickerService; private readonly IDialogService dialogService; private readonly IApplicationLocalizationService localizationService; private readonly ILogger logger; private bool isApplyingState = true; private bool isSavingState; + private AutopilotProvisioningMode provisioningMode = AutopilotProvisioningMode.JsonProfile; + private AutopilotHardwareHashUploadSettings hardwareHashUploadSettings = new(); + private AutopilotTenantOnboardingStatus? tenantOnboardingStatus; + private AutopilotPfxValidationCode bootMediaCertificateValidationCode = AutopilotPfxValidationCode.PfxRequired; + private bool isBootMediaCertificateFileMissing; public AutopilotConfigurationViewModel( IFoundryConfigurationStateService configurationStateService, IAutopilotProfileImportService autopilotProfileImportService, IAutopilotTenantProfileService autopilotTenantProfileService, - IAutopilotTenantDownloadDialogService tenantDownloadDialogService, + IAutopilotTenantOnboardingService autopilotTenantOnboardingService, + IAutopilotHardwareHashGraphSessionService hardwareHashGraphSessionService, + IAutopilotTenantOperationDialogService tenantOperationDialogService, + IAutopilotCertificateDialogService certificateDialogService, IAutopilotProfileSelectionDialogService profileSelectionDialogService, + IAutopilotHardwareHashSessionState hardwareHashSessionState, IFilePickerService filePickerService, IDialogService dialogService, IApplicationLocalizationService localizationService, @@ -43,8 +58,12 @@ public AutopilotConfigurationViewModel( this.configurationStateService = configurationStateService; this.autopilotProfileImportService = autopilotProfileImportService; this.autopilotTenantProfileService = autopilotTenantProfileService; - this.tenantDownloadDialogService = tenantDownloadDialogService; + this.autopilotTenantOnboardingService = autopilotTenantOnboardingService; + this.hardwareHashGraphSessionService = hardwareHashGraphSessionService; + this.tenantOperationDialogService = tenantOperationDialogService; + this.certificateDialogService = certificateDialogService; this.profileSelectionDialogService = profileSelectionDialogService; + this.hardwareHashSessionState = hardwareHashSessionState; this.filePickerService = filePickerService; this.dialogService = dialogService; this.localizationService = localizationService; @@ -57,7 +76,9 @@ public AutopilotConfigurationViewModel( configurationStateService.StateChanged += OnConfigurationStateChanged; Profiles.CollectionChanged += OnProfilesCollectionChanged; SelectedProfiles.CollectionChanged += OnSelectedProfilesCollectionChanged; + SelectedCertificates.CollectionChanged += OnSelectedCertificatesCollectionChanged; isApplyingState = false; + SelectedCertificateValidityOption = CertificateValidityOptions.First(option => option.Months == 6); } /// @@ -70,17 +91,135 @@ public AutopilotConfigurationViewModel( /// public ObservableCollection SelectedProfiles { get; } = []; + /// + /// Gets app registration certificate credentials discovered from Microsoft Graph. + /// + public ObservableCollection Certificates { get; } = []; + + /// + /// Gets the currently selected certificate rows for bulk UI actions. + /// + public ObservableCollection SelectedCertificates { get; } = []; + + /// + /// Gets the fixed certificate validity options available for managed Autopilot app certificates. + /// + public ObservableCollection CertificateValidityOptions { get; } = + [ + new(1, "1 month"), + new(3, "3 months"), + new(6, "6 months"), + new(12, "12 months") + ]; + + /// + /// Gets default group tag choices, including the optional None choice. + /// + public ObservableCollection DefaultGroupTagOptions { get; } = []; + + /// + /// Gets tenant readiness information displayed after a successful tenant connection. + /// + public ObservableCollection TenantReadinessEntries { get; } = []; + public bool IsAutopilotSectionEnabled => IsAutopilotEnabled; public bool HasProfiles => Profiles.Count > 0; public Visibility EmptyProfilesVisibility => HasProfiles ? Visibility.Collapsed : Visibility.Visible; public Visibility ProfilesVisibility => HasProfiles ? Visibility.Visible : Visibility.Collapsed; - public bool IsBusy => IsImporting || IsDownloading; + public bool HasCertificates => Certificates.Count > 0; + public Visibility CertificatesVisibility => HasCertificates ? Visibility.Visible : Visibility.Collapsed; + public Visibility EmptyCertificatesVisibility => HasCertificates ? Visibility.Collapsed : Visibility.Visible; + public Visibility ConnectedTenantDetailsVisibility => HasConnectedTenantInCurrentSession ? Visibility.Visible : Visibility.Collapsed; + public bool IsBusy => IsImporting || IsDownloading || IsConnectingTenant; public Visibility BusyStatusVisibility => IsBusy ? Visibility.Visible : Visibility.Collapsed; public string BusyStatusText => IsImporting ? ImportingStatusText : IsDownloading ? DownloadingStatusText - : string.Empty; + : IsConnectingTenant + ? ConnectingTenantStatusText + : string.Empty; + public bool UseJsonProfileProvisioning + { + get => provisioningMode == AutopilotProvisioningMode.JsonProfile; + set + { + if (value) + { + SetProvisioningMode(AutopilotProvisioningMode.JsonProfile); + } + else if (UseJsonProfileProvisioning && !UseHardwareHashUploadProvisioning) + { + OnPropertyChanged(); + } + } + } + + public bool UseHardwareHashUploadProvisioning + { + get => provisioningMode == AutopilotProvisioningMode.HardwareHashUpload; + set + { + if (value) + { + SetProvisioningMode(AutopilotProvisioningMode.HardwareHashUpload); + } + else if (UseHardwareHashUploadProvisioning && !UseJsonProfileProvisioning) + { + OnPropertyChanged(); + } + } + } + + public bool IsJsonProfileMode => provisioningMode == AutopilotProvisioningMode.JsonProfile; + public bool IsHardwareHashUploadMode => provisioningMode == AutopilotProvisioningMode.HardwareHashUpload; + public bool IsHardwareHashCertificateExpired => hardwareHashUploadSettings.ActiveCertificate?.ExpiresOnUtc is DateTimeOffset expiresOnUtc && + expiresOnUtc <= DateTimeOffset.UtcNow; + public Visibility JsonProfileSettingsVisibility => IsJsonProfileMode ? Visibility.Visible : Visibility.Collapsed; + public Visibility HardwareHashSettingsVisibility => IsHardwareHashUploadMode ? Visibility.Visible : Visibility.Collapsed; + public Visibility BootMediaCertificateVisibility => HasConnectedTenantInCurrentSession && + HasCertificates + ? Visibility.Visible + : Visibility.Collapsed; + public string BootMediaCertificatePfxPath => hardwareHashUploadSettings.BootMediaCertificate.PfxPath ?? string.Empty; + public string BootMediaCertificateStatusText => CreateBootMediaCertificateStatusText(); + public Brush BootMediaCertificateStatusForeground => ResolveBootMediaCertificateStatusBrush(); + public bool IsBootMediaCertificateReady => hardwareHashUploadSettings.BootMediaCertificate.ValidatedExpiresOnUtc is DateTimeOffset expiresOnUtc && + expiresOnUtc > DateTimeOffset.UtcNow && + !string.IsNullOrWhiteSpace(hardwareHashUploadSettings.BootMediaCertificate.PfxPath) && + !string.IsNullOrWhiteSpace(hardwareHashUploadSettings.BootMediaCertificate.PfxPassword) && + string.Equals( + NormalizeThumbprint(hardwareHashUploadSettings.ActiveCertificate?.Thumbprint), + NormalizeThumbprint(hardwareHashUploadSettings.BootMediaCertificate.ValidatedThumbprint), + StringComparison.OrdinalIgnoreCase); + public string ManagedAppRegistrationName => AutopilotHardwareHashUploadSettings.ManagedAppRegistrationDisplayName; + public string TenantStatusText => HasConnectedTenantInCurrentSession && HasTenantRegistration + ? localizationService.GetString("Autopilot.HardwareHashTenantConnected") + : localizationService.GetString("Autopilot.HardwareHashTenantNotConnected"); + public Brush TenantStatusForeground => HasConnectedTenantInCurrentSession && HasTenantRegistration + ? (Brush)Application.Current.Resources["SystemFillColorSuccessBrush"] + : (Brush)Application.Current.Resources["SystemFillColorCriticalBrush"]; + public string TenantConnectionButtonText => HasConnectedTenantInCurrentSession + ? DisconnectTenantButtonText + : ConnectTenantButtonText; + public string AppRegistrationStatusText => string.IsNullOrWhiteSpace(hardwareHashUploadSettings.Tenant.ApplicationObjectId) + ? localizationService.GetString("Autopilot.HardwareHashAppRegistrationMissing") + : localizationService.FormatString("Autopilot.HardwareHashAppRegistrationFoundFormat", ManagedAppRegistrationName); + public string TenantIdText => hardwareHashUploadSettings.Tenant.TenantId ?? string.Empty; + public string ClientIdText => hardwareHashUploadSettings.Tenant.ClientId ?? string.Empty; + public string TenantOnboardingStatusText => CreateTenantOnboardingStatusText(); + public Brush TenantOnboardingStatusForeground => ResolveTenantOnboardingStatusBrush(); + public string DefaultGroupTagText => string.IsNullOrWhiteSpace(hardwareHashUploadSettings.DefaultGroupTag) + ? localizationService.GetString("Autopilot.HardwareHashDefaultGroupTagNone") + : hardwareHashUploadSettings.DefaultGroupTag!; + private bool HasTenantRegistration => !string.IsNullOrWhiteSpace(hardwareHashUploadSettings.Tenant.TenantId) && + !string.IsNullOrWhiteSpace(hardwareHashUploadSettings.Tenant.ClientId); + + private bool HasConnectedTenantInCurrentSession + { + get => hardwareHashSessionState.HasConnectedTenant; + set => hardwareHashSessionState.HasConnectedTenant = value; + } [ObservableProperty] public partial string PageTitle { get; set; } @@ -94,6 +233,129 @@ public AutopilotConfigurationViewModel( [ObservableProperty] public partial string EnableAutopilotText { get; set; } + [ObservableProperty] + public partial string JsonProfileHeader { get; set; } + + [ObservableProperty] + public partial string JsonProfileDescription { get; set; } + + [ObservableProperty] + public partial string JsonProfileEnableText { get; set; } + + [ObservableProperty] + public partial string ActionsDescription { get; set; } + + [ObservableProperty] + public partial string DefaultProfileDescription { get; set; } + + [ObservableProperty] + public partial string ProfilesDescription { get; set; } + + [ObservableProperty] + public partial string HardwareHashHeader { get; set; } + + [ObservableProperty] + public partial string HardwareHashDescription { get; set; } + + [ObservableProperty] + public partial string HardwareHashEnableText { get; set; } + + [ObservableProperty] + public partial string ConnectTenantButtonText { get; set; } + + [ObservableProperty] + public partial string DisconnectTenantButtonText { get; set; } + + [ObservableProperty] + public partial string ConnectingTenantStatusText { get; set; } + + [ObservableProperty] + public partial string CreateCertificateButtonText { get; set; } + + [ObservableProperty] + public partial string RetireCertificateButtonText { get; set; } + + [ObservableProperty] + public partial string TenantStatusLabel { get; set; } + + [ObservableProperty] + public partial string TenantStatusDescription { get; set; } + + [ObservableProperty] + public partial string TenantReadinessLabel { get; set; } + + [ObservableProperty] + public partial string TenantReadinessDescription { get; set; } + + [ObservableProperty] + public partial string TenantReadinessNameColumnHeader { get; set; } + + [ObservableProperty] + public partial string TenantReadinessValueColumnHeader { get; set; } + + [ObservableProperty] + public partial string TenantDetailsTenantIdLabel { get; set; } + + [ObservableProperty] + public partial string TenantDetailsClientIdLabel { get; set; } + + [ObservableProperty] + public partial string AppRegistrationLabel { get; set; } + + [ObservableProperty] + public partial string TenantOnboardingStatusLabel { get; set; } + + [ObservableProperty] + public partial string CertificateActionsLabel { get; set; } + + [ObservableProperty] + public partial string CertificateActionsDescription { get; set; } + + [ObservableProperty] + public partial string ProvisionedCertificatesLabel { get; set; } + + [ObservableProperty] + public partial string ProvisionedCertificatesDescription { get; set; } + + [ObservableProperty] + public partial string EmptyCertificatesText { get; set; } + + [ObservableProperty] + public partial string CertificateThumbprintColumnHeader { get; set; } + + [ObservableProperty] + public partial string CertificateCreatedColumnHeader { get; set; } + + [ObservableProperty] + public partial string CertificateExpiresColumnHeader { get; set; } + + [ObservableProperty] + public partial string CertificateIdColumnHeader { get; set; } + + [ObservableProperty] + public partial string BootMediaCertificateLabel { get; set; } + + [ObservableProperty] + public partial string BootMediaCertificateDescription { get; set; } + + [ObservableProperty] + public partial string BootMediaCertificatePfxPathLabel { get; set; } + + [ObservableProperty] + public partial string BootMediaCertificatePasswordLabel { get; set; } + + [ObservableProperty] + public partial string SelectBootMediaCertificateButtonText { get; set; } + + [ObservableProperty] + public partial string GroupTagLabel { get; set; } + + [ObservableProperty] + public partial string GroupTagDescription { get; set; } + + [ObservableProperty] + public partial string DefaultGroupTagNoneOptionText { get; set; } + [ObservableProperty] public partial string ImportButtonText { get; set; } @@ -147,9 +409,15 @@ public AutopilotConfigurationViewModel( [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsAutopilotSectionEnabled))] + [NotifyPropertyChangedFor(nameof(JsonProfileSettingsVisibility))] + [NotifyPropertyChangedFor(nameof(HardwareHashSettingsVisibility))] [NotifyCanExecuteChangedFor(nameof(ImportProfileCommand))] [NotifyCanExecuteChangedFor(nameof(DownloadProfilesCommand))] [NotifyCanExecuteChangedFor(nameof(RemoveSelectedProfilesCommand))] + [NotifyCanExecuteChangedFor(nameof(ConnectTenantCommand))] + [NotifyCanExecuteChangedFor(nameof(CreateCertificateCommand))] + [NotifyCanExecuteChangedFor(nameof(RetireActiveCertificateCommand))] + [NotifyCanExecuteChangedFor(nameof(SelectBootMediaCertificatePfxCommand))] public partial bool IsAutopilotEnabled { get; set; } [ObservableProperty] @@ -162,6 +430,10 @@ public AutopilotConfigurationViewModel( [NotifyPropertyChangedFor(nameof(IsBusy))] [NotifyPropertyChangedFor(nameof(BusyStatusText))] [NotifyPropertyChangedFor(nameof(BusyStatusVisibility))] + [NotifyCanExecuteChangedFor(nameof(ConnectTenantCommand))] + [NotifyCanExecuteChangedFor(nameof(CreateCertificateCommand))] + [NotifyCanExecuteChangedFor(nameof(RetireActiveCertificateCommand))] + [NotifyCanExecuteChangedFor(nameof(SelectBootMediaCertificatePfxCommand))] public partial bool IsImporting { get; set; } [ObservableProperty] @@ -171,8 +443,36 @@ public AutopilotConfigurationViewModel( [NotifyPropertyChangedFor(nameof(IsBusy))] [NotifyPropertyChangedFor(nameof(BusyStatusText))] [NotifyPropertyChangedFor(nameof(BusyStatusVisibility))] + [NotifyCanExecuteChangedFor(nameof(ConnectTenantCommand))] + [NotifyCanExecuteChangedFor(nameof(CreateCertificateCommand))] + [NotifyCanExecuteChangedFor(nameof(RetireActiveCertificateCommand))] + [NotifyCanExecuteChangedFor(nameof(SelectBootMediaCertificatePfxCommand))] public partial bool IsDownloading { get; set; } + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(ImportProfileCommand))] + [NotifyCanExecuteChangedFor(nameof(DownloadProfilesCommand))] + [NotifyCanExecuteChangedFor(nameof(RemoveSelectedProfilesCommand))] + [NotifyPropertyChangedFor(nameof(IsBusy))] + [NotifyPropertyChangedFor(nameof(BusyStatusText))] + [NotifyPropertyChangedFor(nameof(BusyStatusVisibility))] + [NotifyCanExecuteChangedFor(nameof(ConnectTenantCommand))] + [NotifyCanExecuteChangedFor(nameof(CreateCertificateCommand))] + [NotifyCanExecuteChangedFor(nameof(RetireActiveCertificateCommand))] + [NotifyCanExecuteChangedFor(nameof(SelectBootMediaCertificatePfxCommand))] + public partial bool IsConnectingTenant { get; set; } + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(CreateCertificateCommand))] + public partial CertificateValidityOptionViewModel? SelectedCertificateValidityOption { get; set; } + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(RetireActiveCertificateCommand))] + public partial AutopilotCertificateEntryViewModel? SelectedCertificate { get; set; } + + [ObservableProperty] + public partial AutopilotGroupTagEntryViewModel? SelectedDefaultGroupTag { get; set; } + /// /// Releases subscriptions to localization, configuration state, and profile collections. /// @@ -182,6 +482,7 @@ public void Dispose() configurationStateService.StateChanged -= OnConfigurationStateChanged; Profiles.CollectionChanged -= OnProfilesCollectionChanged; SelectedProfiles.CollectionChanged -= OnSelectedProfilesCollectionChanged; + SelectedCertificates.CollectionChanged -= OnSelectedCertificatesCollectionChanged; } [RelayCommand(CanExecute = nameof(CanImportProfile))] @@ -219,8 +520,10 @@ private async Task DownloadProfilesAsync() try { logger.Information("Starting Autopilot profile download from tenant."); - IReadOnlyList? availableProfiles = - await tenantDownloadDialogService.DownloadAsync(autopilotTenantProfileService.DownloadFromTenantAsync); + IReadOnlyList? availableProfiles = await tenantOperationDialogService.RunAsync( + localizationService.GetString("Autopilot.TenantDownloadDialogTitle"), + localizationService.GetString("Autopilot.TenantDownloadDialogMessage"), + autopilotTenantProfileService.DownloadFromTenantAsync); if (availableProfiles is null) { logger.Information("Autopilot tenant download was canceled."); @@ -312,11 +615,217 @@ private async Task RemoveSelectedProfilesAsync() SaveState(); } + [RelayCommand(CanExecute = nameof(CanConnectTenant))] + private async Task ConnectTenantAsync() + { + if (HasConnectedTenantInCurrentSession) + { + DisconnectTenantSession(); + return; + } + + IsConnectingTenant = true; + try + { + logger.Information("Starting Autopilot hardware hash tenant onboarding."); + AutopilotTenantOnboardingResult? result = await tenantOperationDialogService.RunAsync( + localizationService.GetString("Autopilot.HardwareHashTenantConnectionDialogTitle"), + localizationService.GetString("Autopilot.HardwareHashTenantConnectionDialogMessage"), + cancellationToken => autopilotTenantOnboardingService.ConnectAsync(hardwareHashUploadSettings, cancellationToken)); + if (result is null) + { + logger.Information("Autopilot hardware hash tenant onboarding was canceled."); + return; + } + + hardwareHashUploadSettings = result.Settings; + tenantOnboardingStatus = result.Status; + hardwareHashSessionState.TenantOnboardingStatus = result.Status; + HasConnectedTenantInCurrentSession = true; + ReplaceCertificates(result.Certificates); + ReplaceDefaultGroupTagOptions(result.Settings.KnownGroupTags, result.Settings.DefaultGroupTag); + ClearBootMediaCertificateIfActiveCertificateChanged(); + RefreshHardwareHashUploadState(); + SaveState(); + logger.Information( + "Autopilot hardware hash tenant onboarding updated. Status={Status}, TenantId={TenantId}, ApplicationObjectId={ApplicationObjectId}", + result.Status, + result.Settings.Tenant.TenantId, + result.Settings.Tenant.ApplicationObjectId); + if (ShouldShowTenantOnboardingResultDialog(result.Status)) + { + await dialogService.ShowMessageAsync(new DialogRequest( + GetTenantOnboardingDialogTitle(), + CreateTenantOnboardingResultMessage(result.Status))); + } + } + catch (Exception ex) when (ex is AuthenticationFailedException or HttpRequestException or InvalidOperationException or JsonException) + { + logger.Error(ex, "Autopilot hardware hash tenant onboarding failed."); + await dialogService.ShowMessageAsync(new DialogRequest( + localizationService.GetString("Autopilot.HardwareHashOnboardingFailedTitle"), + localizationService.FormatString("Autopilot.HardwareHashOnboardingFailedMessageFormat", ex.Message))); + } + finally + { + IsConnectingTenant = false; + } + } + + [RelayCommand(CanExecute = nameof(CanCreateCertificate))] + private async Task CreateCertificateAsync() + { + string? outputPath = await filePickerService.PickSaveFileAsync(new FileSavePickerRequest( + localizationService.GetString("Autopilot.HardwareHashCertificateSavePickerTitle"), + "foundry-osd-autopilot-registration.pfx", + [new FilePickerTypeChoice(localizationService.GetString("Autopilot.HardwareHashCertificatePfxFileType"), [".pfx"])], + ".pfx")); + if (string.IsNullOrWhiteSpace(outputPath) || SelectedCertificateValidityOption is null) + { + return; + } + + IsConnectingTenant = true; + try + { + AutopilotCertificateCreationResult result = await autopilotTenantOnboardingService.CreateCertificateAsync( + hardwareHashUploadSettings, + outputPath, + SelectedCertificateValidityOption.Months); + hardwareHashUploadSettings = result.Settings; + tenantOnboardingStatus = AutopilotTenantOnboardingStatus.Ready; + hardwareHashSessionState.TenantOnboardingStatus = tenantOnboardingStatus; + ReplaceCertificates(result.Certificates); + SetBootMediaCertificateInput(outputPath, result.GeneratedPassword); + RefreshHardwareHashUploadState(); + SaveState(); + + await certificateDialogService.ShowCreatedAsync(outputPath, result.GeneratedPassword); + } + catch (Exception ex) when (ex is AuthenticationFailedException or HttpRequestException or InvalidOperationException or IOException or UnauthorizedAccessException) + { + logger.Error("Autopilot hardware hash certificate creation failed. ErrorType={ErrorType}", ex.GetType().Name); + await dialogService.ShowMessageAsync(new DialogRequest( + localizationService.GetString("Autopilot.HardwareHashCertificateCreateFailedTitle"), + localizationService.FormatString("Autopilot.HardwareHashCertificateCreateFailedMessageFormat", ex.Message))); + } + finally + { + IsConnectingTenant = false; + } + } + + [RelayCommand(CanExecute = nameof(CanRetireActiveCertificate))] + private async Task RetireActiveCertificateAsync() + { + AutopilotCertificateEntryViewModel[] certificatesToRemove = SelectedCertificates.ToArray(); + if (certificatesToRemove.Length == 0) + { + return; + } + + bool confirmed = await dialogService.ConfirmAsync(new ConfirmationDialogRequest( + localizationService.GetString("Autopilot.HardwareHashRetireCertificateConfirmationTitle"), + certificatesToRemove.Length == 1 + ? localizationService.GetString("Autopilot.HardwareHashRetireCertificateConfirmationMessage") + : localizationService.FormatString("Autopilot.HardwareHashRetireCertificatesConfirmationMessageFormat", certificatesToRemove.Length), + localizationService.GetString("Autopilot.HardwareHashRetireCertificateConfirmationPrimary"), + localizationService.GetString("Common.Cancel"))); + if (!confirmed) + { + return; + } + + IsConnectingTenant = true; + try + { + AutopilotCertificateRemovalResult? result = null; + foreach (AutopilotCertificateEntryViewModel certificate in certificatesToRemove) + { + result = await autopilotTenantOnboardingService.RemoveCertificateAsync( + hardwareHashUploadSettings, + certificate.KeyId); + hardwareHashUploadSettings = result.Settings; + } + + if (result is null) + { + return; + } + + tenantOnboardingStatus = ResolveTenantReadinessAfterCertificateChange(result.Certificates); + hardwareHashSessionState.TenantOnboardingStatus = tenantOnboardingStatus; + + ReplaceCertificates(result.Certificates); + ClearBootMediaCertificateIfActiveCertificateChanged(); + RefreshHardwareHashUploadState(); + SaveState(); + } + catch (Exception ex) when (ex is AuthenticationFailedException or HttpRequestException or InvalidOperationException or JsonException) + { + logger.Error(ex, "Autopilot hardware hash certificate retirement failed."); + await dialogService.ShowMessageAsync(new DialogRequest( + localizationService.GetString("Autopilot.HardwareHashCertificateRetireFailedTitle"), + localizationService.FormatString("Autopilot.HardwareHashCertificateRetireFailedMessageFormat", ex.Message))); + } + finally + { + IsConnectingTenant = false; + } + } + partial void OnIsAutopilotEnabledChanged(bool value) { + ImportProfileCommand.NotifyCanExecuteChanged(); + DownloadProfilesCommand.NotifyCanExecuteChanged(); + RemoveSelectedProfilesCommand.NotifyCanExecuteChanged(); + ConnectTenantCommand.NotifyCanExecuteChanged(); + CreateCertificateCommand.NotifyCanExecuteChanged(); + RetireActiveCertificateCommand.NotifyCanExecuteChanged(); + SelectBootMediaCertificatePfxCommand.NotifyCanExecuteChanged(); SaveState(); } + [RelayCommand(CanExecute = nameof(CanSelectBootMediaCertificatePfx))] + private async Task SelectBootMediaCertificatePfxAsync() + { + string? filePath = await filePickerService.PickOpenFileAsync( + new FileOpenPickerRequest(localizationService.GetString("Autopilot.HardwareHashBootMediaCertificatePfxPickerTitle"), [".pfx"])); + if (string.IsNullOrWhiteSpace(filePath)) + { + return; + } + + SetBootMediaCertificateInput(filePath, hardwareHashUploadSettings.BootMediaCertificate.PfxPassword); + RefreshHardwareHashUploadState(); + SaveState(); + } + + /// + /// Updates the session-only boot media PFX password after the password box changes. + /// + /// PFX password entered by the operator. + public void SetBootMediaCertificatePassword(string password) + { + if (string.Equals(hardwareHashUploadSettings.BootMediaCertificate.PfxPassword, password, StringComparison.Ordinal)) + { + return; + } + + SetBootMediaCertificateInput(hardwareHashUploadSettings.BootMediaCertificate.PfxPath, password); + RefreshBootMediaCertificateState(); + SaveState(); + } + + /// + /// Gets the session-only boot media PFX password for synchronizing the password box. + /// + /// The current session PFX password, or an empty string. + public string GetBootMediaCertificatePassword() + { + return hardwareHashUploadSettings.BootMediaCertificate.PfxPassword ?? string.Empty; + } + partial void OnSelectedDefaultProfileChanged(AutopilotProfileEntryViewModel? value) { SaveState(); @@ -328,9 +837,21 @@ private void ApplyState(AutopilotSettings settings) try { IsAutopilotEnabled = settings.IsEnabled; + provisioningMode = Enum.IsDefined(settings.ProvisioningMode) + ? settings.ProvisioningMode + : AutopilotProvisioningMode.JsonProfile; + hardwareHashUploadSettings = (settings.HardwareHashUpload ?? new AutopilotHardwareHashUploadSettings()) with + { + BootMediaCertificate = hardwareHashSessionState.BootMediaCertificate + }; + tenantOnboardingStatus = hardwareHashSessionState.TenantOnboardingStatus; + ReplaceCertificates(HasConnectedTenantInCurrentSession ? hardwareHashSessionState.Certificates : []); + ReplaceDefaultGroupTagOptions(hardwareHashUploadSettings.KnownGroupTags, hardwareHashUploadSettings.DefaultGroupTag); ReplaceProfiles( settings.Profiles.Select(AutopilotProfileEntryViewModel.FromSettings), settings.DefaultProfileId); + RefreshProvisioningModeState(); + RefreshHardwareHashUploadState(); } finally { @@ -391,8 +912,10 @@ private void SaveState() configurationStateService.UpdateAutopilot(new AutopilotSettings { IsEnabled = IsAutopilotEnabled, + ProvisioningMode = provisioningMode, DefaultProfileId = SelectedDefaultProfile?.Id, - Profiles = Profiles.Select(profile => profile.ToSettings()).ToArray() + Profiles = Profiles.Select(profile => profile.ToSettings()).ToArray(), + HardwareHashUpload = hardwareHashUploadSettings }); } finally @@ -407,6 +930,47 @@ private void RefreshLocalizedText() AutopilotHeader = localizationService.GetString("Autopilot.Header"); AutopilotDescription = localizationService.GetString("Autopilot.Description"); EnableAutopilotText = localizationService.GetString("Autopilot.EnableLabel"); + JsonProfileHeader = localizationService.GetString("Autopilot.JsonProfileHeader"); + JsonProfileDescription = localizationService.GetString("Autopilot.JsonProfileDescription"); + JsonProfileEnableText = localizationService.GetString("Autopilot.JsonProfileEnableLabel"); + ActionsDescription = localizationService.GetString("Autopilot.ActionsDescription"); + DefaultProfileDescription = localizationService.GetString("Autopilot.DefaultProfileDescription"); + ProfilesDescription = localizationService.GetString("Autopilot.ProfilesDescription"); + HardwareHashHeader = localizationService.GetString("Autopilot.HardwareHashHeader"); + HardwareHashDescription = localizationService.GetString("Autopilot.HardwareHashDescription"); + HardwareHashEnableText = localizationService.GetString("Autopilot.HardwareHashEnableLabel"); + ConnectTenantButtonText = localizationService.GetString("Autopilot.HardwareHashConnectTenantButton"); + DisconnectTenantButtonText = localizationService.GetString("Autopilot.HardwareHashDisconnectTenantButton"); + ConnectingTenantStatusText = localizationService.GetString("Autopilot.HardwareHashConnectingTenantStatus"); + CreateCertificateButtonText = localizationService.GetString("Autopilot.HardwareHashCreateCertificateButton"); + RetireCertificateButtonText = localizationService.GetString("Autopilot.HardwareHashRetireCertificateButton"); + TenantStatusLabel = localizationService.GetString("Autopilot.HardwareHashTenantStatusLabel"); + TenantStatusDescription = localizationService.GetString("Autopilot.HardwareHashTenantStatusDescription"); + TenantReadinessLabel = localizationService.GetString("Autopilot.HardwareHashTenantReadinessLabel"); + TenantReadinessDescription = localizationService.GetString("Autopilot.HardwareHashTenantReadinessDescription"); + TenantReadinessNameColumnHeader = localizationService.GetString("Autopilot.HardwareHashTenantReadinessNameColumn"); + TenantReadinessValueColumnHeader = localizationService.GetString("Autopilot.HardwareHashTenantReadinessValueColumn"); + TenantDetailsTenantIdLabel = localizationService.GetString("Autopilot.HardwareHashTenantDetailsTenantId"); + TenantDetailsClientIdLabel = localizationService.GetString("Autopilot.HardwareHashTenantDetailsClientId"); + AppRegistrationLabel = localizationService.GetString("Autopilot.HardwareHashAppRegistrationLabel"); + TenantOnboardingStatusLabel = localizationService.GetString("Autopilot.HardwareHashOnboardingStatusLabel"); + CertificateActionsLabel = localizationService.GetString("Autopilot.HardwareHashCertificateActionsLabel"); + CertificateActionsDescription = localizationService.GetString("Autopilot.HardwareHashCertificateActionsDescription"); + ProvisionedCertificatesLabel = localizationService.GetString("Autopilot.HardwareHashProvisionedCertificatesLabel"); + ProvisionedCertificatesDescription = localizationService.GetString("Autopilot.HardwareHashProvisionedCertificatesDescription"); + EmptyCertificatesText = localizationService.GetString("Autopilot.HardwareHashCertificatesNone"); + CertificateThumbprintColumnHeader = localizationService.GetString("Autopilot.HardwareHashCertificateThumbprintColumn"); + CertificateCreatedColumnHeader = localizationService.GetString("Autopilot.HardwareHashCertificateCreatedColumn"); + CertificateExpiresColumnHeader = localizationService.GetString("Autopilot.HardwareHashCertificateExpiresColumn"); + CertificateIdColumnHeader = localizationService.GetString("Autopilot.HardwareHashCertificateIdColumn"); + BootMediaCertificateLabel = localizationService.GetString("Autopilot.HardwareHashBootMediaCertificateLabel"); + BootMediaCertificateDescription = localizationService.GetString("Autopilot.HardwareHashBootMediaCertificateDescription"); + BootMediaCertificatePfxPathLabel = localizationService.GetString("Autopilot.HardwareHashBootMediaCertificatePfxPathLabel"); + BootMediaCertificatePasswordLabel = localizationService.GetString("Autopilot.HardwareHashBootMediaCertificatePasswordLabel"); + SelectBootMediaCertificateButtonText = localizationService.GetString("Autopilot.HardwareHashBootMediaCertificateSelectButton"); + GroupTagLabel = localizationService.GetString("Autopilot.HardwareHashGroupTagLabel"); + GroupTagDescription = localizationService.GetString("Autopilot.HardwareHashGroupTagDescription"); + DefaultGroupTagNoneOptionText = localizationService.GetString("Autopilot.HardwareHashDefaultGroupTagNoSelection"); ImportButtonText = localizationService.GetString("Autopilot.ImportButton"); DownloadButtonText = localizationService.GetString("Autopilot.DownloadButton"); RemoveButtonText = localizationService.GetString("Autopilot.RemoveButton"); @@ -425,6 +989,373 @@ private void RefreshLocalizedText() ProfileImportedColumnHeader = localizationService.GetString("Autopilot.ColumnImported"); ProfileFolderColumnHeader = localizationService.GetString("Autopilot.ColumnFolder"); OnPropertyChanged(nameof(BusyStatusText)); + OnPropertyChanged(nameof(TenantConnectionButtonText)); + OnPropertyChanged(nameof(TenantStatusForeground)); + RefreshHardwareHashUploadState(); + } + + private void SetProvisioningMode(AutopilotProvisioningMode mode) + { + if (provisioningMode == mode) + { + return; + } + + provisioningMode = mode; + RefreshProvisioningModeState(); + SaveState(); + } + + private void RefreshProvisioningModeState() + { + OnPropertyChanged(nameof(UseJsonProfileProvisioning)); + OnPropertyChanged(nameof(UseHardwareHashUploadProvisioning)); + OnPropertyChanged(nameof(IsJsonProfileMode)); + OnPropertyChanged(nameof(IsHardwareHashUploadMode)); + OnPropertyChanged(nameof(JsonProfileSettingsVisibility)); + OnPropertyChanged(nameof(HardwareHashSettingsVisibility)); + ImportProfileCommand.NotifyCanExecuteChanged(); + DownloadProfilesCommand.NotifyCanExecuteChanged(); + RemoveSelectedProfilesCommand.NotifyCanExecuteChanged(); + ConnectTenantCommand.NotifyCanExecuteChanged(); + CreateCertificateCommand.NotifyCanExecuteChanged(); + RetireActiveCertificateCommand.NotifyCanExecuteChanged(); + } + + private void RefreshHardwareHashUploadState() + { + OnPropertyChanged(nameof(TenantStatusText)); + OnPropertyChanged(nameof(TenantStatusForeground)); + OnPropertyChanged(nameof(TenantConnectionButtonText)); + OnPropertyChanged(nameof(AppRegistrationStatusText)); + OnPropertyChanged(nameof(TenantIdText)); + OnPropertyChanged(nameof(ClientIdText)); + OnPropertyChanged(nameof(TenantOnboardingStatusText)); + OnPropertyChanged(nameof(TenantOnboardingStatusForeground)); + RefreshTenantReadinessEntries(); + OnPropertyChanged(nameof(IsHardwareHashCertificateExpired)); + OnPropertyChanged(nameof(EmptyCertificatesVisibility)); + OnPropertyChanged(nameof(DefaultGroupTagText)); + OnPropertyChanged(nameof(DefaultGroupTagOptions)); + OnPropertyChanged(nameof(ConnectedTenantDetailsVisibility)); + OnPropertyChanged(nameof(BootMediaCertificateVisibility)); + OnPropertyChanged(nameof(BootMediaCertificatePfxPath)); + OnPropertyChanged(nameof(BootMediaCertificateStatusText)); + OnPropertyChanged(nameof(BootMediaCertificateStatusForeground)); + OnPropertyChanged(nameof(IsBootMediaCertificateReady)); + RetireActiveCertificateCommand.NotifyCanExecuteChanged(); + SelectBootMediaCertificatePfxCommand.NotifyCanExecuteChanged(); + } + + private void RefreshBootMediaCertificateState() + { + OnPropertyChanged(nameof(BootMediaCertificatePfxPath)); + OnPropertyChanged(nameof(BootMediaCertificateStatusText)); + OnPropertyChanged(nameof(BootMediaCertificateStatusForeground)); + OnPropertyChanged(nameof(IsBootMediaCertificateReady)); + SelectBootMediaCertificatePfxCommand.NotifyCanExecuteChanged(); + } + + private void RefreshTenantReadinessEntries() + { + TenantReadinessEntries.Clear(); + if (!HasTenantRegistration) + { + return; + } + + TenantReadinessEntries.Add(new AutopilotTenantReadinessEntryViewModel( + AppRegistrationLabel, + AppRegistrationStatusText, + null)); + TenantReadinessEntries.Add(new AutopilotTenantReadinessEntryViewModel( + TenantDetailsTenantIdLabel, + TenantIdText, + null)); + TenantReadinessEntries.Add(new AutopilotTenantReadinessEntryViewModel( + TenantDetailsClientIdLabel, + ClientIdText, + null)); + TenantReadinessEntries.Add(new AutopilotTenantReadinessEntryViewModel( + TenantOnboardingStatusLabel, + TenantOnboardingStatusText, + TenantOnboardingStatusForeground)); + } + + private void DisconnectTenantSession() + { + hardwareHashGraphSessionService.Disconnect(); + HasConnectedTenantInCurrentSession = false; + tenantOnboardingStatus = null; + ReplaceCertificates([]); + ClearBootMediaCertificateInput(); + hardwareHashSessionState.ClearTenantConnection(); + RefreshHardwareHashUploadState(); + SaveState(); + } + + private void ReplaceDefaultGroupTagOptions(IReadOnlyList groupTags, string? preferredDefaultGroupTag) + { + string[] orderedGroupTags = groupTags + .Where(groupTag => !string.IsNullOrWhiteSpace(groupTag)) + .Select(groupTag => groupTag.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(groupTag => groupTag, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + DefaultGroupTagOptions.Clear(); + DefaultGroupTagOptions.Add(new AutopilotGroupTagEntryViewModel(DefaultGroupTagNoneOptionText, null)); + foreach (string groupTag in orderedGroupTags) + { + AutopilotGroupTagEntryViewModel groupTagEntry = new(groupTag, groupTag); + DefaultGroupTagOptions.Add(groupTagEntry); + } + + AutopilotGroupTagEntryViewModel? selectedGroupTag = DefaultGroupTagOptions.FirstOrDefault(groupTag => + string.Equals(groupTag.GroupTag, preferredDefaultGroupTag, StringComparison.OrdinalIgnoreCase)) + ?? DefaultGroupTagOptions.First(); + SelectedDefaultGroupTag = selectedGroupTag; + hardwareHashUploadSettings = hardwareHashUploadSettings with + { + KnownGroupTags = orderedGroupTags, + DefaultGroupTag = selectedGroupTag?.GroupTag + }; + } + + private void ReplaceCertificates(IReadOnlyList credentials) + { + AutopilotGraphKeyCredential[] managedCredentials = credentials + .Where(IsManagedCertificateCredential) + .ToArray(); + + Certificates.Clear(); + SelectedCertificates.Clear(); + foreach (AutopilotCertificateEntryViewModel certificate in managedCredentials + .OrderBy(certificate => certificate.ExpiresOnUtc) + .Select(AutopilotCertificateEntryViewModel.FromGraphCredential)) + { + Certificates.Add(certificate); + } + + SelectedCertificate = null; + hardwareHashSessionState.Certificates = managedCredentials; + OnPropertyChanged(nameof(HasCertificates)); + OnPropertyChanged(nameof(CertificatesVisibility)); + OnPropertyChanged(nameof(EmptyCertificatesVisibility)); + OnPropertyChanged(nameof(BootMediaCertificateVisibility)); + } + + private string CreateBootMediaCertificateStatusText() + { + if (string.IsNullOrWhiteSpace(hardwareHashUploadSettings.BootMediaCertificate.PfxPath)) + { + return localizationService.GetString("Autopilot.HardwareHashBootMediaCertificatePfxMissing"); + } + + if (isBootMediaCertificateFileMissing) + { + return localizationService.GetString("Autopilot.HardwareHashBootMediaCertificateFileMissing"); + } + + if (bootMediaCertificateValidationCode == AutopilotPfxValidationCode.PasswordRequired) + { + return localizationService.GetString("Autopilot.HardwareHashBootMediaCertificatePasswordMissing"); + } + + if (bootMediaCertificateValidationCode == AutopilotPfxValidationCode.InvalidPfx) + { + return localizationService.GetString("Autopilot.HardwareHashBootMediaCertificateInvalidPfx"); + } + + if (bootMediaCertificateValidationCode == AutopilotPfxValidationCode.PrivateKeyMissing) + { + return localizationService.GetString("Autopilot.HardwareHashBootMediaCertificatePrivateKeyMissing"); + } + + if (bootMediaCertificateValidationCode == AutopilotPfxValidationCode.ThumbprintMismatch) + { + return localizationService.GetString("Autopilot.HardwareHashBootMediaCertificateThumbprintMismatch"); + } + + if (hardwareHashUploadSettings.ActiveCertificate is null) + { + return localizationService.GetString("Autopilot.HardwareHashBootMediaCertificateActiveMissing"); + } + + if (IsHardwareHashCertificateExpired) + { + return localizationService.GetString("Autopilot.HardwareHashBootMediaCertificateActiveExpired"); + } + + return bootMediaCertificateValidationCode switch + { + AutopilotPfxValidationCode.Valid => localizationService.GetString("Autopilot.HardwareHashBootMediaCertificateReady"), + AutopilotPfxValidationCode.ExpectedThumbprintRequired => localizationService.GetString("Autopilot.HardwareHashBootMediaCertificateActiveMissing"), + _ => localizationService.GetString("Autopilot.HardwareHashBootMediaCertificatePfxMissing") + }; + } + + private Brush ResolveBootMediaCertificateStatusBrush() + { + if (IsBootMediaCertificateReady) + { + return (Brush)Application.Current.Resources["SystemFillColorSuccessBrush"]; + } + + if (IsHardwareHashCertificateExpired || + isBootMediaCertificateFileMissing || + (hardwareHashUploadSettings.ActiveCertificate is null && + !string.IsNullOrWhiteSpace(hardwareHashUploadSettings.BootMediaCertificate.PfxPath) && + bootMediaCertificateValidationCode != AutopilotPfxValidationCode.PasswordRequired) || + bootMediaCertificateValidationCode is AutopilotPfxValidationCode.InvalidPfx + or AutopilotPfxValidationCode.PrivateKeyMissing + or AutopilotPfxValidationCode.ThumbprintMismatch) + { + return (Brush)Application.Current.Resources["SystemFillColorCriticalBrush"]; + } + + return (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"]; + } + + private void SetBootMediaCertificateInput(string? pfxPath, string? password) + { + string? normalizedPath = string.IsNullOrWhiteSpace(pfxPath) ? null : pfxPath.Trim(); + AutopilotBootMediaCertificateSettings bootMediaCertificate = new() + { + PfxPath = normalizedPath, + PfxPassword = password + }; + + isBootMediaCertificateFileMissing = false; + bootMediaCertificateValidationCode = AutopilotPfxValidationCode.PfxRequired; + if (!string.IsNullOrWhiteSpace(normalizedPath)) + { + if (File.Exists(normalizedPath)) + { + try + { + AutopilotPfxValidationResult validation = AutopilotPfxCertificateValidator.Validate( + File.ReadAllBytes(normalizedPath), + password); + bootMediaCertificateValidationCode = validation.Code; + if (validation.IsValid) + { + AutopilotCertificateEntryViewModel? matchingCertificate = FindTenantCertificateByThumbprint(validation.Thumbprint); + if (matchingCertificate is null) + { + bootMediaCertificateValidationCode = AutopilotPfxValidationCode.ThumbprintMismatch; + hardwareHashUploadSettings = hardwareHashUploadSettings with { ActiveCertificate = null }; + bootMediaCertificate = bootMediaCertificate with + { + ValidatedThumbprint = validation.Thumbprint, + ValidatedExpiresOnUtc = validation.ExpiresOnUtc + }; + } + else + { + hardwareHashUploadSettings = hardwareHashUploadSettings with + { + ActiveCertificate = CreateCertificateMetadata(matchingCertificate) + }; + tenantOnboardingStatus = ResolveCertificateSelectionStatus(matchingCertificate); + hardwareHashSessionState.TenantOnboardingStatus = tenantOnboardingStatus; + bootMediaCertificate = bootMediaCertificate with + { + ValidatedThumbprint = validation.Thumbprint, + ValidatedExpiresOnUtc = validation.ExpiresOnUtc + }; + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + bootMediaCertificateValidationCode = AutopilotPfxValidationCode.InvalidPfx; + } + } + else + { + isBootMediaCertificateFileMissing = true; + } + } + + hardwareHashUploadSettings = hardwareHashUploadSettings with + { + BootMediaCertificate = bootMediaCertificate + }; + hardwareHashSessionState.BootMediaCertificate = bootMediaCertificate; + } + + private void ClearBootMediaCertificateIfActiveCertificateChanged() + { + string? activeThumbprint = NormalizeThumbprint(hardwareHashUploadSettings.ActiveCertificate?.Thumbprint); + string? validatedThumbprint = NormalizeThumbprint(hardwareHashUploadSettings.BootMediaCertificate.ValidatedThumbprint); + if (string.Equals(activeThumbprint, validatedThumbprint, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + ClearBootMediaCertificateInput(); + } + + private void ClearBootMediaCertificateInput() + { + bootMediaCertificateValidationCode = AutopilotPfxValidationCode.PfxRequired; + isBootMediaCertificateFileMissing = false; + hardwareHashUploadSettings = hardwareHashUploadSettings with + { + BootMediaCertificate = new AutopilotBootMediaCertificateSettings() + }; + hardwareHashSessionState.BootMediaCertificate = hardwareHashUploadSettings.BootMediaCertificate; + OnPropertyChanged(nameof(BootMediaCertificatePfxPath)); + } + + private string CreateTenantOnboardingStatusText() + { + return tenantOnboardingStatus == AutopilotTenantOnboardingStatus.Ready + ? localizationService.GetString("Autopilot.HardwareHashOnboardingStatusReady") + : localizationService.GetString("Autopilot.HardwareHashOnboardingStatusNotReady"); + } + + private Brush ResolveTenantOnboardingStatusBrush() + { + return tenantOnboardingStatus == AutopilotTenantOnboardingStatus.Ready + ? (Brush)Application.Current.Resources["SystemFillColorSuccessBrush"] + : (Brush)Application.Current.Resources["SystemFillColorCriticalBrush"]; + } + + private string GetTenantOnboardingDialogTitle() + { + return localizationService.GetString("Autopilot.HardwareHashOnboardingRequiresAttentionTitle"); + } + + private string CreateTenantOnboardingResultMessage(AutopilotTenantOnboardingStatus status) + { + string key = status switch + { + AutopilotTenantOnboardingStatus.AppRegistrationMissing => "Autopilot.HardwareHashOnboardingMessageAppRegistrationMissing", + AutopilotTenantOnboardingStatus.AdoptionRequired => "Autopilot.HardwareHashOnboardingMessageAdoptionRequired", + AutopilotTenantOnboardingStatus.PermissionMissing => "Autopilot.HardwareHashOnboardingMessagePermissionMissing", + AutopilotTenantOnboardingStatus.ConsentMissing => "Autopilot.HardwareHashOnboardingMessageConsentMissing", + AutopilotTenantOnboardingStatus.ServicePrincipalUnavailable => "Autopilot.HardwareHashOnboardingMessageServicePrincipalUnavailable", + AutopilotTenantOnboardingStatus.ActiveCertificateNotFound => "Autopilot.HardwareHashOnboardingMessageActiveCertificateNotFound", + AutopilotTenantOnboardingStatus.ActiveCertificateExpired => "Autopilot.HardwareHashOnboardingMessageActiveCertificateExpired", + _ => "Autopilot.HardwareHashOnboardingMessageGeneric" + }; + + return localizationService.GetString(key); + } + + private static bool ShouldShowTenantOnboardingResultDialog(AutopilotTenantOnboardingStatus status) + { + return status is not (AutopilotTenantOnboardingStatus.Ready or AutopilotTenantOnboardingStatus.ActiveCertificateMissing); + } + + private static bool IsManagedCertificateCredential(AutopilotGraphKeyCredential credential) + { + return string.Equals( + credential.DisplayName, + AutopilotHardwareHashUploadSettings.ManagedAppRegistrationDisplayName, + StringComparison.OrdinalIgnoreCase); } private void RefreshProfileState() @@ -445,6 +1376,26 @@ private void OnSelectedProfilesCollectionChanged(object? sender, System.Collecti RemoveSelectedProfilesCommand.NotifyCanExecuteChanged(); } + private void OnSelectedCertificatesCollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + RetireActiveCertificateCommand.NotifyCanExecuteChanged(); + } + + partial void OnSelectedDefaultGroupTagChanged(AutopilotGroupTagEntryViewModel? value) + { + if (isApplyingState) + { + return; + } + + hardwareHashUploadSettings = hardwareHashUploadSettings with + { + DefaultGroupTag = value?.GroupTag + }; + OnPropertyChanged(nameof(DefaultGroupTagText)); + SaveState(); + } + /// /// Replaces the selected profile rows after a XAML selection change. /// @@ -458,6 +1409,90 @@ public void ReplaceSelectedProfiles(IEnumerable } } + /// + /// Replaces the selected certificate rows after a XAML selection change. + /// + /// The selected certificate view models. + public void ReplaceSelectedCertificate(IEnumerable certificates) + { + SelectedCertificates.Clear(); + foreach (AutopilotCertificateEntryViewModel certificate in certificates) + { + SelectedCertificates.Add(certificate); + } + + SelectedCertificate = SelectedCertificates.FirstOrDefault(); + } + + private AutopilotTenantOnboardingStatus? ResolveCertificateSelectionStatus(AutopilotCertificateEntryViewModel? certificate) + { + if (tenantOnboardingStatus is AutopilotTenantOnboardingStatus.PermissionMissing + or AutopilotTenantOnboardingStatus.ConsentMissing + or AutopilotTenantOnboardingStatus.ServicePrincipalUnavailable + or AutopilotTenantOnboardingStatus.AppRegistrationMissing + or AutopilotTenantOnboardingStatus.AdoptionRequired) + { + return tenantOnboardingStatus; + } + + if (certificate is null) + { + return AutopilotTenantOnboardingStatus.ActiveCertificateMissing; + } + + return certificate.ExpiresOnUtc <= DateTimeOffset.UtcNow + ? AutopilotTenantOnboardingStatus.ActiveCertificateExpired + : AutopilotTenantOnboardingStatus.Ready; + } + + private AutopilotTenantOnboardingStatus ResolveTenantReadinessAfterCertificateChange( + IReadOnlyList credentials) + { + if (tenantOnboardingStatus is AutopilotTenantOnboardingStatus.PermissionMissing + or AutopilotTenantOnboardingStatus.ConsentMissing + or AutopilotTenantOnboardingStatus.ServicePrincipalUnavailable + or AutopilotTenantOnboardingStatus.AppRegistrationMissing + or AutopilotTenantOnboardingStatus.AdoptionRequired) + { + return tenantOnboardingStatus.Value; + } + + return credentials.Any(credential => + string.Equals( + credential.DisplayName, + AutopilotHardwareHashUploadSettings.ManagedAppRegistrationDisplayName, + StringComparison.OrdinalIgnoreCase) && + credential.ExpiresOnUtc > DateTimeOffset.UtcNow) + ? AutopilotTenantOnboardingStatus.Ready + : AutopilotTenantOnboardingStatus.ActiveCertificateMissing; + } + + private static AutopilotCertificateMetadata CreateCertificateMetadata(AutopilotCertificateEntryViewModel certificate) + { + return new AutopilotCertificateMetadata + { + KeyId = certificate.KeyId, + Thumbprint = certificate.Thumbprint, + DisplayName = AutopilotHardwareHashUploadSettings.ManagedAppRegistrationDisplayName, + ExpiresOnUtc = certificate.ExpiresOnUtc + }; + } + + private AutopilotCertificateEntryViewModel? FindTenantCertificateByThumbprint(string? thumbprint) + { + string? normalizedThumbprint = NormalizeThumbprint(thumbprint); + if (string.IsNullOrWhiteSpace(normalizedThumbprint)) + { + return null; + } + + return Certificates.FirstOrDefault(certificate => + string.Equals( + NormalizeThumbprint(certificate.Thumbprint), + normalizedThumbprint, + StringComparison.OrdinalIgnoreCase)); + } + private void OnLanguageChanged(object? sender, ApplicationLanguageChangedEventArgs e) { RefreshLocalizedText(); @@ -475,16 +1510,58 @@ private void OnConfigurationStateChanged(object? sender, EventArgs e) private bool CanImportProfile() { - return IsAutopilotEnabled && !IsImporting && !IsDownloading; + return IsAutopilotEnabled && IsJsonProfileMode && !IsImporting && !IsDownloading; } private bool CanDownloadProfiles() { - return IsAutopilotEnabled && !IsImporting && !IsDownloading; + return IsAutopilotEnabled && IsJsonProfileMode && !IsImporting && !IsDownloading; } private bool CanRemoveSelectedProfiles() { - return IsAutopilotEnabled && !IsImporting && !IsDownloading && SelectedProfiles.Count > 0; + return IsAutopilotEnabled && IsJsonProfileMode && !IsImporting && !IsDownloading && SelectedProfiles.Count > 0; + } + + private bool CanConnectTenant() + { + return IsAutopilotEnabled && IsHardwareHashUploadMode && !IsBusy; } + + private bool CanCreateCertificate() + { + return IsAutopilotEnabled && + IsHardwareHashUploadMode && + !IsBusy && + SelectedCertificateValidityOption is not null && + HasConnectedTenantInCurrentSession && + !string.IsNullOrWhiteSpace(hardwareHashUploadSettings.Tenant.ApplicationObjectId); + } + + private bool CanRetireActiveCertificate() + { + return IsAutopilotEnabled && + IsHardwareHashUploadMode && + !IsBusy && + HasConnectedTenantInCurrentSession && + !string.IsNullOrWhiteSpace(hardwareHashUploadSettings.Tenant.ApplicationObjectId) && + SelectedCertificates.Count > 0; + } + + private bool CanSelectBootMediaCertificatePfx() + { + return IsAutopilotEnabled && + IsHardwareHashUploadMode && + !IsBusy && + HasConnectedTenantInCurrentSession && + HasCertificates; + } + + private static string? NormalizeThumbprint(string? thumbprint) + { + string? normalized = thumbprint?.Replace(" ", string.Empty, StringComparison.Ordinal).Trim(); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized.ToUpperInvariant(); + } + + public sealed record CertificateValidityOptionViewModel(int Months, string DisplayName); } diff --git a/src/Foundry/ViewModels/AutopilotGroupTagEntryViewModel.cs b/src/Foundry/ViewModels/AutopilotGroupTagEntryViewModel.cs new file mode 100644 index 00000000..a3b5081e --- /dev/null +++ b/src/Foundry/ViewModels/AutopilotGroupTagEntryViewModel.cs @@ -0,0 +1,6 @@ +namespace Foundry.ViewModels; + +/// +/// Represents one tenant-discovered Autopilot group tag for selection and display. +/// +public sealed record AutopilotGroupTagEntryViewModel(string DisplayName, string? GroupTag); diff --git a/src/Foundry/ViewModels/AutopilotTenantReadinessEntryViewModel.cs b/src/Foundry/ViewModels/AutopilotTenantReadinessEntryViewModel.cs new file mode 100644 index 00000000..3af966e9 --- /dev/null +++ b/src/Foundry/ViewModels/AutopilotTenantReadinessEntryViewModel.cs @@ -0,0 +1,8 @@ +using Microsoft.UI.Xaml.Media; + +namespace Foundry.ViewModels; + +/// +/// Represents one tenant readiness value displayed in the Autopilot hardware hash upload configuration. +/// +public sealed record AutopilotTenantReadinessEntryViewModel(string Name, string Value, Brush? ValueForeground); diff --git a/src/Foundry/ViewModels/StartMediaViewModel.cs b/src/Foundry/ViewModels/StartMediaViewModel.cs index 6e09ff96..36f93a66 100644 --- a/src/Foundry/ViewModels/StartMediaViewModel.cs +++ b/src/Foundry/ViewModels/StartMediaViewModel.cs @@ -4,6 +4,7 @@ using System.Text; using Foundry.Core.Models.Configuration; using Foundry.Core.Services.Application; +using Foundry.Core.Services.Configuration; using Foundry.Core.Services.Media; using Foundry.Core.Services.Telemetry; using Foundry.Core.Services.WinPe; @@ -1204,6 +1205,8 @@ private MediaPreflightOptions CreatePreflightOptions() vendors.Add(WinPeVendorSelection.Hp); } + AutopilotConfigurationValidationResult autopilotValidation = foundryConfigurationStateService.AutopilotConfigurationValidation; + return new MediaPreflightOptions { IsAdkReady = adkService.CurrentStatus.CanCreateMedia, @@ -1212,7 +1215,9 @@ private MediaPreflightOptions CreatePreflightOptions() IsConnectProvisioningReady = foundryConfigurationStateService.IsConnectProvisioningReady, AreRequiredSecretsReady = foundryConfigurationStateService.AreRequiredSecretsReady, IsAutopilotEnabled = foundryConfigurationStateService.IsAutopilotEnabled, - IsAutopilotConfigurationReady = foundryConfigurationStateService.IsAutopilotConfigurationReady, + IsAutopilotConfigurationReady = autopilotValidation.IsReady, + AutopilotConfigurationValidationCode = autopilotValidation.Code, + AutopilotProvisioningMode = foundryConfigurationStateService.AutopilotProvisioningMode, AutopilotProfileDisplayName = foundryConfigurationStateService.SelectedAutopilotProfileDisplayName, AutopilotProfileFolderName = foundryConfigurationStateService.SelectedAutopilotProfileFolderName, IsFinalExecutionEnabled = true, @@ -1525,7 +1530,7 @@ private StartReadinessItemViewModel BuildAutopilotReadinessItem(MediaPreflightOp options.IsAutopilotConfigurationReady ? StartReadinessState.Ready : StartReadinessState.Blocked, options.IsAutopilotConfigurationReady ? FormatAutopilot(options) - : GetBlockingReasonText(MediaPreflightBlockingReason.AutopilotConfigurationNotReady), + : GetAutopilotValidationText(options.AutopilotConfigurationValidationCode), options.IsAutopilotConfigurationReady ? StartReadinessNavigationTarget.None : StartReadinessNavigationTarget.Autopilot); } @@ -1782,7 +1787,12 @@ private string FormatAutopilot(MediaPreflightOptions options) if (!options.IsAutopilotConfigurationReady) { - return localizationService.GetString("StartMedia.Autopilot.NotReady"); + return GetAutopilotValidationText(options.AutopilotConfigurationValidationCode); + } + + if (options.AutopilotProvisioningMode == AutopilotProvisioningMode.HardwareHashUpload) + { + return localizationService.GetString("StartMedia.Autopilot.HardwareHashUpload"); } return string.Format( @@ -1791,6 +1801,15 @@ private string FormatAutopilot(MediaPreflightOptions options) FormatValue(options.AutopilotProfileFolderName)); } + private string GetAutopilotValidationText(AutopilotConfigurationValidationCode code) + { + string key = $"StartMedia.Autopilot.Validation.{code}"; + string value = localizationService.GetString(key); + return string.Equals(value, key, StringComparison.Ordinal) + ? GetBlockingReasonText(MediaPreflightBlockingReason.AutopilotConfigurationNotReady) + : value; + } + private static string FormatValue(string? value) { return string.IsNullOrWhiteSpace(value) ? "-" : value; diff --git a/src/Foundry/Views/AutopilotPage.xaml b/src/Foundry/Views/AutopilotPage.xaml index 0679c293..c5a3f643 100644 --- a/src/Foundry/Views/AutopilotPage.xaml +++ b/src/Foundry/Views/AutopilotPage.xaml @@ -24,95 +24,279 @@ IsOn="{x:Bind ViewModel.IsAutopilotEnabled, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> - - - - - - - - - - - - - - + + + + + + + + + + + + - -