From fa14931e4df3b73e671e762c512f82fc12a3b314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 18:05:44 +0200 Subject: [PATCH 01/25] docs: plan autopilot hardware hash upload --- .../autopilot-hardware-hash-upload.md | 340 ++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 docs/implementation/autopilot-hardware-hash-upload.md diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md new file mode 100644 index 0000000..002f5c5 --- /dev/null +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -0,0 +1,340 @@ +# Autopilot Hardware Hash Upload Implementation Plan + +## 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-media` + - `feature/autopilot-hash-upload-runtime` + - `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. + +## 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. +- V1 should target x64, Ethernet-first, first-time import scenarios. +- V1 should avoid destructive cleanup of existing Intune, Autopilot, or Entra records. + +## 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` + +## 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. + +## 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. + +## Authentication Recommendation +V1 should not install PowerShell modules inside WinPE. + +Recommended direction: +- Use direct Microsoft Graph REST calls or a small .NET service abstraction. +- Use least-privilege upload permissions: + - `DeviceManagementServiceConfig.ReadWrite.All` for import. + - `DeviceManagementServiceConfig.Read.All` only if read-only polling is separated. +- Defer destructive permissions: + - `DeviceManagementManagedDevices.ReadWrite.All` + - `Device.ReadWrite.All` + - `GroupMember.ReadWrite.All` + +Authentication options to evaluate during implementation: +- Device code flow for operator-driven upload. +- Certificate-based app-only auth for controlled lab or factory use. +- A brokered upload workflow outside WinPE if storing credentials in media is rejected. + +Private keys, client secrets, and tenant-wide destructive permissions must not be silently embedded into generated media. + +## 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` + +## WinPE Hash Capture Strategy +V1 capture path: + +1. Stage `oa3tool.exe` from the local ADK. +2. Generate an OA3 config file and dummy input key file. +3. Run: + +```cmd +oa3tool.exe /Report /ConfigFile=.\OA3.cfg /NoKeyCheck /LogTrace=.\OA3.log +``` + +4. Read `OA3.xml`. +5. Extract: + - hardware hash + - serial number +6. Save diagnostics: + - `OA3.xml` + - `OA3.log` + - generated CSV + - Foundry upload result JSON + +V1 should add or gate `WinPE-SecureStartup` because TPM visibility matters for Autopilot quality. Existing media already includes WMI, NetFX, Scripting, PowerShell, WinReCfg, DismCmdlets, StorageWMI, Dot3Svc, and EnhancedStorage. + +`PCPKsp.dll` must be treated as an unresolved legal/support item. Do not bundle it in OSS releases until redistribution and support constraints are validated. + +## Phased Implementation + +### Phase 0: Foundation Branch And Research +- [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] Run baseline tests. + +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 +- [ ] Add `AutopilotProvisioningMode`. +- [ ] Extend `AutopilotSettings` with mode and hardware hash upload settings. +- [ ] Extend `DeployAutopilotSettings` with reduced runtime mode and upload settings. +- [ ] Update schema version handling if needed. +- [ ] Keep old configurations backward compatible as JSON profile mode. +- [ ] Update sanitization in `ExpertDeployConfigurationStateService`. +- [ ] Update `DeployConfigurationGenerator`. + +Automated tests: +- [ ] Existing JSON profile config serializes and generates the same deploy output. +- [ ] Enabled JSON mode requires a selected profile. +- [ ] Enabled hash upload mode does not require a selected profile. +- [ ] Invalid hash upload settings make Autopilot configuration not ready. + +Manual checks: +- [ ] Start Foundry with existing user config and confirm JSON profile mode is selected. +- [ ] Disable Autopilot and confirm no profile or hash settings are required. + +### Phase 2: Autopilot Page UX +- [ ] Replace single Autopilot action section with two settings expanders. +- [ ] Keep global Autopilot toggle. +- [ ] Move existing import/download/remove/default profile/table UI into JSON profile expander. +- [ ] Add hardware hash upload expander. +- [ ] Enforce mutual exclusivity between JSON profile and hash upload modes. +- [ ] Add localized strings in English and French resources. +- [ ] Update readiness messages to include selected mode. + +Automated tests: +- [ ] View model mode changes save state. +- [ ] Selecting JSON mode disables hash upload readiness requirements. +- [ ] Selecting hash upload mode disables JSON profile selection requirements. +- [ ] Busy state still blocks JSON profile import/download/remove commands. + +Manual checks: +- [ ] Autopilot disabled: both expanders are unavailable or collapsed according to final UX decision. +- [ ] Autopilot enabled: both expanders are visible. +- [ ] Activating one method deactivates the other. +- [ ] JSON profile import and tenant download still work. + +### Phase 3: Media Build And WinPE Assets +- [ ] Add `WinPE-SecureStartup` to required optional components or gate it behind hash upload mode. +- [ ] Locate and stage `oa3tool.exe` from the ADK. +- [ ] Add hash capture templates under a Foundry-owned WinPE path. +- [ ] Add hash upload runtime configuration under `X:\Foundry\Config`. +- [ ] 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. + +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. +- [ ] `WinPE-SecureStartup` missing or not applicable is surfaced clearly. + +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. +- [ ] Confirm `WinPE-SecureStartup` is present in the mounted image package list. +- [ ] Confirm no private key or client secret is written to media without explicit user action. + +### Phase 4: Foundry Deploy Runtime Branching +- [ ] Load Autopilot provisioning mode from deploy config. +- [ ] Expose mode in startup snapshot, preparation view model, launch request, deployment context, and runtime state. +- [ ] Update `DeploymentLaunchPreparationService` validation: + - JSON mode requires selected profile. + - Hash upload mode requires valid upload settings. +- [ ] Split current `StageAutopilotConfigurationStep` behavior: + - JSON mode copies `AutopilotConfigurationFile.json`. + - Hash upload mode runs the hash capture/upload workflow. +- [ ] Update deployment summary, logs, and telemetry with mode. + +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. +- [ ] Launch preparation rejects incomplete hash upload settings. + +Manual checks: +- [ ] Deploy dry-run in JSON mode. +- [ ] Deploy dry-run in hash upload mode. +- [ ] Confirm summary page displays the selected Autopilot method. +- [ ] Confirm logs contain mode, hash capture diagnostics path, and upload state. + +### Phase 5: Hash Capture Service +- [ ] Add a service that runs OA3Tool with controlled working directory paths. +- [ ] 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, empty hash, invalid XML, missing serial, and OA3 exit failure. + +Automated tests: +- [ ] 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. +- [ ] Confirm generated hash imports manually in Intune. +- [ ] Confirm troubleshooting files are retained in logs. + +### Phase 6: Graph Upload Service +- [ ] Add a minimal Graph Autopilot import client. +- [ ] Implement import request. +- [ ] Implement polling for import completion. +- [ ] Map Graph errors to operator-readable messages. +- [ ] Add retry/backoff for transient HTTP failures. +- [ ] Keep destructive cleanup out of V1. + +Automated tests: +- [ ] Serializes import payload correctly. +- [ ] Sends hardware identifier in the expected Graph format. +- [ ] Handles `complete`. +- [ ] Handles `error` with device error code/name. +- [ ] Times out with a clear message. +- [ ] Retries transient failures only. + +Manual checks: +- [ ] Import one test device into a test tenant. +- [ ] Confirm Group Tag appears in Intune. +- [ ] Confirm assignment sync behavior is documented, even if not waited on in V1. +- [ ] Confirm duplicate device behavior is clear to the operator. + +### Phase 7: Security And Tenant Onboarding +- [ ] Add a permission matrix to user documentation. +- [ ] Add tenant/app registration guidance. +- [ ] Decide supported auth mode for V1. +- [ ] Validate whether certificate auth can be safely used from generated media. +- [ ] Explicitly document unsupported secret embedding patterns. +- [ ] Add audit-safe logging rules. + +Automated tests: +- [ ] Secret settings are not serialized into plain deploy config unless intentionally allowed. +- [ ] Logs redact tokens, secrets, private key paths, and certificate material. + +Manual checks: +- [ ] Review generated media contents for secrets. +- [ ] Review logs after failed auth and successful auth. +- [ ] Confirm least-privilege app registration can import devices. + +### Phase 8: Documentation And Release Guardrails +- [ ] Add user documentation for hardware hash upload from WinPE. +- [ ] Mark WinPE hash capture as best-effort and not the Microsoft-standard method. +- [ ] Document x64-only V1 scope. +- [ ] Document Ethernet recommendation. +- [ ] Document unsupported or risky scenarios: + - self-deploying mode + - pre-provisioning + - Wi-Fi-only devices + - ARM64 + - missing TPM visibility + - unsupported `PCPKsp.dll` redistribution +- [ ] Update screenshots after UI implementation. + +Manual checks: +- [ ] Follow the docs on a clean test tenant. +- [ ] Follow the docs on a clean x64 test device. +- [ ] Confirm fallback to OOBE/full OS instructions are clear. + +## Open Questions +- Should V1 upload directly from WinPE to Graph, or should it support capture-only with deferred upload first? +- Which authentication mode is acceptable for generated media? +- Can `PCPKsp.dll` be used or referenced without redistribution risk? +- Should ARM64 be explicitly blocked in hash upload mode for V1? +- Should duplicate device cleanup ever be added, or should Foundry only surface the duplicate and stop? + +## 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` + From a95ecd2b14c288b9124718c81f553e6e0595bff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 18:10:04 +0200 Subject: [PATCH 02/25] docs(autopilot): expand hash upload implementation plan --- .../autopilot-hardware-hash-upload.md | 333 +++++++++++++++++- 1 file changed, 332 insertions(+), 1 deletion(-) diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index 002f5c5..59a1d7c 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -10,13 +10,47 @@ This feature is intended to complement the existing offline Autopilot JSON profi - 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-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-security` - `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. +## 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-ui` | `feat(autopilot): add provisioning method selection` | Autopilot page expanders, mutually exclusive method selection, localized strings. | +| 3 | `feature/autopilot-hash-upload-media` | `feat(winpe): stage autopilot hash capture assets` | WinPE optional component requirements, OA3Tool discovery, media payload layout. | +| 4 | `feature/autopilot-hash-upload-runtime` | `feat(deploy): branch autopilot runtime by provisioning mode` | Deploy startup snapshot, launch validation, runtime state, dry-run manifests. | +| 5 | `feature/autopilot-hash-upload-capture` | `feat(deploy): capture autopilot hardware hash in WinPE` | OA3Tool execution service, `OA3.xml` parsing, CSV/diagnostic artifacts. | +| 6 | `feature/autopilot-hash-upload-graph` | `feat(autopilot): import hardware hashes with Graph` | Graph client, import polling, retry policy, operator-facing errors. | +| 7 | `feature/autopilot-hash-upload-security` | `feat(autopilot): add secure tenant upload onboarding` | Auth mode, secret handling, redaction, permission validation, tenant readiness. | +| 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 For V1 +- Do not remove or redesign the existing offline JSON profile workflow. +- Do not involve Foundry Connect. +- Do not install PowerShell Gallery modules inside WinPE. +- 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 support ARM64 until x64 behavior is validated on physical hardware. +- Do not redistribute `PCPKsp.dll` unless redistribution rights and support boundaries are validated. + ## 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. @@ -27,6 +61,22 @@ The foundation branch should remain documentation-first. Implementation branches - V1 should target x64, Ethernet-first, first-time import scenarios. - V1 should avoid destructive cleanup of existing Intune, Autopilot, or Entra records. +## 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 should be treated as the supported V1 network path. +- Wi-Fi-only devices are risky because OA3Tool documentation and community experience point to incomplete or inconsistent device visibility from WinPE. +- 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 matters. The OA3Tool staged into media should come from the same installed ADK family used to build the WinPE image. +- x64 should be the only supported V1 architecture unless ARM64 validation is completed separately. + +Support positioning: +- Supported by Foundry V1 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: @@ -45,6 +95,31 @@ Key files: - `src/Foundry.Deploy/Services/Deployment/DeploymentLaunchPreparationService.cs` - `src/Foundry.Deploy/Services/Deployment/Steps/StageAutopilotConfigurationStep.cs` +Current data flow: + +```text +Foundry app + -> ExpertDeployConfigurationStateService + -> 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 +``` + +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 but is the correct execution boundary for Autopilot mode branching. +- 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. + ## Target UX On the Autopilot page: @@ -56,6 +131,36 @@ On the Autopilot page: - 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". +- Tenant ID field. +- Authentication mode selector. +- Group Tag field. +- Optional assigned user UPN field. +- Upload timing selector: + - `CaptureOnly` + - `CaptureAndUpload` +- Optional "wait for import completion" setting. +- Optional "wait for assignment" setting should be deferred unless proven reliable. +- Readiness and warning text for x64, Ethernet, 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". + ## Proposed Runtime Model Add an explicit provisioning mode. @@ -81,6 +186,60 @@ Deploy runtime configuration should receive only the reduced settings needed by 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 string TenantId { get; init; } = string.Empty; + public AutopilotHashUploadAuthenticationMode AuthenticationMode { get; init; } = AutopilotHashUploadAuthenticationMode.DeviceCode; + public string ClientId { get; init; } = string.Empty; + public string? CertificateThumbprint { get; init; } + public string? GroupTag { get; init; } + public string? AssignedUserPrincipalName { get; init; } + public AutopilotHashUploadMode UploadMode { get; init; } = AutopilotHashUploadMode.CaptureAndUpload; + public bool WaitForImportCompletion { get; init; } = true; +} +``` + +Proposed enums: + +```csharp +public enum AutopilotHashUploadAuthenticationMode +{ + DeviceCode, + Certificate +} + +public enum AutopilotHashUploadMode +{ + CaptureOnly, + CaptureAndUpload +} +``` + +Validation rules: +- `IsEnabled=false`: no Autopilot settings are required. +- `IsEnabled=true` and `JsonProfile`: selected profile must exist. +- `IsEnabled=true` and `HardwareHashUpload`: tenant ID and supported auth settings must be valid. +- `CaptureOnly`: Graph auth settings are optional. +- `CaptureAndUpload`: Graph auth settings are required. +- `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. + ## Authentication Recommendation V1 should not install PowerShell modules inside WinPE. @@ -101,6 +260,22 @@ Authentication options to evaluate during implementation: Private keys, client secrets, and tenant-wide destructive permissions must not be silently embedded into generated media. +Recommended V1 auth decision: +- Start with device code flow if the operator can complete sign-in from another device. +- Treat certificate app-only auth as a controlled-lab feature only after secret handling is designed. +- Avoid client secrets for generated media. + +Open auth design choices: +- Whether the token is acquired by Foundry Deploy inside WinPE. +- Whether Foundry OSD pre-validates tenant/app settings before media generation. +- Whether a future broker service receives hashes from WinPE and performs Graph upload outside the 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. +- If certificate auth is implemented, prefer referencing a certificate already available to the runtime instead of embedding a PFX. +- If a PFX import path is ever supported, require explicit user confirmation and document that the media becomes sensitive. + ## Microsoft Graph Import Shape Use Microsoft Graph `v1.0`: @@ -122,6 +297,24 @@ Import state polling should handle: - `complete` - `error` +Minimum Graph permission matrix: + +| Capability | Permission | V1 status | +| --- | --- | --- | +| Import Autopilot device identity | `DeviceManagementServiceConfig.ReadWrite.All` | Required for `CaptureAndUpload`. | +| Poll imported device identity state | `DeviceManagementServiceConfig.Read.All` or `DeviceManagementServiceConfig.ReadWrite.All` | Required when waiting for completion. | +| Delete Autopilot device identity | `DeviceManagementServiceConfig.ReadWrite.All` | Deferred. Not automatic in V1. | +| Delete Intune managed device | `DeviceManagementManagedDevices.ReadWrite.All` | Deferred. Not in V1. | +| Delete Entra device | `Device.ReadWrite.All` | Deferred. Not in V1. | +| Add device to group | `GroupMember.ReadWrite.All` | Deferred. Not in V1. | + +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. + ## WinPE Hash Capture Strategy V1 capture path: @@ -147,9 +340,38 @@ V1 should add or gate `WinPE-SecureStartup` because TPM visibility matters for A `PCPKsp.dll` must be treated as an unresolved legal/support item. Do not bundle it in OSS releases until redistribution and support constraints are validated. +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` + +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` + +Failure taxonomy: +- `ToolMissing`: OA3Tool is not staged or cannot execute. +- `ToolFailed`: OA3Tool exits non-zero. +- `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. +- `AuthenticationFailed`: token acquisition failed. +- `ImportFailed`: Graph accepted the request path but import state reports `error`. +- `ImportTimedOut`: import polling exceeded the configured timeout. + ## Phased Implementation ### Phase 0: Foundation Branch And Research +PR title: `docs(autopilot): plan hardware hash upload from WinPE` + - [x] Create dedicated worktree. - [x] Create foundation branch. - [x] Analyze supplied feasibility document. @@ -162,6 +384,8 @@ Manual checks: - [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` + - [ ] Add `AutopilotProvisioningMode`. - [ ] Extend `AutopilotSettings` with mode and hardware hash upload settings. - [ ] Extend `DeployAutopilotSettings` with reduced runtime mode and upload settings. @@ -181,6 +405,8 @@ Manual checks: - [ ] Disable Autopilot and confirm no profile or hash settings are required. ### Phase 2: Autopilot Page UX +PR title: `feat(autopilot): add provisioning method selection` + - [ ] Replace single Autopilot action section with two settings expanders. - [ ] Keep global Autopilot toggle. - [ ] Move existing import/download/remove/default profile/table UI into JSON profile expander. @@ -202,6 +428,8 @@ Manual checks: - [ ] JSON profile import and tenant download still work. ### Phase 3: Media Build And WinPE Assets +PR title: `feat(winpe): stage autopilot hash capture assets` + - [ ] Add `WinPE-SecureStartup` to required optional components or gate it behind hash upload mode. - [ ] Locate and stage `oa3tool.exe` from the ADK. - [ ] Add hash capture templates under a Foundry-owned WinPE path. @@ -222,6 +450,8 @@ Manual checks: - [ ] Confirm no private key or client secret is written to media without explicit user action. ### Phase 4: Foundry Deploy Runtime Branching +PR title: `feat(deploy): branch autopilot runtime by provisioning mode` + - [ ] Load Autopilot provisioning mode from deploy config. - [ ] Expose mode in startup snapshot, preparation view model, launch request, deployment context, and runtime state. - [ ] Update `DeploymentLaunchPreparationService` validation: @@ -245,6 +475,8 @@ Manual checks: - [ ] Confirm logs contain mode, hash capture diagnostics path, and upload state. ### Phase 5: Hash Capture Service +PR title: `feat(deploy): capture autopilot hardware hash in WinPE` + - [ ] Add a service that runs OA3Tool with controlled working directory paths. - [ ] Generate `OA3.cfg` and dummy input XML internally. - [ ] Validate `OA3.xml` exists. @@ -266,6 +498,8 @@ Manual checks: - [ ] Confirm troubleshooting files are retained in logs. ### Phase 6: Graph Upload Service +PR title: `feat(autopilot): import hardware hashes with Graph` + - [ ] Add a minimal Graph Autopilot import client. - [ ] Implement import request. - [ ] Implement polling for import completion. @@ -288,6 +522,8 @@ Manual checks: - [ ] Confirm duplicate device behavior is clear to the operator. ### Phase 7: Security And Tenant Onboarding +PR title: `feat(autopilot): add secure tenant upload onboarding` + - [ ] Add a permission matrix to user documentation. - [ ] Add tenant/app registration guidance. - [ ] Decide supported auth mode for V1. @@ -305,6 +541,8 @@ Manual checks: - [ ] Confirm least-privilege app registration can import devices. ### Phase 8: Documentation And Release Guardrails +PR title: `docs(autopilot): document WinPE hardware hash upload` + - [ ] Add user documentation for hardware hash upload from WinPE. - [ ] Mark WinPE hash capture as best-effort and not the Microsoft-standard method. - [ ] Document x64-only V1 scope. @@ -323,6 +561,100 @@ Manual checks: - [ ] Follow the docs on a clean x64 test device. - [ ] Confirm fallback to OOBE/full OS instructions are clear. +## 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 still blocking for shared model/UI/runtime changes even if hash upload is blocked at runtime for ARM64 V1. + +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 V1 release | +| --- | --- | +| x64 physical device with Ethernet, 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. | +| JSON profile mode regression | Yes | +| Capture-only mode | Yes if included in V1. | +| Capture-and-upload mode | Yes if included in V1. | +| Wi-Fi-only device | No, document as unsupported or risky. | +| ARM64 device | No for V1, must be blocked or clearly unsupported. | +| Self-deploying/pre-provisioning | No for V1, document as not recommended. | + +## 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 in V1. | +| Credentials embedded into media | Tenant compromise | Prefer device code or brokered upload; block silent secret embedding. | +| Broad Graph permissions copied from community script | Excessive tenant blast radius | Minimum permission matrix and no destructive V1 flows. | +| Duplicate devices already exist | Import fails or operator confusion | Surface duplicate/import error clearly; defer cleanup automation. | +| ARM64 mismatch for OA3Tool/support files | Runtime failure | Block ARM64 hash upload in V1 until validated. | +| `PCPKsp.dll` redistribution is not allowed | Legal/support issue | Do not bundle; document as unresolved. | +| UI conflates JSON and hash mode | Invalid media or deployment launch | Explicit `ProvisioningMode` and readiness rules. | + +## Implementation Boundaries +Foundry app owns: +- User-facing Autopilot mode 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. +- OA3Tool execution if capture happens inside Deploy. +- Graph import if upload happens inside Deploy. +- Deployment logs and summary artifacts. + +Foundry.Connect owns: +- Nothing for V1. + +## Documentation Deliverables +- Foundry app documentation: + - Autopilot provisioning modes. + - Hardware hash upload setup. + - Tenant permissions. + - Security warning for generated media. + - Troubleshooting. +- Foundry OSD docs site: + - New Autopilot hardware hash upload page. + - Requirements update for `WinPE-SecureStartup`. + - Product boundaries update explaining the workaround status. + - Manual test checklist. +- Release notes: + - Mark as x64/Ethernet-first. + - Mention unsupported ARM64/Wi-Fi-only/self-deploying/pre-provisioning status. + ## Open Questions - Should V1 upload directly from WinPE to Graph, or should it support capture-only with deferred upload first? - Which authentication mode is acceptable for generated media? @@ -337,4 +669,3 @@ Manual checks: - 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` - From ba7bb22bc03ef4d3eb5dd4cb672ce0f2b3b2c6bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 18:11:14 +0200 Subject: [PATCH 03/25] chore(gitignore): ignore project lscache files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6af5a7a..017daf1 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/ From e71ed88529ba8ee53e346196399ce25c0ff1b045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 18:22:09 +0200 Subject: [PATCH 04/25] docs(autopilot): align hash upload implementation plan --- .../autopilot-hardware-hash-upload.md | 165 +++++++++++------- 1 file changed, 101 insertions(+), 64 deletions(-) diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index 59a1d7c..6919dea 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -28,10 +28,10 @@ All PR titles must stay in English and use Conventional Commits. Each phase bran | 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-ui` | `feat(autopilot): add provisioning method selection` | Autopilot page expanders, mutually exclusive method selection, localized strings. | -| 3 | `feature/autopilot-hash-upload-media` | `feat(winpe): stage autopilot hash capture assets` | WinPE optional component requirements, OA3Tool discovery, media payload layout. | -| 4 | `feature/autopilot-hash-upload-runtime` | `feat(deploy): branch autopilot runtime by provisioning mode` | Deploy startup snapshot, launch validation, runtime state, dry-run manifests. | -| 5 | `feature/autopilot-hash-upload-capture` | `feat(deploy): capture autopilot hardware hash in WinPE` | OA3Tool execution service, `OA3.xml` parsing, CSV/diagnostic artifacts. | -| 6 | `feature/autopilot-hash-upload-graph` | `feat(autopilot): import hardware hashes with Graph` | Graph client, import polling, retry policy, operator-facing errors. | +| 3 | `feature/autopilot-hash-upload-media` | `feat(winpe): stage autopilot hash capture assets` | WinPE optional component requirements, x64/ARM64 OA3Tool discovery, media payload layout. | +| 4 | `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. | +| 5 | `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. | +| 6 | `feature/autopilot-hash-upload-graph` | `feat(autopilot): import hardware hashes with Graph` | C# Graph client, import polling, retry policy, operator-facing errors. | | 7 | `feature/autopilot-hash-upload-security` | `feat(autopilot): add secure tenant upload onboarding` | Auth mode, secret handling, redaction, permission validation, tenant readiness. | | 8 | `feature/autopilot-hash-upload-docs` | `docs(autopilot): document WinPE hardware hash upload` | User docs, permissions matrix, troubleshooting, screenshots, release notes. | @@ -41,15 +41,15 @@ Expected PR description structure: - Main changes: concise bullet list. - Testing notes: exact automated commands and manual checks. -## Non-Goals For V1 +## Non-Goals - Do not remove or redesign the existing offline JSON profile workflow. - Do not involve Foundry Connect. -- Do not install PowerShell Gallery modules inside WinPE. +- 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 support ARM64 until x64 behavior is validated on physical hardware. -- Do not redistribute `PCPKsp.dll` unless redistribution rights and support boundaries are validated. +- 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. ## Feasibility Summary - WinPE hardware hash capture is technically feasible with `OA3Tool.exe /Report`. @@ -58,22 +58,23 @@ Expected PR description structure: - 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. -- V1 should target x64, Ethernet-first, first-time import scenarios. -- V1 should avoid destructive cleanup of existing Intune, Autopilot, or Entra records. +- The final implementation should support x64 and ARM64 by using architecture-specific ADK assets. +- The final implementation should be Ethernet-first for the upload path 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 should be treated as the supported V1 network path. +- Ethernet should be treated as the supported network path. - Wi-Fi-only devices are risky because OA3Tool documentation and community experience point to incomplete or inconsistent device visibility from WinPE. - 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 matters. The OA3Tool staged into media should come from the same installed ADK family used to build the WinPE image. -- x64 should be the only supported V1 architecture unless ARM64 validation is completed separately. +- 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 V1 as a best-effort workflow for user-driven Autopilot registration. +- 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. @@ -113,6 +114,21 @@ Foundry app -> \Windows\Provisioning\Autopilot\AutopilotConfigurationFile.json ``` +Target hash upload data flow: + +```text +Foundry Deploy deployment run + -> apply Windows image + -> 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. @@ -148,11 +164,11 @@ Hardware hash upload: - Group Tag field. - Optional assigned user UPN field. - Upload timing selector: - - `CaptureOnly` - `CaptureAndUpload` + - `CaptureOnly` for diagnostics only, if retained. - Optional "wait for import completion" setting. - Optional "wait for assignment" setting should be deferred unless proven reliable. -- Readiness and warning text for x64, Ethernet, WinPE-SecureStartup, and unsupported scenarios. +- Readiness and warning text for x64, ARM64, Ethernet, WinPE-SecureStartup, and unsupported scenarios. UX rules: - The global Autopilot toggle controls whether either method is active. @@ -241,10 +257,11 @@ Validation rules: - `GroupTag` must not contain commas and should stay ASCII-safe for CSV compatibility. ## Authentication Recommendation -V1 should not install PowerShell modules inside WinPE. +The implementation must not use PowerShell for hardware hash capture or upload actions. Recommended direction: -- Use direct Microsoft Graph REST calls or a small .NET service abstraction. +- Use direct Microsoft Graph REST calls through C# service abstractions. +- Invoke OA3Tool through the existing C# process execution patterns, not through PowerShell. - Use least-privilege upload permissions: - `DeviceManagementServiceConfig.ReadWrite.All` for import. - `DeviceManagementServiceConfig.Read.All` only if read-only polling is separated. @@ -260,7 +277,7 @@ Authentication options to evaluate during implementation: Private keys, client secrets, and tenant-wide destructive permissions must not be silently embedded into generated media. -Recommended V1 auth decision: +Recommended auth decision: - Start with device code flow if the operator can complete sign-in from another device. - Treat certificate app-only auth as a controlled-lab feature only after secret handling is designed. - Avoid client secrets for generated media. @@ -299,14 +316,14 @@ Import state polling should handle: Minimum Graph permission matrix: -| Capability | Permission | V1 status | +| Capability | Permission | Implementation status | | --- | --- | --- | | Import Autopilot device identity | `DeviceManagementServiceConfig.ReadWrite.All` | Required for `CaptureAndUpload`. | | Poll imported device identity state | `DeviceManagementServiceConfig.Read.All` or `DeviceManagementServiceConfig.ReadWrite.All` | Required when waiting for completion. | -| Delete Autopilot device identity | `DeviceManagementServiceConfig.ReadWrite.All` | Deferred. Not automatic in V1. | -| Delete Intune managed device | `DeviceManagementManagedDevices.ReadWrite.All` | Deferred. Not in V1. | -| Delete Entra device | `Device.ReadWrite.All` | Deferred. Not in V1. | -| Add device to group | `GroupMember.ReadWrite.All` | Deferred. Not in V1. | +| 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. | Graph request rules: - Prefer a direct HTTP client abstraction with typed request/response records. @@ -316,29 +333,32 @@ Graph request rules: - Do not retry deterministic validation failures. ## WinPE Hash Capture Strategy -V1 capture path: +Final capture path: -1. Stage `oa3tool.exe` from the local ADK. -2. Generate an OA3 config file and dummy input key file. -3. Run: +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 ``` -4. Read `OA3.xml`. -5. Extract: +6. Read `OA3.xml`. +7. Extract: - hardware hash - serial number -6. Save diagnostics: +8. Upload through the C# Microsoft Graph import service when `CaptureAndUpload` is selected. +9. Save diagnostics: - `OA3.xml` - `OA3.log` - generated CSV - Foundry upload result JSON -V1 should add or gate `WinPE-SecureStartup` because TPM visibility matters for Autopilot quality. Existing media already includes WMI, NetFX, Scripting, PowerShell, WinReCfg, DismCmdlets, StorageWMI, Dot3Svc, and EnhancedStorage. +The implementation should add or gate `WinPE-SecureStartup` because TPM visibility matters 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 be treated as an unresolved legal/support item. Do not bundle it in OSS releases until redistribution and support constraints are validated. +`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 or cannot be copied, the hash upload step should fail with a clear diagnostic and keep the rest of deployment behavior explicit. Proposed WinPE paths: - `X:\Foundry\Tools\OA3\oa3tool.exe` @@ -348,6 +368,7 @@ Proposed WinPE paths: - `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` @@ -358,6 +379,9 @@ Proposed retained log paths after deployment: 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. @@ -431,21 +455,25 @@ Manual checks: PR title: `feat(winpe): stage autopilot hash capture assets` - [ ] Add `WinPE-SecureStartup` to required optional components or gate it behind hash upload mode. -- [ ] Locate and stage `oa3tool.exe` from the ADK. +- [ ] 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`. - [ ] 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. 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. - [ ] `WinPE-SecureStartup` missing or not applicable is surfaced clearly. 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 private key or client secret is written to media without explicit user action. @@ -457,15 +485,16 @@ PR title: `feat(deploy): branch autopilot runtime by provisioning mode` - [ ] Update `DeploymentLaunchPreparationService` validation: - JSON mode requires selected profile. - Hash upload mode requires valid upload settings. -- [ ] Split current `StageAutopilotConfigurationStep` behavior: - - JSON mode copies `AutopilotConfigurationFile.json`. - - Hash upload mode runs the hash capture/upload workflow. +- [ ] Keep `StageAutopilotConfigurationStep` profile-only so JSON mode copies `AutopilotConfigurationFile.json`. +- [ ] Add a late hash upload deployment step after OS apply and before deployment finalization. +- [ ] Hash upload mode skips JSON staging and runs the hash capture/upload workflow from the late deployment step. - [ ] Update deployment summary, logs, and telemetry with mode. 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. +- [ ] Hash upload step is ordered after the applied Windows root is available. - [ ] Launch preparation rejects incomplete hash upload settings. Manual checks: @@ -477,15 +506,19 @@ Manual checks: ### Phase 5: Hash Capture Service PR title: `feat(deploy): capture autopilot hardware hash in WinPE` -- [ ] Add a service that runs OA3Tool with controlled working directory paths. +- [ ] 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, empty hash, invalid XML, missing serial, and OA3 exit failure. +- [ ] Return structured failure codes for missing tool, `PCPKsp.dll` copy/load failure, empty hash, invalid XML, missing serial, and OA3 exit failure. 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. @@ -494,6 +527,7 @@ Automated tests: Manual checks: - [ ] Run on one x64 physical test device with Ethernet. +- [ ] Run on one ARM64 physical test device with Ethernet. - [ ] Confirm generated hash imports manually in Intune. - [ ] Confirm troubleshooting files are retained in logs. @@ -505,7 +539,7 @@ PR title: `feat(autopilot): import hardware hashes with Graph` - [ ] Implement polling for import completion. - [ ] Map Graph errors to operator-readable messages. - [ ] Add retry/backoff for transient HTTP failures. -- [ ] Keep destructive cleanup out of V1. +- [ ] Keep destructive cleanup out of the final hash upload workflow. Automated tests: - [ ] Serializes import payload correctly. @@ -518,7 +552,7 @@ Automated tests: Manual checks: - [ ] Import one test device into a test tenant. - [ ] Confirm Group Tag appears in Intune. -- [ ] Confirm assignment sync behavior is documented, even if not waited on in V1. +- [ ] Confirm assignment sync behavior is documented, even if not waited on by the final implementation. - [ ] Confirm duplicate device behavior is clear to the operator. ### Phase 7: Security And Tenant Onboarding @@ -526,7 +560,7 @@ PR title: `feat(autopilot): add secure tenant upload onboarding` - [ ] Add a permission matrix to user documentation. - [ ] Add tenant/app registration guidance. -- [ ] Decide supported auth mode for V1. +- [ ] Decide supported auth mode for the final implementation. - [ ] Validate whether certificate auth can be safely used from generated media. - [ ] Explicitly document unsupported secret embedding patterns. - [ ] Add audit-safe logging rules. @@ -545,20 +579,20 @@ PR title: `docs(autopilot): document WinPE hardware hash upload` - [ ] Add user documentation for hardware hash upload from WinPE. - [ ] Mark WinPE hash capture as best-effort and not the Microsoft-standard method. -- [ ] Document x64-only V1 scope. +- [ ] Document x64 and ARM64 scope. +- [ ] Document that Foundry copies `PCPKsp.dll` from the applied OS to `X:\Windows\System32` late in deployment. - [ ] Document Ethernet recommendation. - [ ] Document unsupported or risky scenarios: - self-deploying mode - pre-provisioning - Wi-Fi-only devices - - ARM64 - missing TPM visibility - - unsupported `PCPKsp.dll` redistribution - [ ] Update screenshots after UI implementation. 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. ## Cross-Cutting Test Matrix @@ -572,7 +606,7 @@ CI must pass for: - x64 - ARM64 -ARM64 CI is still blocking for shared model/UI/runtime changes even if hash upload is blocked at runtime for ARM64 V1. +ARM64 CI is blocking because hash upload support is in scope for both architectures. Recommended focused test areas: - `Foundry.Core.Tests` @@ -592,28 +626,30 @@ Recommended focused test areas: Manual physical validation matrix: -| Scenario | Required before V1 release | +| Scenario | Required before release | | --- | --- | | x64 physical device with Ethernet, 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. | -| JSON profile mode regression | Yes | -| Capture-only mode | Yes if included in V1. | -| Capture-and-upload mode | Yes if included in V1. | +| ARM64 physical device with Ethernet, 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 | +| Capture-only diagnostic mode | Yes if included. | +| Capture-and-upload mode | Yes | | Wi-Fi-only device | No, document as unsupported or risky. | -| ARM64 device | No for V1, must be blocked or clearly unsupported. | -| Self-deploying/pre-provisioning | No for V1, document as not recommended. | +| 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 in V1. | +| TPM not visible from WinPE | Self-deploying/pre-provisioning unreliable | Do not recommend those scenarios until separately validated. | | Credentials embedded into media | Tenant compromise | Prefer device code or brokered upload; block silent secret embedding. | -| Broad Graph permissions copied from community script | Excessive tenant blast radius | Minimum permission matrix and no destructive V1 flows. | +| 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; defer cleanup automation. | -| ARM64 mismatch for OA3Tool/support files | Runtime failure | Block ARM64 hash upload in V1 until validated. | -| `PCPKsp.dll` redistribution is not allowed | Legal/support issue | Do not bundle; document as unresolved. | +| 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 | Hash capture fails late in deployment | Copy from `\Windows\System32` after OS apply, fail clearly, and retain diagnostics. | | UI conflates JSON and hash mode | Invalid media or deployment launch | Explicit `ProvisioningMode` and readiness rules. | ## Implementation Boundaries @@ -632,12 +668,14 @@ Foundry.Core owns: Foundry.Deploy owns: - Runtime configuration consumption. - Deployment wizard behavior. -- OA3Tool execution if capture happens inside Deploy. -- Graph import if upload happens inside Deploy. +- Late deployment workflow step after OS apply. +- `PCPKsp.dll` copy from the applied Windows image to `X:\Windows\System32`. +- OA3Tool execution through C# process orchestration. +- Graph import through C# service abstractions. - Deployment logs and summary artifacts. Foundry.Connect owns: -- Nothing for V1. +- Nothing for this feature. ## Documentation Deliverables - Foundry app documentation: @@ -652,14 +690,13 @@ Foundry.Connect owns: - Product boundaries update explaining the workaround status. - Manual test checklist. - Release notes: - - Mark as x64/Ethernet-first. - - Mention unsupported ARM64/Wi-Fi-only/self-deploying/pre-provisioning status. + - Mark as x64 and ARM64 with Ethernet-first upload guidance. + - Mention unsupported or risky Wi-Fi-only/self-deploying/pre-provisioning status. ## Open Questions -- Should V1 upload directly from WinPE to Graph, or should it support capture-only with deferred upload first? +- Should the final implementation keep a capture-only diagnostic mode in addition to capture-and-upload? - Which authentication mode is acceptable for generated media? -- Can `PCPKsp.dll` be used or referenced without redistribution risk? -- Should ARM64 be explicitly blocked in hash upload mode for V1? +- Should `PCPKsp.dll` copy failure stop the full deployment, or only stop the Autopilot upload step? - Should duplicate device cleanup ever be added, or should Foundry only surface the duplicate and stop? ## Source References From 4b90e1df57f58e69a24725e0fcd360f954e8c25e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 18:35:04 +0200 Subject: [PATCH 05/25] docs(autopilot): update network and secure startup scope --- .../autopilot-hardware-hash-upload.md | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index 6919dea..2e4b300 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -59,15 +59,15 @@ Expected PR description structure: - 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 be Ethernet-first for the upload path and should avoid destructive cleanup of existing Intune, Autopilot, or Entra records. +- 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 should be treated as the supported network path. -- Wi-Fi-only devices are risky because OA3Tool documentation and community experience point to incomplete or inconsistent device visibility from WinPE. +- 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. @@ -168,7 +168,7 @@ Hardware hash upload: - `CaptureOnly` for diagnostics only, if retained. - Optional "wait for import completion" setting. - Optional "wait for assignment" setting should be deferred unless proven reliable. -- Readiness and warning text for x64, ARM64, Ethernet, WinPE-SecureStartup, and unsupported scenarios. +- 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. @@ -356,7 +356,7 @@ oa3tool.exe /Report /ConfigFile=.\OA3.cfg /NoKeyCheck /LogTrace=.\OA3.log - generated CSV - Foundry upload result JSON -The implementation should add or gate `WinPE-SecureStartup` because TPM visibility matters 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. +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 or cannot be copied, the hash upload step should fail with a clear diagnostic and keep the rest of deployment behavior explicit. @@ -454,7 +454,7 @@ Manual checks: ### Phase 3: Media Build And WinPE Assets PR title: `feat(winpe): stage autopilot hash capture assets` -- [ ] Add `WinPE-SecureStartup` to required optional components or gate it behind hash upload mode. +- [ ] 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`. @@ -467,7 +467,7 @@ Automated tests: - [ ] 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. -- [ ] `WinPE-SecureStartup` missing or not applicable is surfaced clearly. +- [ ] `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. @@ -527,7 +527,9 @@ Automated tests: 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 in logs. @@ -581,11 +583,10 @@ PR title: `docs(autopilot): document WinPE hardware hash upload` - [ ] 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 Ethernet recommendation. +- [ ] Document network requirements for Ethernet and Wi-Fi. - [ ] Document unsupported or risky scenarios: - self-deploying mode - pre-provisioning - - Wi-Fi-only devices - missing TPM visibility - [ ] Update screenshots after UI implementation. @@ -629,15 +630,16 @@ 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 | | Capture-only diagnostic mode | Yes if included. | | Capture-and-upload mode | Yes | -| Wi-Fi-only device | No, document as unsupported or risky. | | Self-deploying/pre-provisioning | No, document as not recommended until separately validated. | ## Risk Register @@ -686,12 +688,12 @@ Foundry.Connect owns: - Troubleshooting. - Foundry OSD docs site: - New Autopilot hardware hash upload page. - - Requirements update for `WinPE-SecureStartup`. + - Requirements update documenting `WinPE-SecureStartup` as a default WinPE optional component. - Product boundaries update explaining the workaround status. - Manual test checklist. - Release notes: - - Mark as x64 and ARM64 with Ethernet-first upload guidance. - - Mention unsupported or risky Wi-Fi-only/self-deploying/pre-provisioning status. + - Mark as x64 and ARM64 with Ethernet and Wi-Fi upload guidance. + - Mention unsupported or risky self-deploying/pre-provisioning status. ## Open Questions - Should the final implementation keep a capture-only diagnostic mode in addition to capture-and-upload? From 7aedf4307a13c1eb4e73fc681ed7948dc3021b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 18:53:07 +0200 Subject: [PATCH 06/25] docs(autopilot): plan certificate media secret storage --- .../autopilot-hardware-hash-upload.md | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index 2e4b300..5ae3cf0 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -278,8 +278,8 @@ Authentication options to evaluate during implementation: Private keys, client secrets, and tenant-wide destructive permissions must not be silently embedded into generated media. Recommended auth decision: -- Start with device code flow if the operator can complete sign-in from another device. -- Treat certificate app-only auth as a controlled-lab feature only after secret handling is designed. +- Prefer certificate-based app-only auth for unattended or near zero-touch WinPE upload. +- Keep device code flow as a manual/operator-assisted fallback if certificate material is not embedded. - Avoid client secrets for generated media. Open auth design choices: @@ -290,8 +290,30 @@ Open auth design choices: 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. -- If certificate auth is implemented, prefer referencing a certificate already available to the runtime instead of embedding a PFX. -- If a PFX import path is ever supported, require explicit user confirmation and document that the media becomes sensitive. +- Store embedded certificate private key material with the same envelope concept used by Foundry Connect personal Wi-Fi secrets. +- Require explicit user confirmation before embedding certificate private key material and document that the generated media becomes tenant-sensitive. +- 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, PEM private key, access token, or refresh token to disk. + +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`: @@ -413,6 +435,7 @@ PR title: `feat(autopilot): add provisioning mode configuration` - [ ] Add `AutopilotProvisioningMode`. - [ ] Extend `AutopilotSettings` with mode and hardware hash upload settings. - [ ] Extend `DeployAutopilotSettings` with reduced runtime mode and upload settings. +- [ ] Add encrypted certificate private key settings for certificate-based upload. - [ ] Update schema version handling if needed. - [ ] Keep old configurations backward compatible as JSON profile mode. - [ ] Update sanitization in `ExpertDeployConfigurationStateService`. @@ -423,6 +446,7 @@ Automated tests: - [ ] Enabled JSON mode requires a selected profile. - [ ] Enabled hash upload mode does not require a selected profile. - [ ] Invalid hash upload settings make Autopilot configuration not ready. +- [ ] Certificate private key material is not serialized in plaintext. Manual checks: - [ ] Start Foundry with existing user config and confirm JSON profile mode is selected. @@ -458,6 +482,7 @@ PR title: `feat(winpe): stage autopilot hash capture assets` - [ ] 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 certificate private key envelopes and 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. @@ -467,6 +492,8 @@ Automated tests: - [ ] 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: @@ -537,6 +564,7 @@ Manual checks: PR title: `feat(autopilot): import hardware hashes with Graph` - [ ] Add a minimal Graph Autopilot import client. +- [ ] Add certificate-based credential creation from decrypted in-memory certificate material. - [ ] Implement import request. - [ ] Implement polling for import completion. - [ ] Map Graph errors to operator-readable messages. @@ -546,6 +574,7 @@ PR title: `feat(autopilot): import hardware hashes with Graph` Automated tests: - [ ] Serializes import payload correctly. - [ ] Sends hardware identifier in the expected Graph format. +- [ ] Decrypts certificate material in memory and does not write a decrypted PFX/private key to disk. - [ ] Handles `complete`. - [ ] Handles `error` with device error code/name. - [ ] Times out with a clear message. @@ -563,16 +592,19 @@ PR title: `feat(autopilot): add secure tenant upload onboarding` - [ ] Add a permission matrix to user documentation. - [ ] Add tenant/app registration guidance. - [ ] Decide supported auth mode for the final implementation. -- [ ] Validate whether certificate auth can be safely used from generated media. +- [ ] Document certificate app-only auth as the unattended WinPE upload path. +- [ ] Document that generated media containing encrypted certificate private key material is tenant-sensitive. +- [ ] Generalize the existing Foundry Connect AES-GCM media secret envelope for Autopilot secrets. - [ ] Explicitly document unsupported secret embedding patterns. - [ ] Add audit-safe logging rules. Automated tests: -- [ ] Secret settings are not serialized into plain deploy config unless intentionally allowed. +- [ ] Secret settings are never serialized into plain deploy config. +- [ ] Tampered encrypted certificate envelopes fail without leaking ciphertext, private key material, or certificate password data. - [ ] Logs redact tokens, secrets, private key paths, and certificate material. Manual checks: -- [ ] Review generated media contents for secrets. +- [ ] Review generated media contents and confirm certificate private key material is envelope-encrypted, not plaintext. - [ ] Review logs after failed auth and successful auth. - [ ] Confirm least-privilege app registration can import devices. @@ -647,7 +679,8 @@ Manual physical validation matrix: | --- | --- | --- | | 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 | Prefer device code or brokered upload; block silent secret embedding. | +| 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. | | 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; defer cleanup automation. | | Architecture-specific OA3Tool/support file mismatch | Runtime failure | Resolve ADK assets per selected WinPE architecture and validate both x64 and ARM64 media. | From b0c82cd1f2142c795a1da57e17b37b6044400bd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 18:59:51 +0200 Subject: [PATCH 07/25] docs(autopilot): require certificate graph auth --- .../autopilot-hardware-hash-upload.md | 47 +++++++++---------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index 5ae3cf0..838c0d0 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -160,7 +160,9 @@ JSON profile provisioning: Hardware hash upload: - Method toggle or radio selection: "Capture and upload hardware hash". - Tenant ID field. -- Authentication mode selector. +- Client ID field for the Entra application. +- Certificate private key import field. +- Certificate password field when the imported private key material requires one. - Group Tag field. - Optional assigned user UPN field. - Upload timing selector: @@ -221,9 +223,10 @@ Proposed hash settings: public sealed record AutopilotHardwareHashUploadSettings { public string TenantId { get; init; } = string.Empty; - public AutopilotHashUploadAuthenticationMode AuthenticationMode { get; init; } = AutopilotHashUploadAuthenticationMode.DeviceCode; public string ClientId { get; init; } = string.Empty; public string? CertificateThumbprint { get; init; } + public SecretEnvelope? CertificatePrivateKeySecret { get; init; } + public SecretEnvelope? CertificatePasswordSecret { get; init; } public string? GroupTag { get; init; } public string? AssignedUserPrincipalName { get; init; } public AutopilotHashUploadMode UploadMode { get; init; } = AutopilotHashUploadMode.CaptureAndUpload; @@ -234,12 +237,6 @@ public sealed record AutopilotHardwareHashUploadSettings Proposed enums: ```csharp -public enum AutopilotHashUploadAuthenticationMode -{ - DeviceCode, - Certificate -} - public enum AutopilotHashUploadMode { CaptureOnly, @@ -250,9 +247,9 @@ public enum AutopilotHashUploadMode Validation rules: - `IsEnabled=false`: no Autopilot settings are required. - `IsEnabled=true` and `JsonProfile`: selected profile must exist. -- `IsEnabled=true` and `HardwareHashUpload`: tenant ID and supported auth settings must be valid. +- `IsEnabled=true` and `HardwareHashUpload`: tenant ID, client ID, and certificate settings must be valid. - `CaptureOnly`: Graph auth settings are optional. -- `CaptureAndUpload`: Graph auth settings are required. +- `CaptureAndUpload`: certificate-based Graph auth settings are required. - `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. @@ -270,22 +267,20 @@ Recommended direction: - `Device.ReadWrite.All` - `GroupMember.ReadWrite.All` -Authentication options to evaluate during implementation: -- Device code flow for operator-driven upload. -- Certificate-based app-only auth for controlled lab or factory use. -- A brokered upload workflow outside WinPE if storing credentials in media is rejected. +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. +- 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: -- Prefer certificate-based app-only auth for unattended or near zero-touch WinPE upload. -- Keep device code flow as a manual/operator-assisted fallback if certificate material is not embedded. +- Use certificate-based app-only auth for unattended or near zero-touch WinPE upload. - Avoid client secrets for generated media. Open auth design choices: -- Whether the token is acquired by Foundry Deploy inside WinPE. -- Whether Foundry OSD pre-validates tenant/app settings before media generation. -- Whether a future broker service receives hashes from WinPE and performs Graph upload outside the media. +- Whether Foundry OSD pre-validates tenant/app/certificate settings before media generation. +- Whether Foundry Deploy validates the app certificate thumbprint before requesting a token. Secret handling rules: - Do not write access tokens to disk. @@ -435,7 +430,7 @@ PR title: `feat(autopilot): add provisioning mode configuration` - [ ] Add `AutopilotProvisioningMode`. - [ ] Extend `AutopilotSettings` with mode and hardware hash upload settings. - [ ] Extend `DeployAutopilotSettings` with reduced runtime mode and upload settings. -- [ ] Add encrypted certificate private key settings for certificate-based upload. +- [ ] Add encrypted certificate private key settings for the required certificate-based upload path. - [ ] Update schema version handling if needed. - [ ] Keep old configurations backward compatible as JSON profile mode. - [ ] Update sanitization in `ExpertDeployConfigurationStateService`. @@ -445,7 +440,8 @@ Automated tests: - [ ] Existing JSON profile config serializes and generates the same deploy output. - [ ] Enabled JSON mode requires a selected profile. - [ ] Enabled hash upload mode does not require a selected profile. -- [ ] Invalid hash upload settings make Autopilot configuration not ready. +- [ ] Capture-and-upload mode requires tenant ID, client ID, and encrypted certificate private key material. +- [ ] Invalid certificate settings make Autopilot configuration not ready. - [ ] Certificate private key material is not serialized in plaintext. Manual checks: @@ -502,7 +498,7 @@ Manual checks: - [ ] 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 private key or client secret is written to media without explicit user action. +- [ ] Confirm no plaintext private key or client secret is written to media. ### Phase 4: Foundry Deploy Runtime Branching PR title: `feat(deploy): branch autopilot runtime by provisioning mode` @@ -565,6 +561,7 @@ PR title: `feat(autopilot): import hardware hashes with Graph` - [ ] 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. - [ ] Map Graph errors to operator-readable messages. @@ -575,6 +572,7 @@ Automated tests: - [ ] Serializes import payload correctly. - [ ] Sends hardware identifier in the expected Graph format. - [ ] Decrypts certificate material in memory and does not write a decrypted PFX/private key to disk. +- [ ] Fails clearly when tenant ID, client ID, certificate thumbprint, or encrypted certificate material is missing. - [ ] Handles `complete`. - [ ] Handles `error` with device error code/name. - [ ] Times out with a clear message. @@ -591,10 +589,10 @@ PR title: `feat(autopilot): add secure tenant upload onboarding` - [ ] Add a permission matrix to user documentation. - [ ] Add tenant/app registration guidance. -- [ ] Decide supported auth mode for the final implementation. -- [ ] Document certificate app-only auth as the unattended WinPE upload path. +- [ ] Document certificate app-only auth as the only supported WinPE Graph authentication path. - [ ] Document that generated media containing encrypted certificate private key material is tenant-sensitive. - [ ] Generalize the existing Foundry Connect AES-GCM media secret envelope for Autopilot secrets. +- [ ] Document device code flow, client secrets, and brokered upload as unsupported WinPE authentication modes. - [ ] Explicitly document unsupported secret embedding patterns. - [ ] Add audit-safe logging rules. @@ -730,7 +728,6 @@ Foundry.Connect owns: ## Open Questions - Should the final implementation keep a capture-only diagnostic mode in addition to capture-and-upload? -- Which authentication mode is acceptable for generated media? - Should `PCPKsp.dll` copy failure stop the full deployment, or only stop the Autopilot upload step? - Should duplicate device cleanup ever be added, or should Foundry only surface the duplicate and stop? From 8edef8e2933eb0fad18241f802fbf2228fb19705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 19:33:11 +0200 Subject: [PATCH 08/25] docs(autopilot): refine osd and deploy ux --- .../autopilot-hardware-hash-upload.md | 117 +++++++++++++++++- 1 file changed, 111 insertions(+), 6 deletions(-) diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index 838c0d0..c5e7006 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -32,7 +32,7 @@ All PR titles must stay in English and use Conventional Commits. Each phase bran | 4 | `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. | | 5 | `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. | | 6 | `feature/autopilot-hash-upload-graph` | `feat(autopilot): import hardware hashes with Graph` | C# Graph client, import polling, retry policy, operator-facing errors. | -| 7 | `feature/autopilot-hash-upload-security` | `feat(autopilot): add secure tenant upload onboarding` | Auth mode, secret handling, redaction, permission validation, tenant readiness. | +| 7 | `feature/autopilot-hash-upload-security` | `feat(autopilot): add secure tenant upload onboarding` | Tenant sign-in, app registration creation, certificate lifecycle, secret handling, permission validation. | | 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: @@ -159,11 +159,14 @@ JSON profile provisioning: Hardware hash upload: - Method toggle or radio selection: "Capture and upload hardware hash". -- Tenant ID field. -- Client ID field for the Entra application. -- Certificate private key import field. -- Certificate password field when the imported private key material requires one. -- Group Tag field. +- Default state: tenant connection prompt. +- Connected state: + - Tenant identity summary. + - Foundry-managed app registration status. + - Single managed certificate status. + - Certificate expiration state. + - Certificate private key input for media generation. + - Autopilot group tag default selection. - Optional assigned user UPN field. - Upload timing selector: - `CaptureAndUpload` @@ -179,6 +182,54 @@ UX rules: - 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. +- After sign-in, Foundry OSD searches for the managed app registration. +- The planned app registration display name is `Foundry OSD Autopilot Registration`. +- If the app registration does not exist, Foundry OSD creates it with the required Microsoft Graph application permissions. +- If the app registration exists, Foundry OSD reuses it. +- Foundry OSD manages exactly one certificate on that app registration. +- If no certificate exists, show a create certificate action. +- If a certificate exists, show: + - display name + - thumbprint + - start date + - expiration date + - expired/valid status + - delete certificate action + - replace certificate action +- Certificate creation requires selecting a validity duration from a fixed list. +- The default certificate validity is 12 months. +- Certificate validity options should be fixed, for example: + - 3 months + - 6 months + - 12 months + - 24 months +- When Foundry OSD creates a certificate, it shows the private key once in a content dialog. +- The content dialog must clearly state that the private key is shown only once and must be stored by the operator. +- Foundry OSD must not persist the raw private key after creation. +- On later application launches, the user must sign in again before Foundry OSD can inspect or manage the tenant app registration. +- Before media generation, the Autopilot page requires the private key for the currently provisioned certificate. +- The private key input should be visually close to the certificate status so the operator understands which certificate it belongs to. +- 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. +- When connected to the tenant, Foundry OSD should list existing Autopilot group tags discovered from Intune and let the user choose the default group tag passed to Foundry Deploy. + +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 +- During the Autopilot provisioning step, after hash upload succeeds, Foundry Deploy must wait until the device appears in Intune Windows Autopilot devices before continuing. + ## Proposed Runtime Model Add an explicit provisioning mode. @@ -222,11 +273,16 @@ 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 ClientId { get; init; } = string.Empty; public string? CertificateThumbprint { get; init; } + public DateTimeOffset? CertificateNotAfter { get; init; } public SecretEnvelope? CertificatePrivateKeySecret { get; init; } public SecretEnvelope? CertificatePasswordSecret { get; init; } + public string? DefaultGroupTag { get; init; } + public IReadOnlyList KnownGroupTags { get; init; } = []; public string? GroupTag { get; init; } public string? AssignedUserPrincipalName { get; init; } public AutopilotHashUploadMode UploadMode { get; init; } = AutopilotHashUploadMode.CaptureAndUpload; @@ -250,6 +306,8 @@ Validation rules: - `IsEnabled=true` and `HardwareHashUpload`: tenant ID, client ID, and certificate settings must be valid. - `CaptureOnly`: Graph auth settings are optional. - `CaptureAndUpload`: certificate-based Graph auth settings are required. +- 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. @@ -331,6 +389,8 @@ Import state polling should handle: - `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. + Minimum Graph permission matrix: | Capability | Permission | Implementation status | @@ -348,6 +408,7 @@ Graph request rules: - 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 expired certificate authentication as a non-blocking Autopilot skip in Foundry Deploy, not as a deployment failure. ## WinPE Hash Capture Strategy Final capture path: @@ -404,9 +465,11 @@ Failure taxonomy: - `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. - `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 timeout. ## Phased Implementation @@ -431,6 +494,7 @@ PR title: `feat(autopilot): add provisioning mode configuration` - [ ] Extend `AutopilotSettings` with mode and hardware hash upload settings. - [ ] Extend `DeployAutopilotSettings` with reduced runtime mode and upload settings. - [ ] Add encrypted certificate private key settings for the required certificate-based upload path. +- [ ] Add tenant app registration identity, certificate expiration, known group tags, and default group tag settings. - [ ] Update schema version handling if needed. - [ ] Keep old configurations backward compatible as JSON profile mode. - [ ] Update sanitization in `ExpertDeployConfigurationStateService`. @@ -442,6 +506,7 @@ Automated tests: - [ ] Enabled hash upload mode does not require a selected profile. - [ ] Capture-and-upload mode requires tenant ID, client ID, and encrypted certificate private key material. - [ ] Invalid certificate settings make Autopilot configuration not ready. +- [ ] Expired certificate settings make OSD media generation not ready for hardware hash upload. - [ ] Certificate private key material is not serialized in plaintext. Manual checks: @@ -455,6 +520,13 @@ PR title: `feat(autopilot): add provisioning method selection` - [ ] Keep global Autopilot toggle. - [ ] Move existing import/download/remove/default profile/table UI into JSON profile expander. - [ ] Add hardware hash upload expander. +- [ ] Add tenant connection state, connect action, and connected tenant summary. +- [ ] Add managed app registration creation/reuse status for `Foundry OSD Autopilot Registration`. +- [ ] Add single-certificate lifecycle controls: create, delete, replace, expired state. +- [ ] Add certificate validity selection with a default of 12 months. +- [ ] Add one-time private key content dialog after certificate creation. +- [ ] Add private key input near the active certificate status for boot image generation. +- [ ] Add tenant-discovered Autopilot group tag list and default group tag selection. - [ ] Enforce mutual exclusivity between JSON profile and hash upload modes. - [ ] Add localized strings in English and French resources. - [ ] Update readiness messages to include selected mode. @@ -463,6 +535,9 @@ Automated tests: - [ ] View model mode changes save state. - [ ] Selecting JSON mode disables hash upload readiness requirements. - [ ] Selecting hash upload mode disables JSON profile selection requirements. +- [ ] Hardware hash media generation is not ready when the connected app certificate is expired. +- [ ] Hardware hash media generation requires a private key for the active certificate. +- [ ] Creating a certificate exposes the private key once and never persists the raw private key. - [ ] Busy state still blocks JSON profile import/download/remove commands. Manual checks: @@ -470,6 +545,10 @@ Manual checks: - [ ] Autopilot enabled: both expanders are visible. - [ ] Activating one method deactivates the other. - [ ] JSON profile import and tenant download still work. +- [ ] Connect to a tenant with no app registration and confirm Foundry OSD creates `Foundry OSD Autopilot Registration`. +- [ ] Connect to a tenant with an existing managed app registration and confirm Foundry OSD reuses it. +- [ ] Create a certificate, verify the private key is shown once, close the dialog, and confirm it cannot be shown again. +- [ ] Expire or simulate an expired certificate and confirm the OSD page clearly requires regenerating the certificate before boot image creation. ### Phase 3: Media Build And WinPE Assets PR title: `feat(winpe): stage autopilot hash capture assets` @@ -505,6 +584,7 @@ PR title: `feat(deploy): branch autopilot runtime by provisioning mode` - [ ] Load Autopilot provisioning mode from deploy config. - [ ] Expose mode in startup snapshot, preparation view model, launch request, deployment context, and runtime state. +- [ ] Expose hardware hash group tag selection mode in the Computer Target page. - [ ] Update `DeploymentLaunchPreparationService` validation: - JSON mode requires selected profile. - Hash upload mode requires valid upload settings. @@ -512,6 +592,7 @@ PR title: `feat(deploy): branch autopilot runtime by provisioning mode` - [ ] Add a late hash upload deployment step after OS apply and before deployment finalization. - [ ] Hash upload mode skips JSON staging and runs the hash capture/upload workflow from the late deployment step. - [ ] Update deployment summary, logs, and telemetry with mode. +- [ ] Skip Autopilot upload without blocking OS deployment when the embedded certificate is expired. Automated tests: - [ ] JSON mode still stages the profile to `Windows\Provisioning\Autopilot`. @@ -519,11 +600,15 @@ Automated tests: - [ ] Dry run creates a hash-mode manifest without touching Graph. - [ ] Hash upload step is ordered after the applied Windows root is available. - [ ] Launch preparation rejects incomplete hash upload settings. +- [ ] Expired certificate state hides hardware hash group tag controls and leaves deployment start available. 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 Computer Target shows only hardware hash controls. +- [ ] In JSON mode, confirm Computer Target shows only JSON profile controls. +- [ ] 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 5: Hash Capture Service @@ -564,6 +649,7 @@ PR title: `feat(autopilot): import hardware hashes with Graph` - [ ] 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. - [ ] Map Graph errors to operator-readable messages. - [ ] Add retry/backoff for transient HTTP failures. - [ ] Keep destructive cleanup out of the final hash upload workflow. @@ -573,7 +659,9 @@ Automated tests: - [ ] Sends hardware identifier in the expected Graph format. - [ ] Decrypts certificate material in memory and does not write a decrypted PFX/private key to disk. - [ ] Fails clearly when tenant ID, client ID, certificate thumbprint, or encrypted certificate material is missing. +- [ ] Treats expired certificate auth as skipped Autopilot, not failed deployment. - [ ] Handles `complete`. +- [ ] Handles imported identity completion followed by Windows Autopilot device visibility. - [ ] Handles `error` with device error code/name. - [ ] Times out with a clear message. - [ ] Retries transient failures only. @@ -581,6 +669,7 @@ Automated tests: 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 assignment sync behavior is documented, even if not waited on by the final implementation. - [ ] Confirm duplicate device behavior is clear to the operator. @@ -589,6 +678,8 @@ PR title: `feat(autopilot): add secure tenant upload onboarding` - [ ] Add a permission matrix to user documentation. - [ ] Add tenant/app registration guidance. +- [ ] Implement and document managed app registration discovery/creation with display name `Foundry OSD Autopilot Registration`. +- [ ] Implement and document single-certificate lifecycle management. - [ ] Document certificate app-only auth as the only supported WinPE Graph authentication path. - [ ] Document that generated media containing encrypted certificate private key material is tenant-sensitive. - [ ] Generalize the existing Foundry Connect AES-GCM media secret envelope for Autopilot secrets. @@ -679,6 +770,8 @@ Manual physical validation matrix: | 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 private key | Existing app certificate cannot be used for new media | Require creating/replacing the single managed certificate and rebuilding 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; defer cleanup automation. | | Architecture-specific OA3Tool/support file mismatch | Runtime failure | Resolve ADK assets per selected WinPE architecture and validate both x64 and ARM64 media. | @@ -688,6 +781,11 @@ Manual physical validation matrix: ## Implementation Boundaries Foundry app owns: - User-facing Autopilot mode selection. +- Tenant sign-in for Autopilot hardware hash onboarding. +- Managed app registration discovery and creation. +- Single-certificate lifecycle management. +- One-time private key presentation. +- Autopilot group tag discovery and default selection. - Expert configuration persistence. - Media readiness and media generation. - Tenant setting pre-validation where possible. @@ -701,10 +799,14 @@ Foundry.Core owns: 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 deployment workflow step after OS apply. - `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. - Deployment logs and summary artifacts. Foundry.Connect owns: @@ -714,6 +816,9 @@ Foundry.Connect owns: - Foundry app documentation: - Autopilot provisioning modes. - Hardware hash upload setup. + - Tenant app registration onboarding. + - Certificate creation, one-time private key handling, expiration, and replacement. + - Group tag default selection. - Tenant permissions. - Security warning for generated media. - Troubleshooting. From 9ac6bf0f8a33d66ebae065d2dca4ba205a9f1e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 19:37:41 +0200 Subject: [PATCH 09/25] docs(autopilot): add deploy wait timeout ux --- .../autopilot-hardware-hash-upload.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index c5e7006..a7247f5 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -228,7 +228,10 @@ Foundry Deploy UX: - 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 -- During the Autopilot provisioning step, after hash upload succeeds, Foundry Deploy must wait until the device appears in Intune Windows Autopilot devices before continuing. +- 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 continue the OS deployment, mark Autopilot visibility waiting as timed out/skipped, and retain a clear warning in the deployment summary and logs. ## Proposed Runtime Model Add an explicit provisioning mode. @@ -389,7 +392,7 @@ Import state polling should handle: - `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. +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: @@ -469,7 +472,7 @@ Failure taxonomy: - `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 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. ## Phased Implementation @@ -650,6 +653,7 @@ PR title: `feat(autopilot): import hardware hashes with Graph` - [ ] 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. - [ ] Keep destructive cleanup out of the final hash upload workflow. @@ -662,6 +666,7 @@ Automated tests: - [ ] Treats expired certificate auth as skipped Autopilot, not failed deployment. - [ ] Handles `complete`. - [ ] Handles imported identity completion followed by Windows Autopilot device visibility. +- [ ] Handles Windows Autopilot device visibility timeout as a warning/non-blocking continuation. - [ ] Handles `error` with device error code/name. - [ ] Times out with a clear message. - [ ] Retries transient failures only. @@ -670,6 +675,8 @@ 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 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. @@ -806,7 +813,7 @@ Foundry.Deploy owns: - `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. +- 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: From 4a24b890b02c90315a90bee47ed36ab7ecd17783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 20:06:53 +0200 Subject: [PATCH 10/25] docs(autopilot): clarify certificate and fallback plan --- .../autopilot-hardware-hash-upload.md | 220 ++++++++++++------ 1 file changed, 145 insertions(+), 75 deletions(-) diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index a7247f5..7c432ad 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -11,11 +11,11 @@ This feature is intended to complement the existing offline Autopilot JSON profi - Phase branches should branch from the foundation branch: - `feature/autopilot-hash-upload-config` - `feature/autopilot-hash-upload-ui` + - `feature/autopilot-hash-upload-security` - `feature/autopilot-hash-upload-media` - `feature/autopilot-hash-upload-runtime` - `feature/autopilot-hash-upload-capture` - `feature/autopilot-hash-upload-graph` - - `feature/autopilot-hash-upload-security` - `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. @@ -28,11 +28,11 @@ All PR titles must stay in English and use Conventional Commits. Each phase bran | 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-ui` | `feat(autopilot): add provisioning method selection` | Autopilot page expanders, mutually exclusive method selection, localized strings. | -| 3 | `feature/autopilot-hash-upload-media` | `feat(winpe): stage autopilot hash capture assets` | WinPE optional component requirements, x64/ARM64 OA3Tool discovery, media payload layout. | -| 4 | `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. | -| 5 | `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. | -| 6 | `feature/autopilot-hash-upload-graph` | `feat(autopilot): import hardware hashes with Graph` | C# Graph client, import polling, retry policy, operator-facing errors. | -| 7 | `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-security` | `feat(autopilot): add secure tenant upload onboarding` | Tenant sign-in, app registration creation, certificate lifecycle, secret handling, permission validation. | +| 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: @@ -163,9 +163,9 @@ Hardware hash upload: - Connected state: - Tenant identity summary. - Foundry-managed app registration status. - - Single managed certificate status. + - Active certificate status. - Certificate expiration state. - - Certificate private key input for media generation. + - Password-protected PFX input for media generation. - Autopilot group tag default selection. - Optional assigned user UPN field. - Upload timing selector: @@ -187,17 +187,22 @@ Foundry OSD tenant onboarding UX: - After sign-in, Foundry OSD searches for the managed app registration. - The planned app registration display name is `Foundry OSD Autopilot Registration`. - If the app registration does not exist, Foundry OSD creates it with the required Microsoft Graph application permissions. -- If the app registration exists, Foundry OSD reuses it. -- Foundry OSD manages exactly one certificate on that app registration. -- If no certificate exists, show a create certificate action. -- If a certificate exists, show: +- 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, active certificate key ID, active certificate thumbprint, and active certificate expiration. +- The app registration may contain multiple certificate credentials. Foundry tracks exactly one active Foundry certificate by `keyId` and leaf certificate thumbprint. +- 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 as a warning or informational state, not as a blocking error. +- If no active certificate exists, show a create certificate action. +- If an active certificate exists, show: - display name - thumbprint + - Graph `keyId` - start date - expiration date - expired/valid status - - delete certificate action - - replace certificate action + - retire active certificate action + - replace active certificate action - Certificate creation requires selecting a validity duration from a fixed list. - The default certificate validity is 12 months. - Certificate validity options should be fixed, for example: @@ -205,13 +210,26 @@ Foundry OSD tenant onboarding UX: - 6 months - 12 months - 24 months -- When Foundry OSD creates a certificate, it shows the private key once in a content dialog. -- The content dialog must clearly state that the private key is shown only once and must be stored by the operator. -- Foundry OSD must not persist the raw private key after creation. +- Certificate creation produces a password-protected PFX only. PEM keys, unprotected private keys, and client secrets are not supported. +- When Foundry OSD creates a certificate, it shows the private key material once in a content dialog. +- The content dialog must clearly state that the private key material and PFX password are shown only once and must be stored by the operator. +- Foundry OSD must not persist the raw PFX, PFX password, decrypted private key, or exported private key material after creation. - On later application launches, the user must sign in again before Foundry OSD can inspect or manage the tenant app registration. -- Before media generation, the Autopilot page requires the private key for the currently provisioned certificate. -- The private key input should be visually close to the certificate status so the operator understands which certificate it belongs to. +- Before media generation, the Autopilot page requires the password-protected PFX and its password for the currently active certificate. +- The PFX input should be visually close to the active certificate status so the operator understands which certificate it belongs to. +- Foundry OSD must validate that the supplied PFX leaf certificate thumbprint matches the active certificate thumbprint before media generation. - 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 active certificate is missing from Graph, expired, or no longer matches the persisted `keyId` and thumbprint, Foundry OSD must show a repair state before allowing hash-upload media generation. +- Replacing or retiring the active certificate must warn that previously generated boot images using the old certificate may no longer authenticate once that credential is removed from the app registration. +- If multiple Foundry-looking certificates exist but no active certificate is persisted, Foundry OSD must require the operator to choose one active certificate by thumbprint and validate a matching password-protected PFX, or replace them with a new active certificate. +- 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 +- 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, the active certificate is unexpired, and the supplied PFX matches the active certificate. - When connected to the tenant, Foundry OSD should list existing Autopilot group tags discovered from Intune and let the user choose the default group tag passed to Foundry Deploy. Foundry Deploy UX: @@ -228,10 +246,11 @@ Foundry Deploy UX: - 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. - 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 continue the OS deployment, mark Autopilot visibility waiting as timed out/skipped, and retain a clear warning in the deployment summary and logs. +- 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. ## Proposed Runtime Model Add an explicit provisioning mode. @@ -279,11 +298,12 @@ 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 SecretEnvelope? CertificatePrivateKeySecret { get; init; } - public SecretEnvelope? CertificatePasswordSecret { get; init; } public string? DefaultGroupTag { get; init; } public IReadOnlyList KnownGroupTags { get; init; } = []; public string? GroupTag { get; init; } @@ -306,7 +326,7 @@ public enum AutopilotHashUploadMode Validation rules: - `IsEnabled=false`: no Autopilot settings are required. - `IsEnabled=true` and `JsonProfile`: selected profile must exist. -- `IsEnabled=true` and `HardwareHashUpload`: tenant ID, client ID, and certificate settings must be valid. +- `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. - `CaptureOnly`: Graph auth settings are optional. - `CaptureAndUpload`: certificate-based Graph auth settings are required. - Expired certificates make Foundry OSD hardware hash media generation not ready. @@ -314,6 +334,8 @@ Validation rules: - `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. + ## Authentication Recommendation The implementation must not use PowerShell for hardware hash capture or upload actions. @@ -331,6 +353,7 @@ Recommended direction: 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 the active certificate configured 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. @@ -347,10 +370,12 @@ 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 the active certificate thumbprint. - Require explicit user confirmation before embedding certificate private key material and document that the generated media becomes tenant-sensitive. - 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, PEM private key, access token, or refresh token to disk. +- Never write a decrypted PFX, PFX password, PEM private key, access token, or refresh token to disk. Existing Foundry Connect pattern: - Foundry Core generates a random 32-byte media secret key with `RandomNumberGenerator`. @@ -411,7 +436,9 @@ Graph request rules: - 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 expired certificate authentication as a non-blocking Autopilot skip in Foundry Deploy, not as a deployment failure. +- 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. ## WinPE Hash Capture Strategy Final capture path: @@ -457,6 +484,14 @@ Proposed retained log paths after deployment: - `\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. + Failure taxonomy: - `ToolMissing`: OA3Tool is not staged or cannot execute. - `ToolFailed`: OA3Tool exits non-zero. @@ -469,6 +504,13 @@ Failure taxonomy: - `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. @@ -496,8 +538,8 @@ PR title: `feat(autopilot): add provisioning mode configuration` - [ ] Add `AutopilotProvisioningMode`. - [ ] Extend `AutopilotSettings` with mode and hardware hash upload settings. - [ ] Extend `DeployAutopilotSettings` with reduced runtime mode and upload settings. -- [ ] Add encrypted certificate private key settings for the required certificate-based upload path. -- [ ] Add tenant app registration identity, certificate expiration, known group tags, and default group tag settings. +- [ ] Add active certificate metadata: Graph `keyId`, thumbprint, expiration, and display name. +- [ ] Add tenant app registration identity, service principal identity, known group tags, and default group tag settings. - [ ] Update schema version handling if needed. - [ ] Keep old configurations backward compatible as JSON profile mode. - [ ] Update sanitization in `ExpertDeployConfigurationStateService`. @@ -507,10 +549,10 @@ Automated tests: - [ ] Existing JSON profile config serializes and generates the same deploy output. - [ ] Enabled JSON mode requires a selected profile. - [ ] Enabled hash upload mode does not require a selected profile. -- [ ] Capture-and-upload mode requires tenant ID, client ID, and encrypted certificate private key material. +- [ ] Capture-and-upload mode requires tenant ID, application object ID, client ID, active certificate `keyId`, active certificate thumbprint, and unexpired certificate metadata. - [ ] Invalid certificate settings make Autopilot configuration not ready. - [ ] Expired certificate settings make OSD media generation not ready for hardware hash upload. -- [ ] Certificate private key material is not serialized in plaintext. +- [ ] Persistent OSD settings never serialize PFX bytes, PFX password, decrypted private key material, or access tokens. Manual checks: - [ ] Start Foundry with existing user config and confirm JSON profile mode is selected. @@ -525,10 +567,10 @@ PR title: `feat(autopilot): add provisioning method selection` - [ ] Add hardware hash upload expander. - [ ] Add tenant connection state, connect action, and connected tenant summary. - [ ] Add managed app registration creation/reuse status for `Foundry OSD Autopilot Registration`. -- [ ] Add single-certificate lifecycle controls: create, delete, replace, expired state. +- [ ] Add active certificate lifecycle controls: create, retire, replace, expired state, missing state, and repair/adoption state. - [ ] Add certificate validity selection with a default of 12 months. -- [ ] Add one-time private key content dialog after certificate creation. -- [ ] Add private key input near the active certificate status for boot image generation. +- [ ] Add one-time private key/PFX content dialog after certificate creation. +- [ ] Add password-protected PFX and PFX password input near the active certificate status for boot image generation. - [ ] Add tenant-discovered Autopilot group tag list and default group tag selection. - [ ] Enforce mutual exclusivity between JSON profile and hash upload modes. - [ ] Add localized strings in English and French resources. @@ -539,8 +581,8 @@ Automated tests: - [ ] Selecting JSON mode disables hash upload readiness requirements. - [ ] Selecting hash upload mode disables JSON profile selection requirements. - [ ] Hardware hash media generation is not ready when the connected app certificate is expired. -- [ ] Hardware hash media generation requires a private key for the active certificate. -- [ ] Creating a certificate exposes the private key once and never persists the raw private key. +- [ ] Hardware hash media generation requires a password-protected PFX whose leaf certificate thumbprint matches the active certificate. +- [ ] Creating a certificate exposes the private key/PFX material once and never persists the raw PFX, password, or decrypted private key. - [ ] Busy state still blocks JSON profile import/download/remove commands. Manual checks: @@ -550,17 +592,63 @@ Manual checks: - [ ] JSON profile import and tenant download still work. - [ ] Connect to a tenant with no app registration and confirm Foundry OSD creates `Foundry OSD Autopilot Registration`. - [ ] Connect to a tenant with an existing managed app registration and confirm Foundry OSD reuses it. -- [ ] Create a certificate, verify the private key is shown once, close the dialog, and confirm it cannot be shown again. +- [ ] Connect to a tenant where an app with the same display name exists but no persisted Foundry app ID exists, and confirm Foundry OSD enters repair/adoption state. +- [ ] Create a certificate, verify the private key/PFX material and password are shown once, close the dialog, and confirm they cannot be shown again. +- [ ] Add an extra non-active certificate credential to the app and confirm Foundry OSD warns but does not delete or block on it. - [ ] Expire or simulate an expired certificate and confirm the OSD page clearly requires regenerating the certificate before boot image creation. -### Phase 3: Media Build And WinPE Assets +### Phase 3: Security And Tenant Onboarding +PR title: `feat(autopilot): add secure tenant upload onboarding` + +- [ ] Add a permission matrix to user documentation. +- [ ] Add tenant/app registration guidance. +- [ ] Implement managed app registration discovery/creation with display name `Foundry OSD Autopilot Registration`. +- [ ] Persist tenant ID, application object ID, client ID, service principal object ID, active certificate `keyId`, active certificate thumbprint, and certificate expiration. +- [ ] Implement required Graph permission checks and admin consent status checks. +- [ ] Implement service principal presence/enabled checks. +- [ ] Implement active certificate lifecycle management against Microsoft Graph `keyCredentials`. +- [ ] Merge new certificate credentials with the existing `keyCredentials` collection and never prune unknown credentials automatically. +- [ ] Implement repair/adoption state for existing display-name matches, missing active certificate credentials, and multiple Foundry-looking credentials without a persisted active certificate. +- [ ] Accept only password-protected PFX material for media generation. +- [ ] Validate the PFX leaf certificate thumbprint against the configured active certificate thumbprint. +- [ ] Document certificate app-only auth as the only supported WinPE Graph authentication path. +- [ ] Document that generated media containing encrypted certificate private key material is tenant-sensitive. +- [ ] Generalize the existing Foundry Connect AES-GCM media secret envelope for Autopilot secrets. +- [ ] Document device code flow, client secrets, and brokered upload as unsupported WinPE authentication modes. +- [ ] Explicitly document unsupported secret embedding patterns. +- [ ] Add audit-safe logging rules. + +Automated tests: +- [ ] App registration discovery uses persisted application object ID before display name. +- [ ] Same display name without persisted object ID enters repair/adoption state. +- [ ] Required permission missing maps to `PermissionMissing`. +- [ ] Admin consent missing maps to `ConsentMissing`. +- [ ] Disabled or missing service principal maps to `ServicePrincipalUnavailable`. +- [ ] Adding a certificate preserves existing non-active `keyCredentials`. +- [ ] Retiring a certificate removes only the persisted active `keyId`. +- [ ] PFX thumbprint mismatch blocks media generation. +- [ ] Secret settings are never serialized into plain deploy config. +- [ ] Tampered encrypted certificate envelopes fail without leaking ciphertext, private key material, or certificate password data. +- [ ] Logs redact tokens, secrets, private key paths, certificate data, PFX bytes, and PFX password. + +Manual checks: +- [ ] Create the managed app registration in a clean test tenant. +- [ ] Confirm the app registration name is `Foundry OSD Autopilot Registration`. +- [ ] Confirm required API permissions and admin consent status are visible in Foundry OSD. +- [ ] Add a second certificate credential outside Foundry and confirm Foundry leaves it untouched. +- [ ] Replace the active certificate and confirm the old credential is retained until the operator explicitly retires it. +- [ ] Review generated media contents and confirm certificate private key material is envelope-encrypted, not plaintext. +- [ ] Review logs after failed auth and successful auth. +- [ ] Confirm least-privilege app registration can import devices. + +### Phase 4: Media Build And WinPE Assets PR title: `feat(winpe): stage autopilot hash capture assets` - [ ] 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 certificate private key envelopes and the media secret key through the shared media secret provisioning path. +- [ ] 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. @@ -580,9 +668,9 @@ Manual checks: - [ ] 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 private key or client secret is written to media. +- [ ] Confirm no plaintext PFX, PFX password, private key, token, or client secret is written to media. -### Phase 4: Foundry Deploy Runtime Branching +### Phase 5: Foundry Deploy Runtime Branching PR title: `feat(deploy): branch autopilot runtime by provisioning mode` - [ ] Load Autopilot provisioning mode from deploy config. @@ -595,7 +683,8 @@ PR title: `feat(deploy): branch autopilot runtime by provisioning mode` - [ ] Add a late hash upload deployment step after OS apply and before deployment finalization. - [ ] Hash upload mode skips JSON staging and runs the hash capture/upload workflow from the late deployment step. - [ ] Update deployment summary, logs, and telemetry with mode. -- [ ] Skip Autopilot upload without blocking OS deployment when the embedded certificate is expired. +- [ ] Skip Autopilot upload without blocking OS deployment when certificate, tenant, token, consent, permission, Conditional Access, Intune availability, or Graph connectivity validation fails. +- [ ] Persist sanitized Autopilot diagnostics under `\Windows\Temp\Foundry\Logs\AutopilotHash`. Automated tests: - [ ] JSON mode still stages the profile to `Windows\Provisioning\Autopilot`. @@ -604,6 +693,7 @@ Automated tests: - [ ] Hash upload step is ordered after the applied Windows root is available. - [ ] Launch preparation rejects incomplete hash upload settings. - [ ] Expired certificate state hides hardware hash group tag controls and leaves deployment start available. +- [ ] Tenant/auth failures skip only Autopilot hash upload and leave OS deployment available. Manual checks: - [ ] Deploy dry-run in JSON mode. @@ -612,9 +702,10 @@ Manual checks: - [ ] In hash mode, confirm Computer Target shows only hardware hash controls. - [ ] In JSON mode, confirm Computer Target shows only JSON profile controls. - [ ] In hash mode with expired certificate, confirm Deploy shows the regeneration/recreate media message and still allows OS deployment. +- [ ] In hash mode with simulated auth failure, confirm Deploy shows an Autopilot warning and still continues OS deployment. - [ ] Confirm logs contain mode, hash capture diagnostics path, and upload state. -### Phase 5: Hash Capture Service +### Phase 6: Hash Capture Service PR title: `feat(deploy): capture autopilot hardware hash in WinPE` - [ ] Add a C# service that runs OA3Tool with controlled working directory paths. @@ -642,9 +733,10 @@ Manual checks: - [ ] 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 in logs. +- [ ] 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 6: Graph Upload Service +### Phase 7: Graph Upload Service PR title: `feat(autopilot): import hardware hashes with Graph` - [ ] Add a minimal Graph Autopilot import client. @@ -657,53 +749,31 @@ PR title: `feat(autopilot): import hardware hashes with Graph` - [ ] Map Graph errors to operator-readable messages. - [ ] Add retry/backoff for transient HTTP failures. - [ ] Keep destructive cleanup out of the final hash upload workflow. +- [ ] Sanitize `AutopilotUploadResult.json` before retaining it in `Windows\Temp\Foundry`. Automated tests: - [ ] Serializes import payload correctly. - [ ] Sends hardware identifier in the expected Graph format. -- [ ] Decrypts certificate material in memory and does not write a decrypted PFX/private key to disk. +- [ ] 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 expired certificate auth as skipped Autopilot, not failed deployment. +- [ ] Treats certificate, tenant, token, permission, consent, Conditional Access, Intune availability, and Graph connectivity failures as skipped Autopilot, not failed deployment. - [ ] Handles `complete`. - [ ] Handles imported identity completion followed by Windows Autopilot device visibility. -- [ ] Handles Windows Autopilot device visibility timeout as a warning/non-blocking continuation. +- [ ] 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 continues OS deployment and records a warning. +- [ ] 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. -### Phase 7: Security And Tenant Onboarding -PR title: `feat(autopilot): add secure tenant upload onboarding` - -- [ ] Add a permission matrix to user documentation. -- [ ] Add tenant/app registration guidance. -- [ ] Implement and document managed app registration discovery/creation with display name `Foundry OSD Autopilot Registration`. -- [ ] Implement and document single-certificate lifecycle management. -- [ ] Document certificate app-only auth as the only supported WinPE Graph authentication path. -- [ ] Document that generated media containing encrypted certificate private key material is tenant-sensitive. -- [ ] Generalize the existing Foundry Connect AES-GCM media secret envelope for Autopilot secrets. -- [ ] Document device code flow, client secrets, and brokered upload as unsupported WinPE authentication modes. -- [ ] Explicitly document unsupported secret embedding patterns. -- [ ] Add audit-safe logging rules. - -Automated tests: -- [ ] Secret settings are never serialized into plain deploy config. -- [ ] Tampered encrypted certificate envelopes fail without leaking ciphertext, private key material, or certificate password data. -- [ ] Logs redact tokens, secrets, private key paths, and certificate material. - -Manual checks: -- [ ] Review generated media contents and confirm certificate private key material is envelope-encrypted, not plaintext. -- [ ] Review logs after failed auth and successful auth. -- [ ] Confirm least-privilege app registration can import devices. - ### Phase 8: Documentation And Release Guardrails PR title: `docs(autopilot): document WinPE hardware hash upload` @@ -778,7 +848,7 @@ Manual physical validation matrix: | 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 private key | Existing app certificate cannot be used for new media | Require creating/replacing the single managed certificate and rebuilding boot media. | +| 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; defer cleanup automation. | | Architecture-specific OA3Tool/support file mismatch | Runtime failure | Resolve ADK assets per selected WinPE architecture and validate both x64 and ARM64 media. | @@ -790,8 +860,8 @@ Foundry app owns: - User-facing Autopilot mode selection. - Tenant sign-in for Autopilot hardware hash onboarding. - Managed app registration discovery and creation. -- Single-certificate lifecycle management. -- One-time private key presentation. +- 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. @@ -824,7 +894,7 @@ Foundry.Connect owns: - Autopilot provisioning modes. - Hardware hash upload setup. - Tenant app registration onboarding. - - Certificate creation, one-time private key handling, expiration, and replacement. + - Certificate creation, one-time PFX/private key material handling, expiration, repair, and replacement. - Group tag default selection. - Tenant permissions. - Security warning for generated media. From 39cba8247202149d2a56d6ab57197f555f5acdea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 20:21:30 +0200 Subject: [PATCH 11/25] docs(autopilot): define pfx export handling --- .../autopilot-hardware-hash-upload.md | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index 7c432ad..8386084 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -211,11 +211,14 @@ Foundry OSD tenant onboarding UX: - 12 months - 24 months - Certificate creation produces a password-protected PFX only. PEM keys, unprotected private keys, and client secrets are not supported. -- When Foundry OSD creates a certificate, it shows the private key material once in a content dialog. -- The content dialog must clearly state that the private key material and PFX password are shown only once and must be stored by the operator. -- Foundry OSD must not persist the raw PFX, PFX password, decrypted private key, or exported private key material after creation. +- Certificate creation requires the operator to choose a PFX output path. +- Certificate creation should let the operator generate a strong PFX password or enter a custom PFX password. +- When Foundry OSD creates a certificate, it writes the PFX to the selected output path and shows the generated password once if Foundry generated it. +- The content dialog must clearly state that the PFX and PFX password must be stored by the operator outside Foundry. +- 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. -- Before media generation, the Autopilot page requires the password-protected PFX and its password for the currently active certificate. +- After Foundry OSD restarts, before media generation, the Autopilot page requires selecting the password-protected PFX again and entering its password for the currently active certificate. - The PFX input should be visually close to the active certificate status so the operator understands which certificate it belongs to. - Foundry OSD must validate that the supplied PFX leaf certificate thumbprint matches the active certificate thumbprint before media generation. - 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. @@ -334,7 +337,7 @@ Validation rules: - `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. +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. ## Authentication Recommendation The implementation must not use PowerShell for hardware hash capture or upload actions. @@ -362,10 +365,6 @@ Recommended auth decision: - Use certificate-based app-only auth for unattended or near zero-touch WinPE upload. - Avoid client secrets for generated media. -Open auth design choices: -- Whether Foundry OSD pre-validates tenant/app/certificate settings before media generation. -- Whether Foundry Deploy validates the app certificate thumbprint before requesting a token. - 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. @@ -373,9 +372,11 @@ Secret handling rules: - 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 the active certificate thumbprint. - 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. +- 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`. @@ -610,6 +611,9 @@ PR title: `feat(autopilot): add secure tenant upload onboarding` - [ ] Merge new certificate credentials with the existing `keyCredentials` collection and never prune unknown credentials automatically. - [ ] Implement repair/adoption state for existing display-name matches, missing active certificate credentials, and multiple Foundry-looking credentials without a persisted active certificate. - [ ] Accept only password-protected PFX material for media generation. +- [ ] Require a PFX output path during certificate creation. +- [ ] Keep created PFX bytes and password in memory only for the current app session. +- [ ] Do not implement a ProgramData PFX vault or "remember this PFX" option. - [ ] Validate the PFX leaf certificate thumbprint against the configured active certificate thumbprint. - [ ] Document certificate app-only auth as the only supported WinPE Graph authentication path. - [ ] Document that generated media containing encrypted certificate private key material is tenant-sensitive. @@ -626,6 +630,8 @@ Automated tests: - [ ] Disabled or missing service principal maps to `ServicePrincipalUnavailable`. - [ ] Adding a certificate preserves existing non-active `keyCredentials`. - [ ] Retiring a certificate removes only the persisted active `keyId`. +- [ ] Created PFX material is not persisted in ProgramData, even with DPAPI. +- [ ] After app restart, media generation requires the operator to select the PFX again and enter its password. - [ ] PFX thumbprint mismatch blocks media generation. - [ ] Secret settings are never serialized into plain deploy config. - [ ] Tampered encrypted certificate envelopes fail without leaking ciphertext, private key material, or certificate password data. @@ -637,6 +643,8 @@ Manual checks: - [ ] Confirm required API permissions and admin consent status are visible in Foundry OSD. - [ ] Add a second certificate credential outside Foundry and confirm Foundry leaves it untouched. - [ ] Replace the active certificate and confirm the old credential is retained until the operator explicitly retires it. +- [ ] Create a certificate, choose a PFX output path, and confirm the PFX exists only at the selected path. +- [ ] Restart Foundry OSD and confirm it requires selecting the PFX again before media generation. - [ ] Review generated media contents and confirm certificate private key material is envelope-encrypted, not plaintext. - [ ] Review logs after failed auth and successful auth. - [ ] Confirm least-privilege app registration can import devices. From c94362b18e5a416df305fa7c00260c401d14b725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 21:19:03 +0200 Subject: [PATCH 12/25] docs(autopilot): resolve upload mode decisions --- .../autopilot-hardware-hash-upload.md | 61 +++++++++---------- 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index 8386084..1d77310 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -168,11 +168,8 @@ Hardware hash upload: - Password-protected PFX input for media generation. - Autopilot group tag default selection. - Optional assigned user UPN field. -- Upload timing selector: - - `CaptureAndUpload` - - `CaptureOnly` for diagnostics only, if retained. -- Optional "wait for import completion" setting. -- Optional "wait for assignment" setting should be deferred unless proven reliable. +- 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: @@ -254,6 +251,7 @@ Foundry Deploy UX: - 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. ## Proposed Runtime Model Add an explicit provisioning mode. @@ -311,18 +309,6 @@ public sealed record AutopilotHardwareHashUploadSettings public IReadOnlyList KnownGroupTags { get; init; } = []; public string? GroupTag { get; init; } public string? AssignedUserPrincipalName { get; init; } - public AutopilotHashUploadMode UploadMode { get; init; } = AutopilotHashUploadMode.CaptureAndUpload; - public bool WaitForImportCompletion { get; init; } = true; -} -``` - -Proposed enums: - -```csharp -public enum AutopilotHashUploadMode -{ - CaptureOnly, - CaptureAndUpload } ``` @@ -330,8 +316,7 @@ 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. -- `CaptureOnly`: Graph auth settings are optional. -- `CaptureAndUpload`: certificate-based Graph auth settings are required. +- 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. @@ -345,14 +330,23 @@ The implementation must not use PowerShell for hardware hash capture or upload a Recommended direction: - Use direct Microsoft Graph REST calls through C# service abstractions. - Invoke OA3Tool through the existing C# process execution patterns, not through PowerShell. -- Use least-privilege upload permissions: - - `DeviceManagementServiceConfig.ReadWrite.All` for import. - - `DeviceManagementServiceConfig.Read.All` only if read-only polling is separated. +- Split OSD interactive onboarding permissions from WinPE app-only upload permissions. +- 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, 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 | + +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. + 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. @@ -424,8 +418,9 @@ Minimum Graph permission matrix: | Capability | Permission | Implementation status | | --- | --- | --- | -| Import Autopilot device identity | `DeviceManagementServiceConfig.ReadWrite.All` | Required for `CaptureAndUpload`. | -| Poll imported device identity state | `DeviceManagementServiceConfig.Read.All` or `DeviceManagementServiceConfig.ReadWrite.All` | Required when waiting for completion. | +| 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. | @@ -458,7 +453,7 @@ oa3tool.exe /Report /ConfigFile=.\OA3.cfg /NoKeyCheck /LogTrace=.\OA3.log 7. Extract: - hardware hash - serial number -8. Upload through the C# Microsoft Graph import service when `CaptureAndUpload` is selected. +8. Upload through the C# Microsoft Graph import service. 9. Save diagnostics: - `OA3.xml` - `OA3.log` @@ -467,7 +462,7 @@ oa3tool.exe /Report /ConfigFile=.\OA3.cfg /NoKeyCheck /LogTrace=.\OA3.log 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 or cannot be copied, the hash upload step should fail with a clear diagnostic and keep the rest of deployment behavior explicit. +`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 or cannot be copied, the Autopilot hash upload step should be skipped with a clear diagnostic while the OS deployment continues. Proposed WinPE paths: - `X:\Foundry\Tools\OA3\oa3tool.exe` @@ -781,6 +776,7 @@ Manual checks: - [ ] 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. ### Phase 8: Documentation And Release Guardrails PR title: `docs(autopilot): document WinPE hardware hash upload` @@ -844,8 +840,7 @@ Manual physical validation matrix: | 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 | -| Capture-only diagnostic mode | Yes if included. | -| Capture-and-upload mode | Yes | +| Hardware hash upload mode | Yes | | Self-deploying/pre-provisioning | No, document as not recommended until separately validated. | ## Risk Register @@ -860,7 +855,7 @@ Manual physical validation matrix: | 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; defer cleanup automation. | | 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 | Hash capture fails late in deployment | Copy from `\Windows\System32` after OS apply, fail clearly, and retain diagnostics. | +| `PCPKsp.dll` missing from applied OS or copy fails | Autopilot upload cannot proceed reliably | Copy from `\Windows\System32` after OS apply, skip only the Autopilot hash upload step, continue OS deployment, and retain diagnostics. | | UI conflates JSON and hash mode | Invalid media or deployment launch | Explicit `ProvisioningMode` and readiness rules. | ## Implementation Boundaries @@ -916,10 +911,10 @@ Foundry.Connect owns: - Mark as x64 and ARM64 with Ethernet and Wi-Fi upload guidance. - Mention unsupported or risky self-deploying/pre-provisioning status. -## Open Questions -- Should the final implementation keep a capture-only diagnostic mode in addition to capture-and-upload? -- Should `PCPKsp.dll` copy failure stop the full deployment, or only stop the Autopilot upload step? -- Should duplicate device cleanup ever be added, or should Foundry only surface the duplicate and stop? +## 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 skips only the Autopilot hash upload step and does not block OS deployment. +- 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) From c31f228294400e0b5ac8bcf3404b11f32438973e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 21:22:27 +0200 Subject: [PATCH 13/25] docs(autopilot): clarify permissions and duplicate handling --- .../implementation/autopilot-hardware-hash-upload.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index 1d77310..22caa15 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -342,11 +342,17 @@ 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, verify admin consent, add/retire app certificate credentials, read Autopilot group tags. | No | +| 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 | 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.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. +- 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. @@ -426,6 +432,8 @@ Minimum Graph permission matrix: | 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. @@ -512,6 +520,8 @@ Failure taxonomy: - `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 stop only the Autopilot hash upload step. They must be represented as warnings/skips in deployment state and must not fail the full OS deployment. + ## Phased Implementation ### Phase 0: Foundation Branch And Research From fac00184df40bf2bbf69a6d19e34a284eef20289 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 21:23:44 +0200 Subject: [PATCH 14/25] docs(autopilot): make pcpksp required for hash upload --- docs/implementation/autopilot-hardware-hash-upload.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index 22caa15..63949b0 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -470,7 +470,7 @@ oa3tool.exe /Report /ConfigFile=.\OA3.cfg /NoKeyCheck /LogTrace=.\OA3.log 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 or cannot be copied, the Autopilot hash upload step should be skipped with a clear diagnostic while the OS deployment continues. +`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` @@ -520,7 +520,7 @@ Failure taxonomy: - `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 stop only the Autopilot hash upload step. They must be represented as warnings/skips in deployment state and must not fail the full 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. ## Phased Implementation @@ -865,7 +865,7 @@ Manual physical validation matrix: | 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; defer cleanup automation. | | 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 upload cannot proceed reliably | Copy from `\Windows\System32` after OS apply, skip only the Autopilot hash upload step, continue OS deployment, and retain diagnostics. | +| `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. | | UI conflates JSON and hash mode | Invalid media or deployment launch | Explicit `ProvisioningMode` and readiness rules. | ## Implementation Boundaries @@ -923,7 +923,7 @@ Foundry.Connect owns: ## 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 skips only the Autopilot hash upload step and does not block OS deployment. +- `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 From 77a57646d6c2207cb7c191073c4cdaa1bd66105b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 21:26:38 +0200 Subject: [PATCH 15/25] docs(autopilot): place provisioning step late --- .../autopilot-hardware-hash-upload.md | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index 63949b0..c630d91 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -119,6 +119,8 @@ 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 @@ -132,7 +134,7 @@ Foundry Deploy deployment run 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 but is the correct execution boundary for Autopilot mode branching. +- `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. @@ -253,6 +255,13 @@ Foundry Deploy UX: - 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. @@ -692,9 +701,10 @@ PR title: `feat(deploy): branch autopilot runtime by provisioning mode` - [ ] Update `DeploymentLaunchPreparationService` validation: - JSON mode requires selected profile. - Hash upload mode requires valid upload settings. -- [ ] Keep `StageAutopilotConfigurationStep` profile-only so JSON mode copies `AutopilotConfigurationFile.json`. -- [ ] Add a late hash upload deployment step after OS apply and before deployment finalization. -- [ ] Hash upload mode skips JSON staging and runs the hash capture/upload workflow from the late deployment step. +- [ ] Rename or replace `StageAutopilotConfigurationStep` with a mode-aware `ProvisionAutopilotStep`. +- [ ] 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. - [ ] Skip Autopilot upload without blocking OS deployment when certificate, tenant, token, consent, permission, Conditional Access, Intune availability, or Graph connectivity validation fails. - [ ] Persist sanitized Autopilot diagnostics under `\Windows\Temp\Foundry\Logs\AutopilotHash`. @@ -703,7 +713,8 @@ 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. -- [ ] Hash upload step is ordered after the applied Windows root is available. +- [ ] 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. - [ ] Tenant/auth failures skip only Autopilot hash upload and leave OS deployment available. @@ -892,7 +903,7 @@ Foundry.Deploy owns: - Hardware hash Computer Target mode display. - Certificate expiration detection and non-blocking Autopilot skip. - Runtime group tag selection or custom group tag input. -- Late deployment workflow step after OS apply. +- 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. From 79d49e21662ed2f8fca6314b02bc80945c11d76f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 21:30:45 +0200 Subject: [PATCH 16/25] docs(autopilot): split hash upload plan --- .../autopilot-hardware-hash-upload.md | 974 +----------------- .../autopilot-hash-upload/00-overview.md | 75 ++ .../01-feasibility-current-state.md | 96 ++ .../02-ux-runtime-model.md | 203 ++++ .../03-security-graph.md | 128 +++ .../04-winpe-deploy-workflow.md | 120 +++ .../05-implementation-phases.md | 298 ++++++ .../06-validation-risk-docs.md | 91 ++ 8 files changed, 1042 insertions(+), 943 deletions(-) create mode 100644 docs/implementation/autopilot-hash-upload/00-overview.md create mode 100644 docs/implementation/autopilot-hash-upload/01-feasibility-current-state.md create mode 100644 docs/implementation/autopilot-hash-upload/02-ux-runtime-model.md create mode 100644 docs/implementation/autopilot-hash-upload/03-security-graph.md create mode 100644 docs/implementation/autopilot-hash-upload/04-winpe-deploy-workflow.md create mode 100644 docs/implementation/autopilot-hash-upload/05-implementation-phases.md create mode 100644 docs/implementation/autopilot-hash-upload/06-validation-risk-docs.md diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index c630d91..9577cc1 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -1,946 +1,34 @@ # Autopilot Hardware Hash Upload Implementation Plan -## 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-ui` - - `feature/autopilot-hash-upload-security` - - `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. - -## 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-ui` | `feat(autopilot): add provisioning method selection` | Autopilot page expanders, mutually exclusive method selection, localized strings. | -| 3 | `feature/autopilot-hash-upload-security` | `feat(autopilot): add secure tenant upload onboarding` | Tenant sign-in, app registration creation, certificate lifecycle, secret handling, permission validation. | -| 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. - -## 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 - -> ExpertDeployConfigurationStateService - -> 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. - -## 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 identity summary. - - Foundry-managed app registration status. - - Active certificate status. - - Certificate expiration state. - - Password-protected PFX input for media generation. - - Autopilot group tag default 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. -- After sign-in, Foundry OSD searches for the managed app registration. -- The planned app registration display name is `Foundry OSD Autopilot Registration`. -- 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, active certificate key ID, active certificate thumbprint, and active certificate expiration. -- The app registration may contain multiple certificate credentials. Foundry tracks exactly one active Foundry certificate by `keyId` and leaf certificate thumbprint. -- 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 as a warning or informational state, not as a blocking error. -- If no active certificate exists, show a create certificate action. -- If an active certificate exists, show: - - display name - - thumbprint - - Graph `keyId` - - start date - - expiration date - - expired/valid status - - retire active certificate action - - replace active certificate action -- Certificate creation requires selecting a validity duration from a fixed list. -- The default certificate validity is 12 months. -- Certificate validity options should be fixed, for example: - - 3 months - - 6 months - - 12 months - - 24 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 should let the operator generate a strong PFX password or enter a custom PFX password. -- When Foundry OSD creates a certificate, it writes the PFX to the selected output path and shows the generated password once if Foundry generated it. -- The content dialog must clearly state that the PFX and PFX password must be stored by the operator outside Foundry. -- 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 for the currently active certificate. -- The PFX input should be visually close to the active certificate status so the operator understands which certificate it belongs to. -- Foundry OSD must validate that the supplied PFX leaf certificate thumbprint matches the active certificate thumbprint before media generation. -- 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 active certificate is missing from Graph, expired, or no longer matches the persisted `keyId` and thumbprint, Foundry OSD must show a repair state before allowing hash-upload media generation. -- Replacing or retiring the active certificate must warn that previously generated boot images using the old certificate may no longer authenticate once that credential is removed from the app registration. -- If multiple Foundry-looking certificates exist but no active certificate is persisted, Foundry OSD must require the operator to choose one active certificate by thumbprint and validate a matching password-protected PFX, or replace them with a new active certificate. -- 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 -- 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, the active certificate is unexpired, and the supplied PFX matches the active certificate. -- When connected to the tenant, Foundry OSD should list existing Autopilot group tags discovered from Intune and let the user choose the default group tag passed to Foundry Deploy. - -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. -- 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. - -## 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 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 | +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. +- 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 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 | | --- | --- | --- | --- | -| 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 | - -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.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. -- 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 the active certificate configured 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 the active certificate thumbprint. -- 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. -- 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. - -## 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. - -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. - -## Phased Implementation - -### Phase 0: Foundation Branch And Research -PR title: `docs(autopilot): plan hardware hash upload from WinPE` - -- [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] Run baseline tests. - -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` - -- [ ] Add `AutopilotProvisioningMode`. -- [ ] Extend `AutopilotSettings` with mode and hardware hash upload settings. -- [ ] Extend `DeployAutopilotSettings` with reduced runtime mode and upload settings. -- [ ] Add active certificate metadata: Graph `keyId`, thumbprint, expiration, and display name. -- [ ] Add tenant app registration identity, service principal identity, known group tags, and default group tag settings. -- [ ] Update schema version handling if needed. -- [ ] Keep old configurations backward compatible as JSON profile mode. -- [ ] Update sanitization in `ExpertDeployConfigurationStateService`. -- [ ] Update `DeployConfigurationGenerator`. - -Automated tests: -- [ ] Existing JSON profile config serializes and generates the same deploy output. -- [ ] Enabled JSON mode requires a selected profile. -- [ ] Enabled hash upload mode does not require a selected profile. -- [ ] Capture-and-upload mode requires tenant ID, application object ID, client ID, active certificate `keyId`, active certificate thumbprint, and unexpired certificate metadata. -- [ ] Invalid certificate settings make Autopilot configuration not ready. -- [ ] Expired certificate settings make OSD media generation not ready for hardware hash upload. -- [ ] Persistent OSD settings never serialize PFX bytes, PFX password, decrypted private key material, or access tokens. - -Manual checks: -- [ ] Start Foundry with existing user config and confirm JSON profile mode is selected. -- [ ] Disable Autopilot and confirm no profile or hash settings are required. - -### Phase 2: Autopilot Page UX -PR title: `feat(autopilot): add provisioning method selection` - -- [ ] Replace single Autopilot action section with two settings expanders. -- [ ] Keep global Autopilot toggle. -- [ ] Move existing import/download/remove/default profile/table UI into JSON profile expander. -- [ ] Add hardware hash upload expander. -- [ ] Add tenant connection state, connect action, and connected tenant summary. -- [ ] Add managed app registration creation/reuse status for `Foundry OSD Autopilot Registration`. -- [ ] Add active certificate lifecycle controls: create, retire, replace, expired state, missing state, and repair/adoption state. -- [ ] Add certificate validity selection with a default of 12 months. -- [ ] Add one-time private key/PFX content dialog after certificate creation. -- [ ] Add password-protected PFX and PFX password input near the active certificate status for boot image generation. -- [ ] Add tenant-discovered Autopilot group tag list and default group tag selection. -- [ ] Enforce mutual exclusivity between JSON profile and hash upload modes. -- [ ] Add localized strings in English and French resources. -- [ ] Update readiness messages to include selected mode. - -Automated tests: -- [ ] View model mode changes save state. -- [ ] Selecting JSON mode disables hash upload readiness requirements. -- [ ] Selecting hash upload mode disables JSON profile selection requirements. -- [ ] Hardware hash media generation is not ready when the connected app certificate is expired. -- [ ] Hardware hash media generation requires a password-protected PFX whose leaf certificate thumbprint matches the active certificate. -- [ ] Creating a certificate exposes the private key/PFX material once and never persists the raw PFX, password, or decrypted private key. -- [ ] Busy state still blocks JSON profile import/download/remove commands. - -Manual checks: -- [ ] Autopilot disabled: both expanders are unavailable or collapsed according to final UX decision. -- [ ] Autopilot enabled: both expanders are visible. -- [ ] Activating one method deactivates the other. -- [ ] JSON profile import and tenant download still work. -- [ ] Connect to a tenant with no app registration and confirm Foundry OSD creates `Foundry OSD Autopilot Registration`. -- [ ] Connect to a tenant with an existing managed app registration and confirm Foundry OSD reuses it. -- [ ] Connect to a tenant where an app with the same display name exists but no persisted Foundry app ID exists, and confirm Foundry OSD enters repair/adoption state. -- [ ] Create a certificate, verify the private key/PFX material and password are shown once, close the dialog, and confirm they cannot be shown again. -- [ ] Add an extra non-active certificate credential to the app and confirm Foundry OSD warns but does not delete or block on it. -- [ ] Expire or simulate an expired certificate and confirm the OSD page clearly requires regenerating the certificate before boot image creation. - -### Phase 3: Security And Tenant Onboarding -PR title: `feat(autopilot): add secure tenant upload onboarding` - -- [ ] Add a permission matrix to user documentation. -- [ ] Add tenant/app registration guidance. -- [ ] Implement managed app registration discovery/creation with display name `Foundry OSD Autopilot Registration`. -- [ ] Persist tenant ID, application object ID, client ID, service principal object ID, active certificate `keyId`, active certificate thumbprint, and certificate expiration. -- [ ] Implement required Graph permission checks and admin consent status checks. -- [ ] Implement service principal presence/enabled checks. -- [ ] Implement active certificate lifecycle management against Microsoft Graph `keyCredentials`. -- [ ] Merge new certificate credentials with the existing `keyCredentials` collection and never prune unknown credentials automatically. -- [ ] Implement repair/adoption state for existing display-name matches, missing active certificate credentials, and multiple Foundry-looking credentials without a persisted active certificate. -- [ ] Accept only password-protected PFX material for media generation. -- [ ] Require a PFX output path during certificate creation. -- [ ] Keep created PFX bytes and password in memory only for the current app session. -- [ ] Do not implement a ProgramData PFX vault or "remember this PFX" option. -- [ ] Validate the PFX leaf certificate thumbprint against the configured active certificate thumbprint. -- [ ] Document certificate app-only auth as the only supported WinPE Graph authentication path. -- [ ] Document that generated media containing encrypted certificate private key material is tenant-sensitive. -- [ ] Generalize the existing Foundry Connect AES-GCM media secret envelope for Autopilot secrets. -- [ ] Document device code flow, client secrets, and brokered upload as unsupported WinPE authentication modes. -- [ ] Explicitly document unsupported secret embedding patterns. -- [ ] Add audit-safe logging rules. - -Automated tests: -- [ ] App registration discovery uses persisted application object ID before display name. -- [ ] Same display name without persisted object ID enters repair/adoption state. -- [ ] Required permission missing maps to `PermissionMissing`. -- [ ] Admin consent missing maps to `ConsentMissing`. -- [ ] Disabled or missing service principal maps to `ServicePrincipalUnavailable`. -- [ ] Adding a certificate preserves existing non-active `keyCredentials`. -- [ ] Retiring a certificate removes only the persisted active `keyId`. -- [ ] Created PFX material is not persisted in ProgramData, even with DPAPI. -- [ ] After app restart, media generation requires the operator to select the PFX again and enter its password. -- [ ] PFX thumbprint mismatch blocks media generation. -- [ ] Secret settings are never serialized into plain deploy config. -- [ ] Tampered encrypted certificate envelopes fail without leaking ciphertext, private key material, or certificate password data. -- [ ] Logs redact tokens, secrets, private key paths, certificate data, PFX bytes, and PFX password. - -Manual checks: -- [ ] Create the managed app registration in a clean test tenant. -- [ ] Confirm the app registration name is `Foundry OSD Autopilot Registration`. -- [ ] Confirm required API permissions and admin consent status are visible in Foundry OSD. -- [ ] Add a second certificate credential outside Foundry and confirm Foundry leaves it untouched. -- [ ] Replace the active certificate and confirm the old credential is retained until the operator explicitly retires it. -- [ ] Create a certificate, choose a PFX output path, and confirm the PFX exists only at the selected path. -- [ ] Restart Foundry OSD and confirm it requires selecting the PFX again before media generation. -- [ ] Review generated media contents and confirm certificate private key material is envelope-encrypted, not plaintext. -- [ ] Review logs after failed auth and successful auth. -- [ ] Confirm least-privilege app registration can import devices. - -### Phase 4: Media Build And WinPE Assets -PR title: `feat(winpe): stage autopilot hash capture assets` - -- [ ] 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. - -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` - -- [ ] Load Autopilot provisioning mode from deploy config. -- [ ] Expose mode in startup snapshot, preparation view model, launch request, deployment context, and runtime state. -- [ ] Expose hardware hash group tag selection mode in the Computer Target page. -- [ ] Update `DeploymentLaunchPreparationService` validation: - - JSON mode requires selected profile. - - Hash upload mode requires valid upload settings. -- [ ] Rename or replace `StageAutopilotConfigurationStep` with a mode-aware `ProvisionAutopilotStep`. -- [ ] 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. -- [ ] Skip Autopilot upload without blocking OS deployment when certificate, tenant, token, consent, permission, Conditional Access, Intune availability, or Graph connectivity validation fails. -- [ ] Persist sanitized Autopilot diagnostics under `\Windows\Temp\Foundry\Logs\AutopilotHash`. - -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. -- [ ] Tenant/auth failures skip only Autopilot hash upload and leave OS deployment available. - -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 Computer Target shows only hardware hash controls. -- [ ] In JSON mode, confirm Computer Target shows only JSON profile controls. -- [ ] In hash mode with expired certificate, confirm Deploy shows the regeneration/recreate media message and still allows OS deployment. -- [ ] In hash mode with simulated auth failure, confirm Deploy shows an Autopilot warning and still continues 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` - -- [ ] 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. - -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` - -- [ ] 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. -- [ ] Keep destructive cleanup out of the final hash upload workflow. -- [ ] Sanitize `AutopilotUploadResult.json` before retaining it in `Windows\Temp\Foundry`. - -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. -- [ ] 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. - -### Phase 8: Documentation And Release Guardrails -PR title: `docs(autopilot): document WinPE hardware hash upload` - -- [ ] Add user documentation for hardware hash upload from WinPE. -- [ ] 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 unsupported or risky scenarios: - - self-deploying mode - - pre-provisioning - - missing TPM visibility -- [ ] Update screenshots after UI implementation. - -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. - -## 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; defer cleanup automation. | -| 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. | -| UI conflates JSON and hash mode | Invalid media or deployment launch | Explicit `ProvisioningMode` and readiness rules. | - -## 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. - -## 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. -- Release notes: - - Mark as x64 and ARM64 with Ethernet and Wi-Fi upload guidance. - - Mention unsupported or risky self-deploying/pre-provisioning status. - -## 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` +| 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-ui` | `feat(autopilot): add provisioning method selection` | [Implementation Phases](autopilot-hash-upload/05-implementation-phases.md#phase-2-autopilot-page-ux) | +| 3 | `feature/autopilot-hash-upload-security` | `feat(autopilot): add secure tenant upload onboarding` | [Implementation Phases](autopilot-hash-upload/05-implementation-phases.md#phase-3-security-and-tenant-onboarding) | +| 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) | + +## 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. 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 0000000..0c6bbb8 --- /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). + +## 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-ui` + - `feature/autopilot-hash-upload-security` + - `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. + + +## 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-ui` | `feat(autopilot): add provisioning method selection` | Autopilot page expanders, mutually exclusive method selection, localized strings. | +| 3 | `feature/autopilot-hash-upload-security` | `feat(autopilot): add secure tenant upload onboarding` | Tenant sign-in, app registration creation, certificate lifecycle, secret handling, permission validation. | +| 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 0000000..3d5c840 --- /dev/null +++ b/docs/implementation/autopilot-hash-upload/01-feasibility-current-state.md @@ -0,0 +1,96 @@ +# 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). + +## 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 + -> ExpertDeployConfigurationStateService + -> 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 0000000..f6991e1 --- /dev/null +++ b/docs/implementation/autopilot-hash-upload/02-ux-runtime-model.md @@ -0,0 +1,203 @@ +# 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). + +## 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 identity summary. + - Foundry-managed app registration status. + - Active certificate status. + - Certificate expiration state. + - Password-protected PFX input for media generation. + - Autopilot group tag default 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. +- After sign-in, Foundry OSD searches for the managed app registration. +- The planned app registration display name is `Foundry OSD Autopilot Registration`. +- 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, active certificate key ID, active certificate thumbprint, and active certificate expiration. +- The app registration may contain multiple certificate credentials. Foundry tracks exactly one active Foundry certificate by `keyId` and leaf certificate thumbprint. +- 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 as a warning or informational state, not as a blocking error. +- If no active certificate exists, show a create certificate action. +- If an active certificate exists, show: + - display name + - thumbprint + - Graph `keyId` + - start date + - expiration date + - expired/valid status + - retire active certificate action + - replace active certificate action +- Certificate creation requires selecting a validity duration from a fixed list. +- The default certificate validity is 12 months. +- Certificate validity options should be fixed, for example: + - 3 months + - 6 months + - 12 months + - 24 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 should let the operator generate a strong PFX password or enter a custom PFX password. +- When Foundry OSD creates a certificate, it writes the PFX to the selected output path and shows the generated password once if Foundry generated it. +- The content dialog must clearly state that the PFX and PFX password must be stored by the operator outside Foundry. +- 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 for the currently active certificate. +- The PFX input should be visually close to the active certificate status so the operator understands which certificate it belongs to. +- Foundry OSD must validate that the supplied PFX leaf certificate thumbprint matches the active certificate thumbprint before media generation. +- 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 active certificate is missing from Graph, expired, or no longer matches the persisted `keyId` and thumbprint, Foundry OSD must show a repair state before allowing hash-upload media generation. +- Replacing or retiring the active certificate must warn that previously generated boot images using the old certificate may no longer authenticate once that credential is removed from the app registration. +- If multiple Foundry-looking certificates exist but no active certificate is persisted, Foundry OSD must require the operator to choose one active certificate by thumbprint and validate a matching password-protected PFX, or replace them with a new active certificate. +- 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 +- 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, the active certificate is unexpired, and the supplied PFX matches the active certificate. +- When connected to the tenant, Foundry OSD should list existing Autopilot group tags discovered from Intune and let the user choose the default group tag passed to Foundry Deploy. + +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. +- 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. + + 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 0000000..29b1dea --- /dev/null +++ b/docs/implementation/autopilot-hash-upload/03-security-graph.md @@ -0,0 +1,128 @@ +# 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). + +## 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 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 | + +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.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. +- 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 the active certificate configured 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 the active certificate thumbprint. +- 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. +- 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 0000000..20e8a86 --- /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). + +## 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. + +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. + + +## 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 0000000..d225e8b --- /dev/null +++ b/docs/implementation/autopilot-hash-upload/05-implementation-phases.md @@ -0,0 +1,298 @@ +# 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). + +## Phased Implementation + +### Phase 0: Foundation Branch And Research +PR title: `docs(autopilot): plan hardware hash upload from WinPE` + +- [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] Run baseline tests. + +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` + +- [ ] Add `AutopilotProvisioningMode`. +- [ ] Extend `AutopilotSettings` with mode and hardware hash upload settings. +- [ ] Extend `DeployAutopilotSettings` with reduced runtime mode and upload settings. +- [ ] Add active certificate metadata: Graph `keyId`, thumbprint, expiration, and display name. +- [ ] Add tenant app registration identity, service principal identity, known group tags, and default group tag settings. +- [ ] Update schema version handling if needed. +- [ ] Keep old configurations backward compatible as JSON profile mode. +- [ ] Update sanitization in `ExpertDeployConfigurationStateService`. +- [ ] Update `DeployConfigurationGenerator`. + +Automated tests: +- [ ] Existing JSON profile config serializes and generates the same deploy output. +- [ ] Enabled JSON mode requires a selected profile. +- [ ] Enabled hash upload mode does not require a selected profile. +- [ ] Capture-and-upload mode requires tenant ID, application object ID, client ID, active certificate `keyId`, active certificate thumbprint, and unexpired certificate metadata. +- [ ] Invalid certificate settings make Autopilot configuration not ready. +- [ ] Expired certificate settings make OSD media generation not ready for hardware hash upload. +- [ ] Persistent OSD settings never serialize PFX bytes, PFX password, decrypted private key material, or access tokens. + +Manual checks: +- [ ] Start Foundry with existing user config and confirm JSON profile mode is selected. +- [ ] Disable Autopilot and confirm no profile or hash settings are required. + +### Phase 2: Autopilot Page UX +PR title: `feat(autopilot): add provisioning method selection` + +- [ ] Replace single Autopilot action section with two settings expanders. +- [ ] Keep global Autopilot toggle. +- [ ] Move existing import/download/remove/default profile/table UI into JSON profile expander. +- [ ] Add hardware hash upload expander. +- [ ] Add tenant connection state, connect action, and connected tenant summary. +- [ ] Add managed app registration creation/reuse status for `Foundry OSD Autopilot Registration`. +- [ ] Add active certificate lifecycle controls: create, retire, replace, expired state, missing state, and repair/adoption state. +- [ ] Add certificate validity selection with a default of 12 months. +- [ ] Add one-time private key/PFX content dialog after certificate creation. +- [ ] Add password-protected PFX and PFX password input near the active certificate status for boot image generation. +- [ ] Add tenant-discovered Autopilot group tag list and default group tag selection. +- [ ] Enforce mutual exclusivity between JSON profile and hash upload modes. +- [ ] Add localized strings in English and French resources. +- [ ] Update readiness messages to include selected mode. + +Automated tests: +- [ ] View model mode changes save state. +- [ ] Selecting JSON mode disables hash upload readiness requirements. +- [ ] Selecting hash upload mode disables JSON profile selection requirements. +- [ ] Hardware hash media generation is not ready when the connected app certificate is expired. +- [ ] Hardware hash media generation requires a password-protected PFX whose leaf certificate thumbprint matches the active certificate. +- [ ] Creating a certificate exposes the private key/PFX material once and never persists the raw PFX, password, or decrypted private key. +- [ ] Busy state still blocks JSON profile import/download/remove commands. + +Manual checks: +- [ ] Autopilot disabled: both expanders are unavailable or collapsed according to final UX decision. +- [ ] Autopilot enabled: both expanders are visible. +- [ ] Activating one method deactivates the other. +- [ ] JSON profile import and tenant download still work. +- [ ] Connect to a tenant with no app registration and confirm Foundry OSD creates `Foundry OSD Autopilot Registration`. +- [ ] Connect to a tenant with an existing managed app registration and confirm Foundry OSD reuses it. +- [ ] Connect to a tenant where an app with the same display name exists but no persisted Foundry app ID exists, and confirm Foundry OSD enters repair/adoption state. +- [ ] Create a certificate, verify the private key/PFX material and password are shown once, close the dialog, and confirm they cannot be shown again. +- [ ] Add an extra non-active certificate credential to the app and confirm Foundry OSD warns but does not delete or block on it. +- [ ] Expire or simulate an expired certificate and confirm the OSD page clearly requires regenerating the certificate before boot image creation. + +### Phase 3: Security And Tenant Onboarding +PR title: `feat(autopilot): add secure tenant upload onboarding` + +- [ ] Add a permission matrix to user documentation. +- [ ] Add tenant/app registration guidance. +- [ ] Implement managed app registration discovery/creation with display name `Foundry OSD Autopilot Registration`. +- [ ] Persist tenant ID, application object ID, client ID, service principal object ID, active certificate `keyId`, active certificate thumbprint, and certificate expiration. +- [ ] Implement required Graph permission checks and admin consent status checks. +- [ ] Implement service principal presence/enabled checks. +- [ ] Implement active certificate lifecycle management against Microsoft Graph `keyCredentials`. +- [ ] Merge new certificate credentials with the existing `keyCredentials` collection and never prune unknown credentials automatically. +- [ ] Implement repair/adoption state for existing display-name matches, missing active certificate credentials, and multiple Foundry-looking credentials without a persisted active certificate. +- [ ] Accept only password-protected PFX material for media generation. +- [ ] Require a PFX output path during certificate creation. +- [ ] Keep created PFX bytes and password in memory only for the current app session. +- [ ] Do not implement a ProgramData PFX vault or "remember this PFX" option. +- [ ] Validate the PFX leaf certificate thumbprint against the configured active certificate thumbprint. +- [ ] Document certificate app-only auth as the only supported WinPE Graph authentication path. +- [ ] Document that generated media containing encrypted certificate private key material is tenant-sensitive. +- [ ] Generalize the existing Foundry Connect AES-GCM media secret envelope for Autopilot secrets. +- [ ] Document device code flow, client secrets, and brokered upload as unsupported WinPE authentication modes. +- [ ] Explicitly document unsupported secret embedding patterns. +- [ ] Add audit-safe logging rules. + +Automated tests: +- [ ] App registration discovery uses persisted application object ID before display name. +- [ ] Same display name without persisted object ID enters repair/adoption state. +- [ ] Required permission missing maps to `PermissionMissing`. +- [ ] Admin consent missing maps to `ConsentMissing`. +- [ ] Disabled or missing service principal maps to `ServicePrincipalUnavailable`. +- [ ] Adding a certificate preserves existing non-active `keyCredentials`. +- [ ] Retiring a certificate removes only the persisted active `keyId`. +- [ ] Created PFX material is not persisted in ProgramData, even with DPAPI. +- [ ] After app restart, media generation requires the operator to select the PFX again and enter its password. +- [ ] PFX thumbprint mismatch blocks media generation. +- [ ] Secret settings are never serialized into plain deploy config. +- [ ] Tampered encrypted certificate envelopes fail without leaking ciphertext, private key material, or certificate password data. +- [ ] Logs redact tokens, secrets, private key paths, certificate data, PFX bytes, and PFX password. + +Manual checks: +- [ ] Create the managed app registration in a clean test tenant. +- [ ] Confirm the app registration name is `Foundry OSD Autopilot Registration`. +- [ ] Confirm required API permissions and admin consent status are visible in Foundry OSD. +- [ ] Add a second certificate credential outside Foundry and confirm Foundry leaves it untouched. +- [ ] Replace the active certificate and confirm the old credential is retained until the operator explicitly retires it. +- [ ] Create a certificate, choose a PFX output path, and confirm the PFX exists only at the selected path. +- [ ] Restart Foundry OSD and confirm it requires selecting the PFX again before media generation. +- [ ] Review generated media contents and confirm certificate private key material is envelope-encrypted, not plaintext. +- [ ] Review logs after failed auth and successful auth. +- [ ] Confirm least-privilege app registration can import devices. + +### Phase 4: Media Build And WinPE Assets +PR title: `feat(winpe): stage autopilot hash capture assets` + +- [ ] 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. + +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` + +- [ ] Load Autopilot provisioning mode from deploy config. +- [ ] Expose mode in startup snapshot, preparation view model, launch request, deployment context, and runtime state. +- [ ] Expose hardware hash group tag selection mode in the Computer Target page. +- [ ] Update `DeploymentLaunchPreparationService` validation: + - JSON mode requires selected profile. + - Hash upload mode requires valid upload settings. +- [ ] Rename or replace `StageAutopilotConfigurationStep` with a mode-aware `ProvisionAutopilotStep`. +- [ ] 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. +- [ ] Skip Autopilot upload without blocking OS deployment when certificate, tenant, token, consent, permission, Conditional Access, Intune availability, or Graph connectivity validation fails. +- [ ] Persist sanitized Autopilot diagnostics under `\Windows\Temp\Foundry\Logs\AutopilotHash`. + +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. +- [ ] Tenant/auth failures skip only Autopilot hash upload and leave OS deployment available. + +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 Computer Target shows only hardware hash controls. +- [ ] In JSON mode, confirm Computer Target shows only JSON profile controls. +- [ ] In hash mode with expired certificate, confirm Deploy shows the regeneration/recreate media message and still allows OS deployment. +- [ ] In hash mode with simulated auth failure, confirm Deploy shows an Autopilot warning and still continues 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` + +- [ ] 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. + +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` + +- [ ] 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. +- [ ] Keep destructive cleanup out of the final hash upload workflow. +- [ ] Sanitize `AutopilotUploadResult.json` before retaining it in `Windows\Temp\Foundry`. + +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. +- [ ] 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. + +### Phase 8: Documentation And Release Guardrails +PR title: `docs(autopilot): document WinPE hardware hash upload` + +- [ ] 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. +- [ ] 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 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 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 0000000..3ed940b --- /dev/null +++ b/docs/implementation/autopilot-hash-upload/06-validation-risk-docs.md @@ -0,0 +1,91 @@ +# 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). + +## 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; defer cleanup automation. | +| 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. | +| 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: + - Update pages, sidebars, navigation, screenshots, and release notes when user-facing behavior changes. + - Run the relevant Docusaurus build or preview command 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. + + From 912c93e8cea445a7358220641d2eea7d7f4a968e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 21:38:47 +0200 Subject: [PATCH 17/25] docs(autopilot): require xml documentation comments --- docs/implementation/autopilot-hardware-hash-upload.md | 1 + docs/implementation/autopilot-hash-upload/00-overview.md | 2 +- .../01-feasibility-current-state.md | 2 +- .../autopilot-hash-upload/02-ux-runtime-model.md | 2 +- .../autopilot-hash-upload/03-security-graph.md | 2 +- .../autopilot-hash-upload/04-winpe-deploy-workflow.md | 2 +- .../autopilot-hash-upload/05-implementation-phases.md | 9 ++++++++- .../autopilot-hash-upload/06-validation-risk-docs.md | 2 +- 8 files changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index 9577cc1..8290d26 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -5,6 +5,7 @@ This index is the entry point for the Autopilot hardware hash upload plan. The d ## 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 run full solution tests during planning-only updates unless explicitly requested. diff --git a/docs/implementation/autopilot-hash-upload/00-overview.md b/docs/implementation/autopilot-hash-upload/00-overview.md index 0c6bbb8..700494d 100644 --- a/docs/implementation/autopilot-hash-upload/00-overview.md +++ b/docs/implementation/autopilot-hash-upload/00-overview.md @@ -2,7 +2,7 @@ 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). +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. diff --git a/docs/implementation/autopilot-hash-upload/01-feasibility-current-state.md b/docs/implementation/autopilot-hash-upload/01-feasibility-current-state.md index 3d5c840..ffce609 100644 --- a/docs/implementation/autopilot-hash-upload/01-feasibility-current-state.md +++ b/docs/implementation/autopilot-hash-upload/01-feasibility-current-state.md @@ -2,7 +2,7 @@ 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). +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`. diff --git a/docs/implementation/autopilot-hash-upload/02-ux-runtime-model.md b/docs/implementation/autopilot-hash-upload/02-ux-runtime-model.md index f6991e1..bc12916 100644 --- a/docs/implementation/autopilot-hash-upload/02-ux-runtime-model.md +++ b/docs/implementation/autopilot-hash-upload/02-ux-runtime-model.md @@ -2,7 +2,7 @@ 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). +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: diff --git a/docs/implementation/autopilot-hash-upload/03-security-graph.md b/docs/implementation/autopilot-hash-upload/03-security-graph.md index 29b1dea..bd89b68 100644 --- a/docs/implementation/autopilot-hash-upload/03-security-graph.md +++ b/docs/implementation/autopilot-hash-upload/03-security-graph.md @@ -2,7 +2,7 @@ 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). +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. diff --git a/docs/implementation/autopilot-hash-upload/04-winpe-deploy-workflow.md b/docs/implementation/autopilot-hash-upload/04-winpe-deploy-workflow.md index 20e8a86..d0d0164 100644 --- a/docs/implementation/autopilot-hash-upload/04-winpe-deploy-workflow.md +++ b/docs/implementation/autopilot-hash-upload/04-winpe-deploy-workflow.md @@ -2,7 +2,7 @@ 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). +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: diff --git a/docs/implementation/autopilot-hash-upload/05-implementation-phases.md b/docs/implementation/autopilot-hash-upload/05-implementation-phases.md index d225e8b..495ce47 100644 --- a/docs/implementation/autopilot-hash-upload/05-implementation-phases.md +++ b/docs/implementation/autopilot-hash-upload/05-implementation-phases.md @@ -2,7 +2,7 @@ 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). +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 @@ -32,6 +32,7 @@ PR title: `feat(autopilot): add provisioning mode configuration` - [ ] Keep old configurations backward compatible as JSON profile mode. - [ ] Update sanitization in `ExpertDeployConfigurationStateService`. - [ ] Update `DeployConfigurationGenerator`. +- [ ] Add XML documentation comments to new public configuration records, enums, and service contracts when they clarify the behavior. Automated tests: - [ ] Existing JSON profile config serializes and generates the same deploy output. @@ -63,6 +64,7 @@ PR title: `feat(autopilot): add provisioning method selection` - [ ] Enforce mutual exclusivity between JSON profile and hash upload modes. - [ ] Add localized strings in English and French resources. - [ ] Update readiness messages to include selected mode. +- [ ] Add XML documentation comments to new public view-model members or UI service contracts when the behavior is not obvious. Automated tests: - [ ] View model mode changes save state. @@ -108,6 +110,7 @@ PR title: `feat(autopilot): add secure tenant upload onboarding` - [ ] Document device code flow, client secrets, and brokered upload as unsupported WinPE authentication modes. - [ ] Explicitly document unsupported secret embedding patterns. - [ ] Add audit-safe logging rules. +- [ ] Add XML documentation comments to new public tenant onboarding, certificate, and secret-protection APIs. Automated tests: - [ ] App registration discovery uses persisted application object ID before display name. @@ -147,6 +150,7 @@ PR title: `feat(winpe): stage autopilot hash capture assets` - [ ] 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. @@ -181,6 +185,7 @@ PR title: `feat(deploy): branch autopilot runtime by provisioning mode` - [ ] Update deployment summary, logs, and telemetry with mode. - [ ] Skip Autopilot upload without blocking OS deployment when certificate, tenant, token, consent, permission, Conditional Access, Intune availability, or Graph connectivity validation fails. - [ ] 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`. @@ -214,6 +219,7 @@ PR title: `feat(deploy): capture autopilot hardware hash in WinPE` - [ ] 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. @@ -247,6 +253,7 @@ PR title: `feat(autopilot): import hardware hashes with Graph` - [ ] Add retry/backoff for transient HTTP failures. - [ ] 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. diff --git a/docs/implementation/autopilot-hash-upload/06-validation-risk-docs.md b/docs/implementation/autopilot-hash-upload/06-validation-risk-docs.md index 3ed940b..22ab6f7 100644 --- a/docs/implementation/autopilot-hash-upload/06-validation-risk-docs.md +++ b/docs/implementation/autopilot-hash-upload/06-validation-risk-docs.md @@ -2,7 +2,7 @@ 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). +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: From 08a0f1e1b562f4c27b0b51687c807ef9694dddd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 21:46:21 +0200 Subject: [PATCH 18/25] docs(autopilot): tighten hash upload plan boundaries --- .../autopilot-hardware-hash-upload.md | 4 +- .../autopilot-hash-upload/00-overview.md | 6 +- .../02-ux-runtime-model.md | 1 + .../03-security-graph.md | 3 + .../04-winpe-deploy-workflow.md | 2 + .../05-implementation-phases.md | 112 +++++++++--------- .../06-validation-risk-docs.md | 7 +- 7 files changed, 75 insertions(+), 60 deletions(-) diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index 8290d26..9577e64 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -23,8 +23,8 @@ This index is the entry point for the Autopilot hardware hash upload plan. The d | --- | --- | --- | --- | | 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-ui` | `feat(autopilot): add provisioning method selection` | [Implementation Phases](autopilot-hash-upload/05-implementation-phases.md#phase-2-autopilot-page-ux) | -| 3 | `feature/autopilot-hash-upload-security` | `feat(autopilot): add secure tenant upload onboarding` | [Implementation Phases](autopilot-hash-upload/05-implementation-phases.md#phase-3-security-and-tenant-onboarding) | +| 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) | diff --git a/docs/implementation/autopilot-hash-upload/00-overview.md b/docs/implementation/autopilot-hash-upload/00-overview.md index 700494d..2b410da 100644 --- a/docs/implementation/autopilot-hash-upload/00-overview.md +++ b/docs/implementation/autopilot-hash-upload/00-overview.md @@ -15,8 +15,8 @@ This feature is intended to complement the existing offline Autopilot JSON profi - 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-ui` - `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` @@ -33,8 +33,8 @@ All PR titles must stay in English and use Conventional Commits. Each phase bran | --- | --- | --- | --- | | 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-ui` | `feat(autopilot): add provisioning method selection` | Autopilot page expanders, mutually exclusive method selection, localized strings. | -| 3 | `feature/autopilot-hash-upload-security` | `feat(autopilot): add secure tenant upload onboarding` | Tenant sign-in, app registration creation, certificate lifecycle, secret handling, permission validation. | +| 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. | diff --git a/docs/implementation/autopilot-hash-upload/02-ux-runtime-model.md b/docs/implementation/autopilot-hash-upload/02-ux-runtime-model.md index bc12916..b8d7453 100644 --- a/docs/implementation/autopilot-hash-upload/02-ux-runtime-model.md +++ b/docs/implementation/autopilot-hash-upload/02-ux-runtime-model.md @@ -115,6 +115,7 @@ Foundry Deploy UX: - 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. diff --git a/docs/implementation/autopilot-hash-upload/03-security-graph.md b/docs/implementation/autopilot-hash-upload/03-security-graph.md index bd89b68..c5b0aa4 100644 --- a/docs/implementation/autopilot-hash-upload/03-security-graph.md +++ b/docs/implementation/autopilot-hash-upload/03-security-graph.md @@ -28,8 +28,10 @@ Permission split: 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. - 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. @@ -121,6 +123,7 @@ Graph request rules: - 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 index d0d0164..bf8e45f 100644 --- a/docs/implementation/autopilot-hash-upload/04-winpe-deploy-workflow.md +++ b/docs/implementation/autopilot-hash-upload/04-winpe-deploy-workflow.md @@ -55,6 +55,7 @@ Artifact retention rules: - `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. @@ -81,6 +82,7 @@ Failure taxonomy: - `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 diff --git a/docs/implementation/autopilot-hash-upload/05-implementation-phases.md b/docs/implementation/autopilot-hash-upload/05-implementation-phases.md index 495ce47..22afeb2 100644 --- a/docs/implementation/autopilot-hash-upload/05-implementation-phases.md +++ b/docs/implementation/autopilot-hash-upload/05-implementation-phases.md @@ -14,7 +14,7 @@ PR title: `docs(autopilot): plan hardware hash upload from WinPE` - [x] Analyze supplied feasibility document. - [x] Analyze supplied WinPE upload script. - [x] Query current Microsoft Graph and MSAL documentation through Context7. -- [x] Run baseline tests. +- [x] Record the baseline test command without running full solution tests during planning. Manual checks: - [x] Confirm branch is isolated from `main`. @@ -47,51 +47,11 @@ Manual checks: - [ ] Start Foundry with existing user config and confirm JSON profile mode is selected. - [ ] Disable Autopilot and confirm no profile or hash settings are required. -### Phase 2: Autopilot Page UX -PR title: `feat(autopilot): add provisioning method selection` - -- [ ] Replace single Autopilot action section with two settings expanders. -- [ ] Keep global Autopilot toggle. -- [ ] Move existing import/download/remove/default profile/table UI into JSON profile expander. -- [ ] Add hardware hash upload expander. -- [ ] Add tenant connection state, connect action, and connected tenant summary. -- [ ] Add managed app registration creation/reuse status for `Foundry OSD Autopilot Registration`. -- [ ] Add active certificate lifecycle controls: create, retire, replace, expired state, missing state, and repair/adoption state. -- [ ] Add certificate validity selection with a default of 12 months. -- [ ] Add one-time private key/PFX content dialog after certificate creation. -- [ ] Add password-protected PFX and PFX password input near the active certificate status for boot image generation. -- [ ] Add tenant-discovered Autopilot group tag list and default group tag selection. -- [ ] Enforce mutual exclusivity between JSON profile and hash upload modes. -- [ ] Add localized strings in English and French resources. -- [ ] Update readiness messages to include selected mode. -- [ ] Add XML documentation comments to new public view-model members or UI service contracts when the behavior is not obvious. - -Automated tests: -- [ ] View model mode changes save state. -- [ ] Selecting JSON mode disables hash upload readiness requirements. -- [ ] Selecting hash upload mode disables JSON profile selection requirements. -- [ ] Hardware hash media generation is not ready when the connected app certificate is expired. -- [ ] Hardware hash media generation requires a password-protected PFX whose leaf certificate thumbprint matches the active certificate. -- [ ] Creating a certificate exposes the private key/PFX material once and never persists the raw PFX, password, or decrypted private key. -- [ ] Busy state still blocks JSON profile import/download/remove commands. - -Manual checks: -- [ ] Autopilot disabled: both expanders are unavailable or collapsed according to final UX decision. -- [ ] Autopilot enabled: both expanders are visible. -- [ ] Activating one method deactivates the other. -- [ ] JSON profile import and tenant download still work. -- [ ] Connect to a tenant with no app registration and confirm Foundry OSD creates `Foundry OSD Autopilot Registration`. -- [ ] Connect to a tenant with an existing managed app registration and confirm Foundry OSD reuses it. -- [ ] Connect to a tenant where an app with the same display name exists but no persisted Foundry app ID exists, and confirm Foundry OSD enters repair/adoption state. -- [ ] Create a certificate, verify the private key/PFX material and password are shown once, close the dialog, and confirm they cannot be shown again. -- [ ] Add an extra non-active certificate credential to the app and confirm Foundry OSD warns but does not delete or block on it. -- [ ] Expire or simulate an expired certificate and confirm the OSD page clearly requires regenerating the certificate before boot image creation. - -### Phase 3: Security And Tenant Onboarding +### Phase 2: Security And Tenant Onboarding PR title: `feat(autopilot): add secure tenant upload onboarding` -- [ ] Add a permission matrix to user documentation. -- [ ] Add tenant/app registration guidance. +- [ ] Define the permission matrix for the implementation model; user-facing documentation is handled in Phase 8. +- [ ] Define tenant/app registration guidance for the OSD onboarding UX and Phase 8 documentation. - [ ] Implement managed app registration discovery/creation with display name `Foundry OSD Autopilot Registration`. - [ ] Persist tenant ID, application object ID, client ID, service principal object ID, active certificate `keyId`, active certificate thumbprint, and certificate expiration. - [ ] Implement required Graph permission checks and admin consent status checks. @@ -104,11 +64,11 @@ PR title: `feat(autopilot): add secure tenant upload onboarding` - [ ] Keep created PFX bytes and password in memory only for the current app session. - [ ] Do not implement a ProgramData PFX vault or "remember this PFX" option. - [ ] Validate the PFX leaf certificate thumbprint against the configured active certificate thumbprint. -- [ ] Document certificate app-only auth as the only supported WinPE Graph authentication path. -- [ ] Document that generated media containing encrypted certificate private key material is tenant-sensitive. +- [ ] Define certificate app-only auth as the only supported WinPE Graph authentication path for code, XML documentation comments, and Phase 8 documentation. +- [ ] Define generated media containing encrypted certificate private key material as tenant-sensitive for code warnings, UI copy, and Phase 8 documentation. - [ ] Generalize the existing Foundry Connect AES-GCM media secret envelope for Autopilot secrets. -- [ ] Document device code flow, client secrets, and brokered upload as unsupported WinPE authentication modes. -- [ ] Explicitly document unsupported secret embedding patterns. +- [ ] Define device code flow, client secrets, and brokered upload as unsupported WinPE authentication modes. +- [ ] Define unsupported secret embedding patterns and add test coverage for them. - [ ] Add audit-safe logging rules. - [ ] Add XML documentation comments to new public tenant onboarding, certificate, and secret-protection APIs. @@ -139,6 +99,46 @@ Manual checks: - [ ] Review logs after failed auth and successful auth. - [ ] Confirm least-privilege app registration can import devices. +### Phase 3: Autopilot Page UX +PR title: `feat(autopilot): add provisioning method selection` + +- [ ] Replace single Autopilot action section with two settings expanders. +- [ ] Keep global Autopilot toggle. +- [ ] Move existing import/download/remove/default profile/table UI into JSON profile expander. +- [ ] Add hardware hash upload expander. +- [ ] Add tenant connection state, connect action, and connected tenant summary. +- [ ] Add managed app registration creation/reuse status for `Foundry OSD Autopilot Registration`. +- [ ] Add active certificate lifecycle controls: create, retire, replace, expired state, missing state, and repair/adoption state. +- [ ] Add certificate validity selection with a default of 12 months. +- [ ] Add one-time private key/PFX content dialog after certificate creation. +- [ ] Add password-protected PFX and PFX password input near the active certificate status for boot image generation. +- [ ] Add tenant-discovered Autopilot group tag list and default group tag selection. +- [ ] Enforce mutual exclusivity between JSON profile and hash upload modes. +- [ ] Add localized strings in English and French resources. +- [ ] Update readiness messages to include selected mode. +- [ ] Add XML documentation comments to new public view-model members or UI service contracts when the behavior is not obvious. + +Automated tests: +- [ ] View model mode changes save state. +- [ ] Selecting JSON mode disables hash upload readiness requirements. +- [ ] Selecting hash upload mode disables JSON profile selection requirements. +- [ ] Hardware hash media generation is not ready when the connected app certificate is expired. +- [ ] Hardware hash media generation requires a password-protected PFX whose leaf certificate thumbprint matches the active certificate. +- [ ] Creating a certificate exposes the private key/PFX material once and never persists the raw PFX, password, or decrypted private key. +- [ ] Busy state still blocks JSON profile import/download/remove commands. + +Manual checks: +- [ ] Autopilot disabled: both expanders are unavailable or collapsed according to final UX decision. +- [ ] Autopilot enabled: both expanders are visible. +- [ ] Activating one method deactivates the other. +- [ ] JSON profile import and tenant download still work. +- [ ] Connect to a tenant with no app registration and confirm Foundry OSD creates `Foundry OSD Autopilot Registration`. +- [ ] Connect to a tenant with an existing managed app registration and confirm Foundry OSD reuses it. +- [ ] Connect to a tenant where an app with the same display name exists but no persisted Foundry app ID exists, and confirm Foundry OSD enters repair/adoption state. +- [ ] Create a certificate, verify the private key/PFX material and password are shown once, close the dialog, and confirm they cannot be shown again. +- [ ] Add an extra non-active certificate credential to the app and confirm Foundry OSD warns but does not delete or block on it. +- [ ] Expire or simulate an expired certificate and confirm the OSD page clearly requires regenerating the certificate before boot image creation. + ### Phase 4: Media Build And WinPE Assets PR title: `feat(winpe): stage autopilot hash capture assets` @@ -179,11 +179,12 @@ PR title: `feat(deploy): branch autopilot runtime by provisioning mode` - 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. -- [ ] Skip Autopilot upload without blocking OS deployment when certificate, tenant, token, consent, permission, Conditional Access, Intune availability, or Graph connectivity validation fails. +- [ ] 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. @@ -195,7 +196,7 @@ Automated tests: - [ ] 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. -- [ ] Tenant/auth failures skip only Autopilot hash upload and leave OS deployment available. +- [ ] Runtime state can represent a skipped Autopilot hash upload without failing the deployment state machine. Manual checks: - [ ] Deploy dry-run in JSON mode. @@ -204,7 +205,6 @@ Manual checks: - [ ] In hash mode, confirm Computer Target shows only hardware hash controls. - [ ] In JSON mode, confirm Computer Target shows only JSON profile controls. - [ ] In hash mode with expired certificate, confirm Deploy shows the regeneration/recreate media message and still allows OS deployment. -- [ ] In hash mode with simulated auth failure, confirm Deploy shows an Autopilot warning and still continues OS deployment. - [ ] Confirm logs contain mode, hash capture diagnostics path, and upload state. ### Phase 6: Hash Capture Service @@ -251,6 +251,7 @@ PR title: `feat(autopilot): import hardware hashes with Graph` - [ ] 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. @@ -261,6 +262,7 @@ Automated tests: - [ ] 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. @@ -278,16 +280,20 @@ Manual checks: - [ ] 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` - [ ] 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. +- [ ] Locate the Docusaurus documentation source by searching for `docusaurus.config.*` or the docs package root before editing docs. +- [ ] If the Docusaurus source is not in this repository, record the external documentation repository or path required for the docs update before opening the PR. - [ ] 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 @@ -300,6 +306,6 @@ Manual checks: - [ ] 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 if Docusaurus files are changed. +- [ ] 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 index 22ab6f7..3a3e079 100644 --- a/docs/implementation/autopilot-hash-upload/06-validation-risk-docs.md +++ b/docs/implementation/autopilot-hash-upload/06-validation-risk-docs.md @@ -60,9 +60,10 @@ Manual physical validation matrix: | 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; defer cleanup automation. | +| 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. | @@ -82,8 +83,10 @@ Manual physical validation matrix: - Product boundaries update explaining the workaround status. - Manual test checklist. - Docusaurus documentation: + - Locate the docs source before editing by searching for `docusaurus.config.*` or the docs package root. + - If the docs source is external to this repository, record the required repository/path before opening the implementation PR. - Update pages, sidebars, navigation, screenshots, and release notes when user-facing behavior changes. - - Run the relevant Docusaurus build or preview command if Docusaurus sources are changed. + - 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. From 4570b48225a26ca07e36a56e89d7ccd89642d350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 21:51:06 +0200 Subject: [PATCH 19/25] docs(autopilot): record docusaurus documentation branch --- docs/implementation/autopilot-hardware-hash-upload.md | 2 +- .../autopilot-hash-upload/05-implementation-phases.md | 7 +++++-- .../autopilot-hash-upload/06-validation-risk-docs.md | 7 +++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index 9577e64..2b8f6e5 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -32,4 +32,4 @@ This index is the entry point for the Autopilot hardware hash upload plan. The d | 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) | ## 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. +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/05-implementation-phases.md b/docs/implementation/autopilot-hash-upload/05-implementation-phases.md index 22afeb2..aac2e25 100644 --- a/docs/implementation/autopilot-hash-upload/05-implementation-phases.md +++ b/docs/implementation/autopilot-hash-upload/05-implementation-phases.md @@ -287,8 +287,11 @@ PR title: `docs(autopilot): document WinPE hardware hash upload` - [ ] 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. -- [ ] Locate the Docusaurus documentation source by searching for `docusaurus.config.*` or the docs package root before editing docs. -- [ ] If the Docusaurus source is not in this repository, record the external documentation repository or path required for the docs update before opening the PR. +- [ ] 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. diff --git a/docs/implementation/autopilot-hash-upload/06-validation-risk-docs.md b/docs/implementation/autopilot-hash-upload/06-validation-risk-docs.md index 3a3e079..7d004fc 100644 --- a/docs/implementation/autopilot-hash-upload/06-validation-risk-docs.md +++ b/docs/implementation/autopilot-hash-upload/06-validation-risk-docs.md @@ -83,8 +83,11 @@ Manual physical validation matrix: - Product boundaries update explaining the workaround status. - Manual test checklist. - Docusaurus documentation: - - Locate the docs source before editing by searching for `docusaurus.config.*` or the docs package root. - - If the docs source is external to this repository, record the required repository/path before opening the implementation PR. + - 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: From 907465b117f380da7c7cfd82822972bf6d80ae75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 22:01:40 +0200 Subject: [PATCH 20/25] docs(autopilot): add phase progress tracking --- .../autopilot-hardware-hash-upload.md | 15 ++++ .../05-implementation-phases.md | 74 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index 2b8f6e5..ba50223 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -31,5 +31,20 @@ This index is the entry point for the Autopilot hardware hash upload plan. The d | 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 | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | +| 2 Security and tenant onboarding | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | +| 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/05-implementation-phases.md b/docs/implementation/autopilot-hash-upload/05-implementation-phases.md index aac2e25..49e9926 100644 --- a/docs/implementation/autopilot-hash-upload/05-implementation-phases.md +++ b/docs/implementation/autopilot-hash-upload/05-implementation-phases.md @@ -9,6 +9,14 @@ Implementation agents must follow the repository instructions in [AGENTS.md](../ ### 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. @@ -23,6 +31,14 @@ Manual checks: ### Phase 1: Configuration Model PR title: `feat(autopilot): add provisioning mode configuration` +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 `AutopilotProvisioningMode`. - [ ] Extend `AutopilotSettings` with mode and hardware hash upload settings. - [ ] Extend `DeployAutopilotSettings` with reduced runtime mode and upload settings. @@ -50,6 +66,14 @@ Manual checks: ### Phase 2: Security And Tenant Onboarding PR title: `feat(autopilot): add secure tenant upload onboarding` +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`. + - [ ] Define the permission matrix for the implementation model; user-facing documentation is handled in Phase 8. - [ ] Define tenant/app registration guidance for the OSD onboarding UX and Phase 8 documentation. - [ ] Implement managed app registration discovery/creation with display name `Foundry OSD Autopilot Registration`. @@ -102,6 +126,14 @@ Manual checks: ### Phase 3: Autopilot Page UX PR title: `feat(autopilot): add provisioning method selection` +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`. + - [ ] Replace single Autopilot action section with two settings expanders. - [ ] Keep global Autopilot toggle. - [ ] Move existing import/download/remove/default profile/table UI into JSON profile expander. @@ -142,6 +174,14 @@ Manual checks: ### 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. @@ -172,6 +212,14 @@ Manual checks: ### 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. - [ ] Expose hardware hash group tag selection mode in the Computer Target page. @@ -210,6 +258,14 @@ Manual checks: ### 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. @@ -242,6 +298,14 @@ Manual checks: ### 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. @@ -285,6 +349,16 @@ Manual checks: ### 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`. From 653cf0c8132c9cdd03847f41c454da4b14a8886c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Fri, 15 May 2026 22:03:29 +0200 Subject: [PATCH 21/25] docs(autopilot): clarify manual PR merge workflow --- docs/implementation/autopilot-hardware-hash-upload.md | 1 + docs/implementation/autopilot-hash-upload/00-overview.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index ba50223..763354f 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -7,6 +7,7 @@ This index is the entry point for the Autopilot hardware hash upload plan. The d - 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 diff --git a/docs/implementation/autopilot-hash-upload/00-overview.md b/docs/implementation/autopilot-hash-upload/00-overview.md index 2b410da..51e0c2e 100644 --- a/docs/implementation/autopilot-hash-upload/00-overview.md +++ b/docs/implementation/autopilot-hash-upload/00-overview.md @@ -24,6 +24,7 @@ This feature is intended to complement the existing offline Autopilot JSON profi - `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 From 1f5bc0aae8af1fbfb48b80d1d79e9d8d3a574d68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= <38375531+mchave3@users.noreply.github.com> Date: Fri, 15 May 2026 22:59:00 +0200 Subject: [PATCH 22/25] feat(autopilot): add provisioning mode configuration (#164) --- .../autopilot-hardware-hash-upload.md | 2 +- .../05-implementation-phases.md | 48 ++--- .../AutopilotConfigurationValidatorTests.cs | 197 ++++++++++++++++++ .../ConnectConfigurationGeneratorTests.cs | 2 + .../DeployConfigurationGeneratorTests.cs | 123 +++++++++++ .../ExpertConfigurationServiceTests.cs | 56 +++++ ...untedImageAssetProvisioningServiceTests.cs | 5 +- .../AutopilotCertificateMetadata.cs | 27 +++ .../AutopilotHardwareHashUploadSettings.cs | 32 +++ .../AutopilotProvisioningMode.cs | 20 ++ .../AutopilotProvisioningModeJsonConverter.cs | 51 +++++ .../Models/Configuration/AutopilotSettings.cs | 14 +- .../AutopilotTenantRegistrationSettings.cs | 27 +++ ...ployAutopilotHardwareHashUploadSettings.cs | 37 ++++ .../Deploy/DeployAutopilotSettings.cs | 16 +- .../FoundryDeployConfigurationDocument.cs | 4 +- .../FoundryExpertConfigurationDocument.cs | 4 +- .../AutopilotConfigurationValidator.cs | 87 ++++++++ .../DeployConfigurationGenerator.cs | 30 ++- .../Services/Media/MediaPreflightOptions.cs | 4 +- .../ExpertDeployConfigurationModelTests.cs | 63 ++++++ .../AutopilotProvisioningMode.cs | 20 ++ .../AutopilotProvisioningModeJsonConverter.cs | 51 +++++ ...ployAutopilotHardwareHashUploadSettings.cs | 37 ++++ .../Configuration/DeployAutopilotSettings.cs | 20 ++ .../FoundryDeployConfigurationDocument.cs | 4 +- .../Deployment/DeploymentRuntimeState.cs | 2 +- .../ExpertDeployConfigurationStateService.cs | 54 ++++- .../IExpertDeployConfigurationStateService.cs | 2 +- 29 files changed, 992 insertions(+), 47 deletions(-) create mode 100644 src/Foundry.Core.Tests/Configuration/AutopilotConfigurationValidatorTests.cs create mode 100644 src/Foundry.Core/Models/Configuration/AutopilotCertificateMetadata.cs create mode 100644 src/Foundry.Core/Models/Configuration/AutopilotHardwareHashUploadSettings.cs create mode 100644 src/Foundry.Core/Models/Configuration/AutopilotProvisioningMode.cs create mode 100644 src/Foundry.Core/Models/Configuration/AutopilotProvisioningModeJsonConverter.cs create mode 100644 src/Foundry.Core/Models/Configuration/AutopilotTenantRegistrationSettings.cs create mode 100644 src/Foundry.Core/Models/Configuration/Deploy/DeployAutopilotHardwareHashUploadSettings.cs create mode 100644 src/Foundry.Core/Services/Configuration/AutopilotConfigurationValidator.cs create mode 100644 src/Foundry.Deploy/Models/Configuration/AutopilotProvisioningMode.cs create mode 100644 src/Foundry.Deploy/Models/Configuration/AutopilotProvisioningModeJsonConverter.cs create mode 100644 src/Foundry.Deploy/Models/Configuration/DeployAutopilotHardwareHashUploadSettings.cs diff --git a/docs/implementation/autopilot-hardware-hash-upload.md b/docs/implementation/autopilot-hardware-hash-upload.md index 763354f..47afd66 100644 --- a/docs/implementation/autopilot-hardware-hash-upload.md +++ b/docs/implementation/autopilot-hardware-hash-upload.md @@ -38,7 +38,7 @@ Use this table as the cross-phase implementation status board. Detailed task, au | Phase | Branch created | Implementation complete | Verification complete | Manual checks complete | PR opened | Merged back | | --- | --- | --- | --- | --- | --- | --- | | 0 Foundation | [x] | [x] | [x] | [x] | [ ] | [ ] | -| 1 Configuration model | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | +| 1 Configuration model | [x] | [x] | [x] | [x] | [x] | [ ] | | 2 Security and tenant onboarding | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | | 3 Autopilot page UX | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | | 4 Media build and WinPE assets | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | diff --git a/docs/implementation/autopilot-hash-upload/05-implementation-phases.md b/docs/implementation/autopilot-hash-upload/05-implementation-phases.md index 49e9926..76c0e05 100644 --- a/docs/implementation/autopilot-hash-upload/05-implementation-phases.md +++ b/docs/implementation/autopilot-hash-upload/05-implementation-phases.md @@ -32,36 +32,36 @@ Manual checks: PR title: `feat(autopilot): add provisioning mode configuration` 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. +- [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`. -- [ ] Add `AutopilotProvisioningMode`. -- [ ] Extend `AutopilotSettings` with mode and hardware hash upload settings. -- [ ] Extend `DeployAutopilotSettings` with reduced runtime mode and upload settings. -- [ ] Add active certificate metadata: Graph `keyId`, thumbprint, expiration, and display name. -- [ ] Add tenant app registration identity, service principal identity, known group tags, and default group tag settings. -- [ ] Update schema version handling if needed. -- [ ] Keep old configurations backward compatible as JSON profile mode. -- [ ] Update sanitization in `ExpertDeployConfigurationStateService`. -- [ ] Update `DeployConfigurationGenerator`. -- [ ] Add XML documentation comments to new public configuration records, enums, and service contracts when they clarify the behavior. +- [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 `ExpertDeployConfigurationStateService`. +- [x] Update `DeployConfigurationGenerator`. +- [x] Add XML documentation comments to new public configuration records, enums, and service contracts when they clarify the behavior. Automated tests: -- [ ] Existing JSON profile config serializes and generates the same deploy output. -- [ ] Enabled JSON mode requires a selected profile. -- [ ] Enabled hash upload mode does not require a selected profile. -- [ ] Capture-and-upload mode requires tenant ID, application object ID, client ID, active certificate `keyId`, active certificate thumbprint, and unexpired certificate metadata. -- [ ] Invalid certificate settings make Autopilot configuration not ready. -- [ ] Expired certificate settings make OSD media generation not ready for hardware hash upload. -- [ ] Persistent OSD settings never serialize PFX bytes, PFX password, decrypted private key material, or access tokens. +- [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: -- [ ] Start Foundry with existing user config and confirm JSON profile mode is selected. -- [ ] Disable Autopilot and confirm no profile or hash settings are required. +- [ ] 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` diff --git a/src/Foundry.Core.Tests/Configuration/AutopilotConfigurationValidatorTests.cs b/src/Foundry.Core.Tests/Configuration/AutopilotConfigurationValidatorTests.cs new file mode 100644 index 0000000..015906c --- /dev/null +++ b/src/Foundry.Core.Tests/Configuration/AutopilotConfigurationValidatorTests.cs @@ -0,0 +1,197 @@ +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", "certificate-key-id", "ABCDEF123456")] + [InlineData("tenant-id", "", "client-id", "certificate-key-id", "ABCDEF123456")] + [InlineData("tenant-id", "application-object-id", "", "certificate-key-id", "ABCDEF123456")] + [InlineData("tenant-id", "application-object-id", "client-id", "", "ABCDEF123456")] + [InlineData("tenant-id", "application-object-id", "client-id", "certificate-key-id", "")] + public void IsReady_WhenHardwareHashModeHasMissingRequiredMetadata_ReturnsFalse( + string tenantId, + string applicationObjectId, + string clientId, + string keyId, + string thumbprint) + { + var settings = new AutopilotSettings + { + IsEnabled = true, + ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload, + HardwareHashUpload = CreateCompleteHardwareHashSettings( + EvaluationTimeUtc.AddMonths(6), + tenantId, + applicationObjectId, + clientId, + 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 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 keyId = "certificate-key-id", + string thumbprint = "ABCDEF123456") + { + return new AutopilotHardwareHashUploadSettings + { + Tenant = new AutopilotTenantRegistrationSettings + { + TenantId = tenantId, + ApplicationObjectId = applicationObjectId, + ClientId = clientId, + ServicePrincipalObjectId = "service-principal-object-id" + }, + ActiveCertificate = new AutopilotCertificateMetadata + { + KeyId = keyId, + Thumbprint = thumbprint, + DisplayName = "Foundry OSD Autopilot Registration", + ExpiresOnUtc = expiration + } + }; + } +} diff --git a/src/Foundry.Core.Tests/Configuration/ConnectConfigurationGeneratorTests.cs b/src/Foundry.Core.Tests/Configuration/ConnectConfigurationGeneratorTests.cs index 266375c..61b2c1a 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 da253a7..16b8d76 100644 --- a/src/Foundry.Core.Tests/Configuration/DeployConfigurationGeneratorTests.cs +++ b/src/Foundry.Core.Tests/Configuration/DeployConfigurationGeneratorTests.cs @@ -114,6 +114,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 FoundryExpertConfigurationDocument + { + 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 FoundryExpertConfigurationDocument + { + 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 FoundryExpertConfigurationDocument + { + 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 FoundryExpertConfigurationDocument + { + 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 FoundryExpertConfigurationDocument + { + 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 @@ -126,4 +226,27 @@ 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 + }, + KnownGroupTags = ["Sales", "Engineering"], + DefaultGroupTag = "Sales" + }; + } } diff --git a/src/Foundry.Core.Tests/Configuration/ExpertConfigurationServiceTests.cs b/src/Foundry.Core.Tests/Configuration/ExpertConfigurationServiceTests.cs index dfac7a9..a6335e8 100644 --- a/src/Foundry.Core.Tests/Configuration/ExpertConfigurationServiceTests.cs +++ b/src/Foundry.Core.Tests/Configuration/ExpertConfigurationServiceTests.cs @@ -68,4 +68,60 @@ public void Deserialize_WhenJsonIsNullLiteral_ReturnsDefaultDocument() Assert.False(document.Network.WifiProvisioned); Assert.False(document.Autopilot.IsEnabled); } + + [Fact] + public void Deserialize_WhenAutopilotProvisioningModeIsMissing_DefaultsToJsonProfile() + { + var service = new ExpertConfigurationService(); + + FoundryExpertConfigurationDocument document = service.Deserialize(""" + { + "schemaVersion": 4, + "autopilot": { + "isEnabled": true + } + } + """); + + Assert.Equal(AutopilotProvisioningMode.JsonProfile, document.Autopilot.ProvisioningMode); + } + + [Fact] + public void Serialize_WhenHardwareHashSettingsArePersisted_DoesNotWritePrivateMaterial() + { + var service = new ExpertConfigurationService(); + var document = new FoundryExpertConfigurationDocument + { + 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) + } + } + } + }; + + 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); + } } diff --git a/src/Foundry.Core.Tests/WinPe/WinPeMountedImageAssetProvisioningServiceTests.cs b/src/Foundry.Core.Tests/WinPe/WinPeMountedImageAssetProvisioningServiceTests.cs index 7a5d9e1..75f7a25 100644 --- a/src/Foundry.Core.Tests/WinPe/WinPeMountedImageAssetProvisioningServiceTests.cs +++ b/src/Foundry.Core.Tests/WinPe/WinPeMountedImageAssetProvisioningServiceTests.cs @@ -170,7 +170,10 @@ public async Task ProvisionAsync_WhenDeployConfigurationIsMissing_WritesComplete Assert.True(result.IsSuccess, result.Error?.Details); string deployConfigurationJson = await File.ReadAllTextAsync(Path.Combine(image.MountedImagePath, "Foundry", "Config", "foundry.deploy.config.json")); - Assert.Contains("\"schemaVersion\": 2", deployConfigurationJson, StringComparison.Ordinal); + Assert.Contains( + $"\"schemaVersion\": {Foundry.Core.Models.Configuration.Deploy.FoundryDeployConfigurationDocument.CurrentSchemaVersion}", + deployConfigurationJson, + StringComparison.Ordinal); Assert.Contains("\"localization\":", deployConfigurationJson, StringComparison.Ordinal); Assert.Contains("\"customization\":", deployConfigurationJson, StringComparison.Ordinal); Assert.Contains("\"autopilot\":", deployConfigurationJson, StringComparison.Ordinal); diff --git a/src/Foundry.Core/Models/Configuration/AutopilotCertificateMetadata.cs b/src/Foundry.Core/Models/Configuration/AutopilotCertificateMetadata.cs new file mode 100644 index 0000000..1cd8c58 --- /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 0000000..547918f --- /dev/null +++ b/src/Foundry.Core/Models/Configuration/AutopilotHardwareHashUploadSettings.cs @@ -0,0 +1,32 @@ +namespace Foundry.Core.Models.Configuration; + +/// +/// 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; } +} diff --git a/src/Foundry.Core/Models/Configuration/AutopilotProvisioningMode.cs b/src/Foundry.Core/Models/Configuration/AutopilotProvisioningMode.cs new file mode 100644 index 0000000..036414f --- /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 0000000..82d479b --- /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 0f878b3..06c495d 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 0000000..31f7234 --- /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 0000000..32d9be5 --- /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 e4ba98b..532540f 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 3b5ca80..1ba8442 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 = 2; + public const int CurrentSchemaVersion = 3; /// /// 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/FoundryExpertConfigurationDocument.cs b/src/Foundry.Core/Models/Configuration/FoundryExpertConfigurationDocument.cs index 20f5846..fca9ccd 100644 --- a/src/Foundry.Core/Models/Configuration/FoundryExpertConfigurationDocument.cs +++ b/src/Foundry.Core/Models/Configuration/FoundryExpertConfigurationDocument.cs @@ -10,7 +10,7 @@ public sealed record FoundryExpertConfigurationDocument /// /// Gets the current schema version for Expert Deploy configuration documents. /// - public const int CurrentSchemaVersion = 4; + public const int CurrentSchemaVersion = 5; /// /// Gets the schema version of this configuration document. @@ -38,7 +38,7 @@ public sealed record FoundryExpertConfigurationDocument 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/Configuration/AutopilotConfigurationValidator.cs b/src/Foundry.Core/Services/Configuration/AutopilotConfigurationValidator.cs new file mode 100644 index 0000000..832a857 --- /dev/null +++ b/src/Foundry.Core/Services/Configuration/AutopilotConfigurationValidator.cs @@ -0,0 +1,87 @@ +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) + { + ArgumentNullException.ThrowIfNull(settings); + + if (!settings.IsEnabled) + { + return true; + } + + return settings.ProvisioningMode switch + { + AutopilotProvisioningMode.JsonProfile => GetSelectedJsonProfile(settings) is not null, + AutopilotProvisioningMode.HardwareHashUpload => IsHardwareHashUploadReady(settings.HardwareHashUpload, currentTimeUtc), + _ => false + }; + } + + 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 && + !IsHardwareHashUploadReady(settings.HardwareHashUpload, currentTimeUtc)) + { + throw new InvalidOperationException("Autopilot hardware hash upload mode requires complete tenant and unexpired certificate metadata."); + } + + 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 bool IsHardwareHashUploadReady( + AutopilotHardwareHashUploadSettings? settings, + DateTimeOffset currentTimeUtc) + { + if (settings?.Tenant is null) + { + return false; + } + + return !string.IsNullOrWhiteSpace(settings.Tenant.TenantId) && + !string.IsNullOrWhiteSpace(settings.Tenant.ApplicationObjectId) && + !string.IsNullOrWhiteSpace(settings.Tenant.ClientId) && + !string.IsNullOrWhiteSpace(settings.ActiveCertificate?.KeyId) && + !string.IsNullOrWhiteSpace(settings.ActiveCertificate.Thumbprint) && + settings.ActiveCertificate.ExpiresOnUtc is { } expiresOnUtc && + expiresOnUtc > currentTimeUtc; + } +} diff --git a/src/Foundry.Core/Services/Configuration/DeployConfigurationGenerator.cs b/src/Foundry.Core/Services/Configuration/DeployConfigurationGenerator.cs index 1961281..bf215a0 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(FoundryExpertConfigurationDocument document) { ArgumentNullException.ThrowIfNull(document); + AutopilotConfigurationValidator.ThrowIfNotReady(document.Autopilot, DateTimeOffset.UtcNow); string[] visibleLanguageCodes = CanonicalizeLanguageCodes(document.Localization.VisibleLanguageCodes); string? defaultLanguageCodeOverride = CanonicalizeOptionalLanguageCode(document.Localization.DefaultLanguageCodeOverride); @@ -50,9 +51,13 @@ public FoundryDeployConfigurationDocument Generate(FoundryExpertConfigurationDoc 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 }; @@ -91,4 +96,23 @@ private static string[] CanonicalizeLanguageCodes(IEnumerable languageCo string canonicalCode = LanguageCodeUtility.Canonicalize(languageCode); 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 + }; + } } diff --git a/src/Foundry.Core/Services/Media/MediaPreflightOptions.cs b/src/Foundry.Core/Services/Media/MediaPreflightOptions.cs index 97ec091..63454dd 100644 --- a/src/Foundry.Core/Services/Media/MediaPreflightOptions.cs +++ b/src/Foundry.Core/Services/Media/MediaPreflightOptions.cs @@ -33,12 +33,12 @@ 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; diff --git a/src/Foundry.Deploy.Tests/ExpertDeployConfigurationModelTests.cs b/src/Foundry.Deploy.Tests/ExpertDeployConfigurationModelTests.cs index 696bb83..72899d7 100644 --- a/src/Foundry.Deploy.Tests/ExpertDeployConfigurationModelTests.cs +++ b/src/Foundry.Deploy.Tests/ExpertDeployConfigurationModelTests.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/Models/Configuration/AutopilotProvisioningMode.cs b/src/Foundry.Deploy/Models/Configuration/AutopilotProvisioningMode.cs new file mode 100644 index 0000000..421da6f --- /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 0000000..df00bfe --- /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 0000000..ab16af7 --- /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 54816f1..ae94976 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 8b6f226..4e494d5 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 = 2; + public const int CurrentSchemaVersion = 3; /// /// 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/DeploymentRuntimeState.cs b/src/Foundry.Deploy/Services/Deployment/DeploymentRuntimeState.cs index 43bba8a..5ac6e16 100644 --- a/src/Foundry.Deploy/Services/Deployment/DeploymentRuntimeState.cs +++ b/src/Foundry.Deploy/Services/Deployment/DeploymentRuntimeState.cs @@ -195,7 +195,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/Services/Configuration/ExpertDeployConfigurationStateService.cs b/src/Foundry/Services/Configuration/ExpertDeployConfigurationStateService.cs index a342a71..59d8a2d 100644 --- a/src/Foundry/Services/Configuration/ExpertDeployConfigurationStateService.cs +++ b/src/Foundry/Services/Configuration/ExpertDeployConfigurationStateService.cs @@ -73,15 +73,17 @@ public bool IsDeployConfigurationReady public bool IsAutopilotEnabled => Current.Autopilot.IsEnabled; /// - public bool IsAutopilotConfigurationReady => !Current.Autopilot.IsEnabled || GetSelectedAutopilotProfile() is not null; + public bool IsAutopilotConfigurationReady => AutopilotConfigurationValidator.IsReady(Current.Autopilot, DateTimeOffset.UtcNow); /// - public string? SelectedAutopilotProfileDisplayName => Current.Autopilot.IsEnabled + 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; @@ -213,7 +215,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) }; } @@ -237,6 +277,12 @@ private static CustomizationSettings SanitizeCustomizationForPersistence(Customi }; } + private static string? NormalizeOptional(string? value) + { + string? trimmed = value?.Trim(); + return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; + } + private NetworkMediaReadinessEvaluation EvaluateNetworkMediaReadiness() { return NetworkMediaReadinessEvaluator.Evaluate(Current.Network, networkSecretStateService.PersonalWifiPassphrase); diff --git a/src/Foundry/Services/Configuration/IExpertDeployConfigurationStateService.cs b/src/Foundry/Services/Configuration/IExpertDeployConfigurationStateService.cs index 904df4d..21e69ed 100644 --- a/src/Foundry/Services/Configuration/IExpertDeployConfigurationStateService.cs +++ b/src/Foundry/Services/Configuration/IExpertDeployConfigurationStateService.cs @@ -43,7 +43,7 @@ public interface IExpertDeployConfigurationStateService bool AreRequiredSecretsReady { get; } /// - /// Gets a value indicating whether Autopilot staging is enabled. + /// Gets a value indicating whether Autopilot provisioning is enabled. /// bool IsAutopilotEnabled { get; } From de581c68fd5dcd64e8e7cb3a31664e3937841ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= Date: Wed, 20 May 2026 17:40:09 +0200 Subject: [PATCH 23/25] docs(autopilot): update configuration references --- docs/implementation/autopilot-hash-upload/00-overview.md | 1 - .../autopilot-hash-upload/01-feasibility-current-state.md | 5 +---- .../autopilot-hash-upload/02-ux-runtime-model.md | 2 -- .../autopilot-hash-upload/03-security-graph.md | 2 -- .../autopilot-hash-upload/04-winpe-deploy-workflow.md | 2 -- .../autopilot-hash-upload/05-implementation-phases.md | 4 +--- .../autopilot-hash-upload/06-validation-risk-docs.md | 2 -- 7 files changed, 2 insertions(+), 16 deletions(-) diff --git a/docs/implementation/autopilot-hash-upload/00-overview.md b/docs/implementation/autopilot-hash-upload/00-overview.md index 51e0c2e..e038086 100644 --- a/docs/implementation/autopilot-hash-upload/00-overview.md +++ b/docs/implementation/autopilot-hash-upload/00-overview.md @@ -73,4 +73,3 @@ Expected PR description structure: - 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 index ffce609..98ef3cc 100644 --- a/docs/implementation/autopilot-hash-upload/01-feasibility-current-state.md +++ b/docs/implementation/autopilot-hash-upload/01-feasibility-current-state.md @@ -15,7 +15,6 @@ Implementation agents must follow the repository instructions in [AGENTS.md](../ - 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. @@ -55,7 +54,7 @@ Current data flow: ```text Foundry app - -> ExpertDeployConfigurationStateService + -> FoundryConfigurationStateService -> DeployConfigurationGenerator -> StartMediaViewModel -> WinPeWorkspacePreparationService @@ -92,5 +91,3 @@ Current assumptions to break carefully: - `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 index b8d7453..5532873 100644 --- a/docs/implementation/autopilot-hash-upload/02-ux-runtime-model.md +++ b/docs/implementation/autopilot-hash-upload/02-ux-runtime-model.md @@ -200,5 +200,3 @@ Validation rules: - `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. - - diff --git a/docs/implementation/autopilot-hash-upload/03-security-graph.md b/docs/implementation/autopilot-hash-upload/03-security-graph.md index c5b0aa4..4297486 100644 --- a/docs/implementation/autopilot-hash-upload/03-security-graph.md +++ b/docs/implementation/autopilot-hash-upload/03-security-graph.md @@ -127,5 +127,3 @@ Graph request rules: - 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 index bf8e45f..3cde30d 100644 --- a/docs/implementation/autopilot-hash-upload/04-winpe-deploy-workflow.md +++ b/docs/implementation/autopilot-hash-upload/04-winpe-deploy-workflow.md @@ -118,5 +118,3 @@ Foundry.Deploy owns: 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 index 76c0e05..e672560 100644 --- a/docs/implementation/autopilot-hash-upload/05-implementation-phases.md +++ b/docs/implementation/autopilot-hash-upload/05-implementation-phases.md @@ -46,7 +46,7 @@ Implementation progress: - [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 `ExpertDeployConfigurationStateService`. +- [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. @@ -384,5 +384,3 @@ Manual checks: - [ ] 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 index 7d004fc..c2faac9 100644 --- a/docs/implementation/autopilot-hash-upload/06-validation-risk-docs.md +++ b/docs/implementation/autopilot-hash-upload/06-validation-risk-docs.md @@ -93,5 +93,3 @@ Manual physical validation matrix: - Release notes: - Mark as x64 and ARM64 with Ethernet and Wi-Fi upload guidance. - Mention unsupported or risky self-deploying/pre-provisioning status. - - From 58286359465bf23899c59326e399e2b0b14979e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20CHAVE?= <38375531+mchave3@users.noreply.github.com> Date: Wed, 20 May 2026 19:02:03 +0200 Subject: [PATCH 24/25] feat(autopilot): add hardware hash upload UX (#186) --- .../05-implementation-phases.md | 35 +-- .../Services/Media/MediaPreflightOptions.cs | 6 + ...DeploymentLaunchPreparationServiceTests.cs | 58 +++++ .../DeploymentPreparationViewModelTests.cs | 72 ++++++ .../StageAutopilotConfigurationStepTests.cs | 176 +++++++++++++ src/Foundry.Deploy/MainWindow.xaml | 22 ++ .../Services/Deployment/DeploymentContext.cs | 10 + .../DeploymentLaunchPreparationService.cs | 16 +- .../Deployment/DeploymentLaunchRequest.cs | 2 + .../Steps/StageAutopilotConfigurationStep.cs | 40 +++ .../Services/Runtime/DebugAutopilotMode.cs | 22 ++ .../Strings/en-US/Resources.resx | 12 + .../Strings/fr-FR/Resources.resx | 12 + .../DeploymentPreparationViewModel.cs | 139 +++++++++- .../ViewModels/MainWindowViewModel.cs | 41 ++- .../Views/Wizard/SummaryStepView.xaml | 18 +- .../Views/Wizard/TargetStepView.xaml | 29 ++- .../FoundryConfigurationStateService.cs | 3 + .../IFoundryConfigurationStateService.cs | 5 + src/Foundry/Strings/en-US/Resources.resw | 78 ++++++ src/Foundry/Strings/fr-FR/Resources.resw | 78 ++++++ .../AutopilotConfigurationViewModel.cs | 212 +++++++++++++++- src/Foundry/ViewModels/StartMediaViewModel.cs | 6 + src/Foundry/Views/AutopilotPage.xaml | 237 +++++++++++------- 24 files changed, 1217 insertions(+), 112 deletions(-) create mode 100644 src/Foundry.Deploy.Tests/StageAutopilotConfigurationStepTests.cs create mode 100644 src/Foundry.Deploy/Services/Runtime/DebugAutopilotMode.cs diff --git a/docs/implementation/autopilot-hash-upload/05-implementation-phases.md b/docs/implementation/autopilot-hash-upload/05-implementation-phases.md index e672560..a5a77d4 100644 --- a/docs/implementation/autopilot-hash-upload/05-implementation-phases.md +++ b/docs/implementation/autopilot-hash-upload/05-implementation-phases.md @@ -124,36 +124,41 @@ Manual checks: - [ ] Confirm least-privilege app registration can import devices. ### Phase 3: Autopilot Page UX -PR title: `feat(autopilot): add provisioning method selection` +PR title: `feat(autopilot): add hardware hash upload UX` Implementation progress: -- [ ] Phase branch created from `feature/autopilot-hash-upload-foundation`. +- [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`. -- [ ] Replace single Autopilot action section with two settings expanders. -- [ ] Keep global Autopilot toggle. -- [ ] Move existing import/download/remove/default profile/table UI into JSON profile expander. -- [ ] Add hardware hash upload expander. -- [ ] Add tenant connection state, connect action, and connected tenant summary. -- [ ] Add managed app registration creation/reuse status for `Foundry OSD Autopilot Registration`. +- [x] Replace single Autopilot action section with two settings expanders. +- [x] Keep global Autopilot toggle. +- [x] Move existing import/download/remove/default profile/table UI into JSON profile expander. +- [x] Add hardware hash upload expander. +- [x] Add tenant connection state, connect action, and connected tenant summary. +- [x] Add managed app registration creation/reuse status for `Foundry OSD Autopilot Registration`. - [ ] Add active certificate lifecycle controls: create, retire, replace, expired state, missing state, and repair/adoption state. - [ ] Add certificate validity selection with a default of 12 months. - [ ] Add one-time private key/PFX content dialog after certificate creation. - [ ] Add password-protected PFX and PFX password input near the active certificate status for boot image generation. -- [ ] Add tenant-discovered Autopilot group tag list and default group tag selection. -- [ ] Enforce mutual exclusivity between JSON profile and hash upload modes. -- [ ] Add localized strings in English and French resources. -- [ ] Update readiness messages to include selected mode. +- [x] Add tenant-discovered Autopilot group tag list and default group tag selection. +- [x] Enforce mutual exclusivity between JSON profile and hash upload modes. +- [x] Carry the selected mode into the current Foundry Deploy target page so hardware hash mode does not require a JSON profile. +- [x] Block live hardware hash deployments until the deployment runtime phase exists, instead of silently skipping Autopilot. +- [x] Add localized strings in English and French resources. +- [x] Update readiness messages to include selected mode. - [ ] Add XML documentation comments to new public view-model members or UI service contracts when the behavior is not obvious. Automated tests: -- [ ] View model mode changes save state. -- [ ] Selecting JSON mode disables hash upload readiness requirements. -- [ ] Selecting hash upload mode disables JSON profile selection requirements. +- [x] View model mode changes save state. +- [x] Selecting JSON mode preserves hash upload metadata. +- [x] Selecting hash upload mode preserves hash upload metadata and does not require a selected JSON profile in the OSD UI. +- [x] Deploy launch preparation accepts hardware hash mode without a selected JSON profile. +- [x] Current Deploy Autopilot staging step skips JSON profile staging in hardware hash mode. +- [x] Live hardware hash mode fails before deployment confirmation until the runtime implementation exists. - [ ] Hardware hash media generation is not ready when the connected app certificate is expired. - [ ] Hardware hash media generation requires a password-protected PFX whose leaf certificate thumbprint matches the active certificate. - [ ] Creating a certificate exposes the private key/PFX material once and never persists the raw PFX, password, or decrypted private key. diff --git a/src/Foundry.Core/Services/Media/MediaPreflightOptions.cs b/src/Foundry.Core/Services/Media/MediaPreflightOptions.cs index 63454dd..0da9b17 100644 --- a/src/Foundry.Core/Services/Media/MediaPreflightOptions.cs +++ b/src/Foundry.Core/Services/Media/MediaPreflightOptions.cs @@ -1,3 +1,4 @@ +using Foundry.Core.Models.Configuration; using Foundry.Core.Services.WinPe; namespace Foundry.Core.Services.Media; @@ -42,6 +43,11 @@ public sealed record MediaPreflightOptions /// public bool IsAutopilotConfigurationReady { get; init; } = true; + /// + /// 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.Deploy.Tests/DeploymentLaunchPreparationServiceTests.cs b/src/Foundry.Deploy.Tests/DeploymentLaunchPreparationServiceTests.cs index 42e36e2..dfee449 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 c4bedd3..fd43440 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 0000000..f6e0393 --- /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 61a0257..df6edad 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/Services/Deployment/DeploymentContext.cs b/src/Foundry.Deploy/Services/Deployment/DeploymentContext.cs index 0e3b63f..1f6169c 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 bc9e65a..b48103a 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 fe62dca..956eaed 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/Steps/StageAutopilotConfigurationStep.cs b/src/Foundry.Deploy/Services/Deployment/Steps/StageAutopilotConfigurationStep.cs index ef14ed5..d81c722 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 0000000..7bd0f5d --- /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 99c7ea9..b8a21db 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 39f6c1c..5e6a280 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 6b9ed6c..bef32be 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 443d15d..bc6677b 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 c25bd01..5210cbb 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 1fae0a3..3283509 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/Services/Configuration/FoundryConfigurationStateService.cs b/src/Foundry/Services/Configuration/FoundryConfigurationStateService.cs index 8453bde..d8f4dcf 100644 --- a/src/Foundry/Services/Configuration/FoundryConfigurationStateService.cs +++ b/src/Foundry/Services/Configuration/FoundryConfigurationStateService.cs @@ -80,6 +80,9 @@ public bool IsDeployConfigurationReady /// public bool IsAutopilotConfigurationReady => AutopilotConfigurationValidator.IsReady(Current.Autopilot, DateTimeOffset.UtcNow); + /// + public AutopilotProvisioningMode AutopilotProvisioningMode => Current.Autopilot.ProvisioningMode; + /// public string? SelectedAutopilotProfileDisplayName => Current.Autopilot.IsEnabled && Current.Autopilot.ProvisioningMode == AutopilotProvisioningMode.JsonProfile diff --git a/src/Foundry/Services/Configuration/IFoundryConfigurationStateService.cs b/src/Foundry/Services/Configuration/IFoundryConfigurationStateService.cs index dd27d58..6c38c44 100644 --- a/src/Foundry/Services/Configuration/IFoundryConfigurationStateService.cs +++ b/src/Foundry/Services/Configuration/IFoundryConfigurationStateService.cs @@ -52,6 +52,11 @@ public interface IFoundryConfigurationStateService /// bool IsAutopilotConfigurationReady { 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 f981bff..603e1e3 100644 --- a/src/Foundry/Strings/en-US/Resources.resw +++ b/src/Foundry/Strings/en-US/Resources.resw @@ -282,6 +282,81 @@ {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 + + + Tenant connection + + + Not connected + + + Connected to tenant {0} + + + App registration + + + Foundry OSD Autopilot Registration is not configured. + + + {0} is configured. + + + Certificate + + + No active certificate is configured. + + + The active certificate expiration is missing. + + + Valid until {0} + + + Expired on {0} + + + Regenerate the certificate and boot media before using hardware hash upload. + + + Default group tag + + + No default group tag selected. + + + Known group tags + + + No group tags discovered. + + + Tenant onboarding + + + Tenant onboarding and certificate creation are implemented in the next security phase. + Customization @@ -1137,6 +1212,9 @@ Enabled: {0} ({1}) + + Enabled: hardware hash upload + Enabled; default profile missing diff --git a/src/Foundry/Strings/fr-FR/Resources.resw b/src/Foundry/Strings/fr-FR/Resources.resw index c95e476..2ac215c 100644 --- a/src/Foundry/Strings/fr-FR/Resources.resw +++ b/src/Foundry/Strings/fr-FR/Resources.resw @@ -282,6 +282,81 @@ {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 + + + Connexion tenant + + + Non connecté + + + Connecté au tenant {0} + + + App registration + + + Foundry OSD Autopilot Registration n'est pas configurée. + + + {0} est configurée. + + + Certificat + + + Aucun certificat actif n'est configuré. + + + L'expiration du certificat actif est manquante. + + + Valide jusqu'au {0} + + + Expiré le {0} + + + Regénérez le certificat et le média de démarrage avant d'utiliser l'upload du hardware hash. + + + Group tag par défaut + + + Aucun group tag par défaut sélectionné. + + + Group tags connus + + + Aucun group tag découvert. + + + Onboarding tenant + + + L'onboarding tenant et la création de certificat seront implémentés dans la prochaine phase de sécurité. + Personnalisation @@ -1137,6 +1212,9 @@ Activé : {0} ({1}) + + Activé : upload du hardware hash + Activé ; profil par défaut manquant diff --git a/src/Foundry/ViewModels/AutopilotConfigurationViewModel.cs b/src/Foundry/ViewModels/AutopilotConfigurationViewModel.cs index 3fbfe66..f9386ca 100644 --- a/src/Foundry/ViewModels/AutopilotConfigurationViewModel.cs +++ b/src/Foundry/ViewModels/AutopilotConfigurationViewModel.cs @@ -28,6 +28,8 @@ public sealed partial class AutopilotConfigurationViewModel : ObservableObject, private readonly ILogger logger; private bool isApplyingState = true; private bool isSavingState; + private AutopilotProvisioningMode provisioningMode = AutopilotProvisioningMode.JsonProfile; + private AutopilotHardwareHashUploadSettings hardwareHashUploadSettings = new(); public AutopilotConfigurationViewModel( IFoundryConfigurationStateService configurationStateService, @@ -81,6 +83,62 @@ public AutopilotConfigurationViewModel( : IsDownloading ? DownloadingStatusText : 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 HardwareHashCertificateWarningVisibility => IsHardwareHashCertificateExpired ? Visibility.Visible : Visibility.Collapsed; + public string ManagedAppRegistrationName => AutopilotHardwareHashUploadSettings.ManagedAppRegistrationDisplayName; + public string TenantStatusText => HasTenantRegistration + ? localizationService.FormatString("Autopilot.HardwareHashTenantConnectedFormat", hardwareHashUploadSettings.Tenant.TenantId!) + : localizationService.GetString("Autopilot.HardwareHashTenantNotConnected"); + public string AppRegistrationStatusText => string.IsNullOrWhiteSpace(hardwareHashUploadSettings.Tenant.ApplicationObjectId) + ? localizationService.GetString("Autopilot.HardwareHashAppRegistrationMissing") + : localizationService.FormatString("Autopilot.HardwareHashAppRegistrationFoundFormat", ManagedAppRegistrationName); + public string CertificateStatusText => CreateCertificateStatusText(); + public string DefaultGroupTagText => string.IsNullOrWhiteSpace(hardwareHashUploadSettings.DefaultGroupTag) + ? localizationService.GetString("Autopilot.HardwareHashDefaultGroupTagNone") + : hardwareHashUploadSettings.DefaultGroupTag!; + public string KnownGroupTagsText => hardwareHashUploadSettings.KnownGroupTags.Count == 0 + ? localizationService.GetString("Autopilot.HardwareHashKnownGroupTagsNone") + : string.Join(", ", hardwareHashUploadSettings.KnownGroupTags); + + private bool HasTenantRegistration => !string.IsNullOrWhiteSpace(hardwareHashUploadSettings.Tenant.TenantId) && + !string.IsNullOrWhiteSpace(hardwareHashUploadSettings.Tenant.ClientId); [ObservableProperty] public partial string PageTitle { get; set; } @@ -94,6 +152,51 @@ 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 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 TenantStatusLabel { get; set; } + + [ObservableProperty] + public partial string AppRegistrationLabel { get; set; } + + [ObservableProperty] + public partial string CertificateStatusLabel { get; set; } + + [ObservableProperty] + public partial string CertificateExpiredWarningText { get; set; } + + [ObservableProperty] + public partial string DefaultGroupTagLabel { get; set; } + + [ObservableProperty] + public partial string KnownGroupTagsLabel { get; set; } + + [ObservableProperty] + public partial string HardwareHashOnboardingUnavailableTitle { get; set; } + + [ObservableProperty] + public partial string HardwareHashOnboardingUnavailableMessage { get; set; } + [ObservableProperty] public partial string ImportButtonText { get; set; } @@ -147,9 +250,12 @@ public AutopilotConfigurationViewModel( [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsAutopilotSectionEnabled))] + [NotifyPropertyChangedFor(nameof(JsonProfileSettingsVisibility))] + [NotifyPropertyChangedFor(nameof(HardwareHashSettingsVisibility))] [NotifyCanExecuteChangedFor(nameof(ImportProfileCommand))] [NotifyCanExecuteChangedFor(nameof(DownloadProfilesCommand))] [NotifyCanExecuteChangedFor(nameof(RemoveSelectedProfilesCommand))] + [NotifyCanExecuteChangedFor(nameof(ConnectTenantCommand))] public partial bool IsAutopilotEnabled { get; set; } [ObservableProperty] @@ -162,6 +268,7 @@ public AutopilotConfigurationViewModel( [NotifyPropertyChangedFor(nameof(IsBusy))] [NotifyPropertyChangedFor(nameof(BusyStatusText))] [NotifyPropertyChangedFor(nameof(BusyStatusVisibility))] + [NotifyCanExecuteChangedFor(nameof(ConnectTenantCommand))] public partial bool IsImporting { get; set; } [ObservableProperty] @@ -171,6 +278,7 @@ public AutopilotConfigurationViewModel( [NotifyPropertyChangedFor(nameof(IsBusy))] [NotifyPropertyChangedFor(nameof(BusyStatusText))] [NotifyPropertyChangedFor(nameof(BusyStatusVisibility))] + [NotifyCanExecuteChangedFor(nameof(ConnectTenantCommand))] public partial bool IsDownloading { get; set; } /// @@ -312,8 +420,20 @@ private async Task RemoveSelectedProfilesAsync() SaveState(); } + [RelayCommand(CanExecute = nameof(CanConnectTenant))] + private async Task ConnectTenantAsync() + { + await dialogService.ShowMessageAsync(new DialogRequest( + HardwareHashOnboardingUnavailableTitle, + HardwareHashOnboardingUnavailableMessage)); + } + partial void OnIsAutopilotEnabledChanged(bool value) { + ImportProfileCommand.NotifyCanExecuteChanged(); + DownloadProfilesCommand.NotifyCanExecuteChanged(); + RemoveSelectedProfilesCommand.NotifyCanExecuteChanged(); + ConnectTenantCommand.NotifyCanExecuteChanged(); SaveState(); } @@ -328,9 +448,15 @@ private void ApplyState(AutopilotSettings settings) try { IsAutopilotEnabled = settings.IsEnabled; + provisioningMode = Enum.IsDefined(settings.ProvisioningMode) + ? settings.ProvisioningMode + : AutopilotProvisioningMode.JsonProfile; + hardwareHashUploadSettings = settings.HardwareHashUpload ?? new AutopilotHardwareHashUploadSettings(); ReplaceProfiles( settings.Profiles.Select(AutopilotProfileEntryViewModel.FromSettings), settings.DefaultProfileId); + RefreshProvisioningModeState(); + RefreshHardwareHashUploadState(); } finally { @@ -391,8 +517,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 +535,21 @@ 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"); + HardwareHashHeader = localizationService.GetString("Autopilot.HardwareHashHeader"); + HardwareHashDescription = localizationService.GetString("Autopilot.HardwareHashDescription"); + HardwareHashEnableText = localizationService.GetString("Autopilot.HardwareHashEnableLabel"); + ConnectTenantButtonText = localizationService.GetString("Autopilot.HardwareHashConnectTenantButton"); + TenantStatusLabel = localizationService.GetString("Autopilot.HardwareHashTenantStatusLabel"); + AppRegistrationLabel = localizationService.GetString("Autopilot.HardwareHashAppRegistrationLabel"); + CertificateStatusLabel = localizationService.GetString("Autopilot.HardwareHashCertificateStatusLabel"); + CertificateExpiredWarningText = localizationService.GetString("Autopilot.HardwareHashCertificateExpiredWarning"); + DefaultGroupTagLabel = localizationService.GetString("Autopilot.HardwareHashDefaultGroupTagLabel"); + KnownGroupTagsLabel = localizationService.GetString("Autopilot.HardwareHashKnownGroupTagsLabel"); + HardwareHashOnboardingUnavailableTitle = localizationService.GetString("Autopilot.HardwareHashOnboardingUnavailableTitle"); + HardwareHashOnboardingUnavailableMessage = localizationService.GetString("Autopilot.HardwareHashOnboardingUnavailableMessage"); ImportButtonText = localizationService.GetString("Autopilot.ImportButton"); DownloadButtonText = localizationService.GetString("Autopilot.DownloadButton"); RemoveButtonText = localizationService.GetString("Autopilot.RemoveButton"); @@ -425,6 +568,62 @@ private void RefreshLocalizedText() ProfileImportedColumnHeader = localizationService.GetString("Autopilot.ColumnImported"); ProfileFolderColumnHeader = localizationService.GetString("Autopilot.ColumnFolder"); OnPropertyChanged(nameof(BusyStatusText)); + 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(); + } + + private void RefreshHardwareHashUploadState() + { + OnPropertyChanged(nameof(TenantStatusText)); + OnPropertyChanged(nameof(AppRegistrationStatusText)); + OnPropertyChanged(nameof(CertificateStatusText)); + OnPropertyChanged(nameof(IsHardwareHashCertificateExpired)); + OnPropertyChanged(nameof(HardwareHashCertificateWarningVisibility)); + OnPropertyChanged(nameof(DefaultGroupTagText)); + OnPropertyChanged(nameof(KnownGroupTagsText)); + } + + private string CreateCertificateStatusText() + { + AutopilotCertificateMetadata? certificate = hardwareHashUploadSettings.ActiveCertificate; + if (certificate is null) + { + return localizationService.GetString("Autopilot.HardwareHashCertificateMissing"); + } + + if (certificate.ExpiresOnUtc is null) + { + return localizationService.GetString("Autopilot.HardwareHashCertificateExpirationMissing"); + } + + return certificate.ExpiresOnUtc <= DateTimeOffset.UtcNow + ? localizationService.FormatString("Autopilot.HardwareHashCertificateExpiredFormat", certificate.ExpiresOnUtc.Value.LocalDateTime) + : localizationService.FormatString("Autopilot.HardwareHashCertificateValidFormat", certificate.ExpiresOnUtc.Value.LocalDateTime); } private void RefreshProfileState() @@ -475,16 +674,21 @@ 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; } } diff --git a/src/Foundry/ViewModels/StartMediaViewModel.cs b/src/Foundry/ViewModels/StartMediaViewModel.cs index 6e09ff9..257067c 100644 --- a/src/Foundry/ViewModels/StartMediaViewModel.cs +++ b/src/Foundry/ViewModels/StartMediaViewModel.cs @@ -1213,6 +1213,7 @@ private MediaPreflightOptions CreatePreflightOptions() AreRequiredSecretsReady = foundryConfigurationStateService.AreRequiredSecretsReady, IsAutopilotEnabled = foundryConfigurationStateService.IsAutopilotEnabled, IsAutopilotConfigurationReady = foundryConfigurationStateService.IsAutopilotConfigurationReady, + AutopilotProvisioningMode = foundryConfigurationStateService.AutopilotProvisioningMode, AutopilotProfileDisplayName = foundryConfigurationStateService.SelectedAutopilotProfileDisplayName, AutopilotProfileFolderName = foundryConfigurationStateService.SelectedAutopilotProfileFolderName, IsFinalExecutionEnabled = true, @@ -1785,6 +1786,11 @@ private string FormatAutopilot(MediaPreflightOptions options) return localizationService.GetString("StartMedia.Autopilot.NotReady"); } + if (options.AutopilotProvisioningMode == AutopilotProvisioningMode.HardwareHashUpload) + { + return localizationService.GetString("StartMedia.Autopilot.HardwareHashUpload"); + } + return string.Format( localizationService.GetString("StartMedia.Autopilot.ProfileFormat"), FormatValue(options.AutopilotProfileDisplayName), diff --git a/src/Foundry/Views/AutopilotPage.xaml b/src/Foundry/Views/AutopilotPage.xaml index 0679c29..3491fb9 100644 --- a/src/Foundry/Views/AutopilotPage.xaml +++ b/src/Foundry/Views/AutopilotPage.xaml @@ -24,95 +24,162 @@ IsOn="{x:Bind ViewModel.IsAutopilotEnabled, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> - - - - - - - - - - - - - - + + + + + + + + + + + + - - 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/Services/Autopilot/AutopilotPfxCertificateValidator.cs b/src/Foundry.Core/Services/Autopilot/AutopilotPfxCertificateValidator.cs new file mode 100644 index 0000000..e1ffffc --- /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 0000000..1cee423 --- /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 0000000..65adef3 --- /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 index 832a857..bbfbcac 100644 --- a/src/Foundry.Core/Services/Configuration/AutopilotConfigurationValidator.cs +++ b/src/Foundry.Core/Services/Configuration/AutopilotConfigurationValidator.cs @@ -14,19 +14,32 @@ public static class AutopilotConfigurationValidator /// 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 true; + return AutopilotConfigurationValidationResult.Ready(AutopilotConfigurationValidationCode.Disabled); } return settings.ProvisioningMode switch { - AutopilotProvisioningMode.JsonProfile => GetSelectedJsonProfile(settings) is not null, - AutopilotProvisioningMode.HardwareHashUpload => IsHardwareHashUploadReady(settings.HardwareHashUpload, currentTimeUtc), - _ => false + 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) }; } @@ -45,9 +58,9 @@ internal static void ThrowIfNotReady(AutopilotSettings settings, DateTimeOffset } if (settings.ProvisioningMode == AutopilotProvisioningMode.HardwareHashUpload && - !IsHardwareHashUploadReady(settings.HardwareHashUpload, currentTimeUtc)) + !EvaluateHardwareHashUpload(settings.HardwareHashUpload, currentTimeUtc).IsReady) { - throw new InvalidOperationException("Autopilot hardware hash upload mode requires complete tenant and unexpired certificate metadata."); + 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)) @@ -67,21 +80,213 @@ internal static void ThrowIfNotReady(AutopilotSettings settings, DateTimeOffset string.Equals(profile.Id, settings.DefaultProfileId, StringComparison.OrdinalIgnoreCase)); } - private static bool IsHardwareHashUploadReady( + private static AutopilotConfigurationValidationResult EvaluateHardwareHashUpload( AutopilotHardwareHashUploadSettings? settings, DateTimeOffset currentTimeUtc) { if (settings?.Tenant is null) { - return false; + 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 !string.IsNullOrWhiteSpace(settings.Tenant.TenantId) && - !string.IsNullOrWhiteSpace(settings.Tenant.ApplicationObjectId) && - !string.IsNullOrWhiteSpace(settings.Tenant.ClientId) && - !string.IsNullOrWhiteSpace(settings.ActiveCertificate?.KeyId) && - !string.IsNullOrWhiteSpace(settings.ActiveCertificate.Thumbprint) && - settings.ActiveCertificate.ExpiresOnUtc is { } expiresOnUtc && - expiresOnUtc > currentTimeUtc; + 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 06c5ffd..148958e 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/Media/MediaPreflightOptions.cs b/src/Foundry.Core/Services/Media/MediaPreflightOptions.cs index 0da9b17..50672a1 100644 --- a/src/Foundry.Core/Services/Media/MediaPreflightOptions.cs +++ b/src/Foundry.Core/Services/Media/MediaPreflightOptions.cs @@ -1,4 +1,5 @@ using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Configuration; using Foundry.Core.Services.WinPe; namespace Foundry.Core.Services.Media; @@ -43,6 +44,12 @@ public sealed record MediaPreflightOptions /// 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. /// diff --git a/src/Foundry.Core/Services/WinPe/WinPeMountedImageAssetProvisioningService.cs b/src/Foundry.Core/Services/WinPe/WinPeMountedImageAssetProvisioningService.cs index 50b5ba1..477ab6a 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/DependencyInjection/ServiceCollectionExtensions.cs b/src/Foundry/DependencyInjection/ServiceCollectionExtensions.cs index f6e1995..f7e0671 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 0000000..c589009 --- /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 0000000..caccb7c --- /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 0000000..486c871 --- /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 0000000..e6406d0 --- /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 0000000..fdaeadd --- /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 0000000..30b2955 --- /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 d47f80f..0000000 --- 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 0000000..2f4efbf --- /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 0000000..4650f8f --- /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 0000000..d9e1141 --- /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 58ecb48..15bef8d 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 0000000..80dd710 --- /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 0000000..6005292 --- /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 0000000..2b35ad4 --- /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 fa879df..0000000 --- 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 0000000..e3fafdf --- /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 0000000..faf891f --- /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 d8f4dcf..096e667 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,7 +82,11 @@ public bool IsDeployConfigurationReady public bool IsAutopilotEnabled => Current.Autopilot.IsEnabled; /// - public bool IsAutopilotConfigurationReady => AutopilotConfigurationValidator.IsReady(Current.Autopilot, DateTimeOffset.UtcNow); + public bool IsAutopilotConfigurationReady => AutopilotConfigurationValidation.IsReady; + + /// + public AutopilotConfigurationValidationResult AutopilotConfigurationValidation => + AutopilotConfigurationValidator.Evaluate(CreateAutopilotSettingsForValidation(Current.Autopilot), DateTimeOffset.UtcNow); /// public AutopilotProvisioningMode AutopilotProvisioningMode => Current.Autopilot.ProvisioningMode; @@ -153,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)); } @@ -194,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 diff --git a/src/Foundry/Services/Configuration/IFoundryConfigurationStateService.cs b/src/Foundry/Services/Configuration/IFoundryConfigurationStateService.cs index 6c38c44..da65fe4 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; @@ -52,6 +53,11 @@ 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. /// diff --git a/src/Foundry/Strings/en-US/Resources.resw b/src/Foundry/Strings/en-US/Resources.resw index 603e1e3..ae2fbba 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 @@ -303,17 +309,50 @@ 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 to tenant {0} + + Connected + + + Tenant readiness + + + Tenant, app registration, and readiness information for hardware hash upload. + + + Name + + + Value + + + Tenant ID + + + Client ID - App registration + Managed app registration Foundry OSD Autopilot Registration is not configured. @@ -321,41 +360,188 @@ {0} is configured. - - Certificate + + 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 - - No active certificate is configured. + + Foundry could not remove the selected app registration certificate. {0} - - The active certificate expiration is missing. + + Thumbprint - - Valid until {0} + + Created - - Expired on {0} + + Expiration - - Regenerate the certificate and boot media before using hardware hash upload. + + 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. - - Known group tags + + None + + + None + + + Tenant onboarding requires attention + + + Tenant onboarding failed + + + Foundry could not configure the Autopilot app registration. {0} - - No group tags discovered. + + The managed app registration could not be found. Reconnect to let Foundry create or adopt it. - - Tenant onboarding + + An existing app registration with the Foundry name was found. Reconnect to adopt it into the Foundry configuration. - - Tenant onboarding and certificate creation are implemented in the next security phase. + + 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 @@ -1215,8 +1401,56 @@ Enabled: hardware hash upload - - Enabled; default profile missing + + 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 2ac215c..ca877a0 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é @@ -303,17 +309,50 @@ 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é au tenant {0} + + 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 + App registration managée Foundry OSD Autopilot Registration n'est pas configurée. @@ -321,41 +360,188 @@ {0} est configurée. - - Certificat + + É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 - - Aucun certificat actif n'est configuré. + + Foundry n'a pas pu supprimer le certificat sélectionné de l'app registration. {0} - - L'expiration du certificat actif est manquante. + + Empreinte - - Valide jusqu'au {0} + + Création - - Expiré le {0} + + Expiration - - Regénérez le certificat et le média de démarrage avant d'utiliser l'upload du hardware hash. + + 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é. - - Group tags connus + + None + + + Aucun + + + L'onboarding tenant demande une action + + + Échec de l'onboarding tenant + + + Foundry n'a pas pu configurer l'app registration Autopilot. {0} - - Aucun group tag découvert. + + L'app registration managée est introuvable. Reconnectez-vous pour laisser Foundry la créer ou l'adopter. - - Onboarding tenant + + Une app registration existante avec le nom Foundry a été trouvée. Reconnectez-vous pour l'adopter dans la configuration Foundry. - - L'onboarding tenant et la création de certificat seront implémentés dans la prochaine phase de sécurité. + + 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 @@ -1215,8 +1401,56 @@ Activé : upload du hardware hash - - Activé ; profil par défaut manquant + + 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 0000000..45dbc88 --- /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 f9386ca..3cd49b1 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,8 +22,12 @@ 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; @@ -30,13 +36,20 @@ public sealed partial class AutopilotConfigurationViewModel : ObservableObject, 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, @@ -45,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; @@ -59,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); } /// @@ -72,17 +91,54 @@ 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; @@ -121,25 +177,50 @@ public bool UseHardwareHashUploadProvisioning expiresOnUtc <= DateTimeOffset.UtcNow; public Visibility JsonProfileSettingsVisibility => IsJsonProfileMode ? Visibility.Visible : Visibility.Collapsed; public Visibility HardwareHashSettingsVisibility => IsHardwareHashUploadMode ? Visibility.Visible : Visibility.Collapsed; - public Visibility HardwareHashCertificateWarningVisibility => IsHardwareHashCertificateExpired ? 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 => HasTenantRegistration - ? localizationService.FormatString("Autopilot.HardwareHashTenantConnectedFormat", hardwareHashUploadSettings.Tenant.TenantId!) + 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 CertificateStatusText => CreateCertificateStatusText(); + 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!; - public string KnownGroupTagsText => hardwareHashUploadSettings.KnownGroupTags.Count == 0 - ? localizationService.GetString("Autopilot.HardwareHashKnownGroupTagsNone") - : string.Join(", ", hardwareHashUploadSettings.KnownGroupTags); - 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; } @@ -161,6 +242,15 @@ public bool UseHardwareHashUploadProvisioning [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; } @@ -173,29 +263,98 @@ public bool UseHardwareHashUploadProvisioning [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 CertificateStatusLabel { get; set; } + 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 CertificateExpiredWarningText { get; set; } + public partial string BootMediaCertificatePfxPathLabel { get; set; } [ObservableProperty] - public partial string DefaultGroupTagLabel { get; set; } + public partial string BootMediaCertificatePasswordLabel { get; set; } [ObservableProperty] - public partial string KnownGroupTagsLabel { get; set; } + public partial string SelectBootMediaCertificateButtonText { get; set; } [ObservableProperty] - public partial string HardwareHashOnboardingUnavailableTitle { get; set; } + public partial string GroupTagLabel { get; set; } [ObservableProperty] - public partial string HardwareHashOnboardingUnavailableMessage { get; set; } + public partial string GroupTagDescription { get; set; } + + [ObservableProperty] + public partial string DefaultGroupTagNoneOptionText { get; set; } [ObservableProperty] public partial string ImportButtonText { get; set; } @@ -256,6 +415,9 @@ public bool UseHardwareHashUploadProvisioning [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] @@ -269,6 +431,9 @@ public bool UseHardwareHashUploadProvisioning [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] @@ -279,8 +444,35 @@ public bool UseHardwareHashUploadProvisioning [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. /// @@ -290,6 +482,7 @@ public void Dispose() configurationStateService.StateChanged -= OnConfigurationStateChanged; Profiles.CollectionChanged -= OnProfilesCollectionChanged; SelectedProfiles.CollectionChanged -= OnSelectedProfilesCollectionChanged; + SelectedCertificates.CollectionChanged -= OnSelectedCertificatesCollectionChanged; } [RelayCommand(CanExecute = nameof(CanImportProfile))] @@ -327,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."); @@ -423,9 +618,160 @@ private async Task RemoveSelectedProfilesAsync() [RelayCommand(CanExecute = nameof(CanConnectTenant))] private async Task ConnectTenantAsync() { - await dialogService.ShowMessageAsync(new DialogRequest( - HardwareHashOnboardingUnavailableTitle, - HardwareHashOnboardingUnavailableMessage)); + 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) @@ -434,9 +780,52 @@ partial void OnIsAutopilotEnabledChanged(bool value) 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(); @@ -451,7 +840,13 @@ private void ApplyState(AutopilotSettings settings) provisioningMode = Enum.IsDefined(settings.ProvisioningMode) ? settings.ProvisioningMode : AutopilotProvisioningMode.JsonProfile; - hardwareHashUploadSettings = settings.HardwareHashUpload ?? new AutopilotHardwareHashUploadSettings(); + 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); @@ -538,18 +933,44 @@ private void RefreshLocalizedText() 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"); - CertificateStatusLabel = localizationService.GetString("Autopilot.HardwareHashCertificateStatusLabel"); - CertificateExpiredWarningText = localizationService.GetString("Autopilot.HardwareHashCertificateExpiredWarning"); - DefaultGroupTagLabel = localizationService.GetString("Autopilot.HardwareHashDefaultGroupTagLabel"); - KnownGroupTagsLabel = localizationService.GetString("Autopilot.HardwareHashKnownGroupTagsLabel"); - HardwareHashOnboardingUnavailableTitle = localizationService.GetString("Autopilot.HardwareHashOnboardingUnavailableTitle"); - HardwareHashOnboardingUnavailableMessage = localizationService.GetString("Autopilot.HardwareHashOnboardingUnavailableMessage"); + 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"); @@ -568,6 +989,8 @@ private void RefreshLocalizedText() ProfileImportedColumnHeader = localizationService.GetString("Autopilot.ColumnImported"); ProfileFolderColumnHeader = localizationService.GetString("Autopilot.ColumnFolder"); OnPropertyChanged(nameof(BusyStatusText)); + OnPropertyChanged(nameof(TenantConnectionButtonText)); + OnPropertyChanged(nameof(TenantStatusForeground)); RefreshHardwareHashUploadState(); } @@ -595,35 +1018,344 @@ private void RefreshProvisioningModeState() 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(CertificateStatusText)); + OnPropertyChanged(nameof(TenantIdText)); + OnPropertyChanged(nameof(ClientIdText)); + OnPropertyChanged(nameof(TenantOnboardingStatusText)); + OnPropertyChanged(nameof(TenantOnboardingStatusForeground)); + RefreshTenantReadinessEntries(); OnPropertyChanged(nameof(IsHardwareHashCertificateExpired)); - OnPropertyChanged(nameof(HardwareHashCertificateWarningVisibility)); + OnPropertyChanged(nameof(EmptyCertificatesVisibility)); OnPropertyChanged(nameof(DefaultGroupTagText)); - OnPropertyChanged(nameof(KnownGroupTagsText)); + 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 string CreateCertificateStatusText() + private void RefreshBootMediaCertificateState() { - AutopilotCertificateMetadata? certificate = hardwareHashUploadSettings.ActiveCertificate; - if (certificate is null) + 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)) { - return localizationService.GetString("Autopilot.HardwareHashCertificateMissing"); + Certificates.Add(certificate); } - if (certificate.ExpiresOnUtc is null) + 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.HardwareHashCertificateExpirationMissing"); + return localizationService.GetString("Autopilot.HardwareHashBootMediaCertificatePfxMissing"); } - return certificate.ExpiresOnUtc <= DateTimeOffset.UtcNow - ? localizationService.FormatString("Autopilot.HardwareHashCertificateExpiredFormat", certificate.ExpiresOnUtc.Value.LocalDateTime) - : localizationService.FormatString("Autopilot.HardwareHashCertificateValidFormat", certificate.ExpiresOnUtc.Value.LocalDateTime); + 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() @@ -644,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. /// @@ -657,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(); @@ -691,4 +1527,41 @@ 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 0000000..a3b5081 --- /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 0000000..3af966e --- /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 257067c..36f93a6 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,8 @@ 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, @@ -1526,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); } @@ -1783,7 +1787,7 @@ private string FormatAutopilot(MediaPreflightOptions options) if (!options.IsAutopilotConfigurationReady) { - return localizationService.GetString("StartMedia.Autopilot.NotReady"); + return GetAutopilotValidationText(options.AutopilotConfigurationValidationCode); } if (options.AutopilotProvisioningMode == AutopilotProvisioningMode.HardwareHashUpload) @@ -1797,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 3491fb9..c5a3f64 100644 --- a/src/Foundry/Views/AutopilotPage.xaml +++ b/src/Foundry/Views/AutopilotPage.xaml @@ -35,6 +35,7 @@ IsOn="{x:Bind ViewModel.UseJsonProfileProvisioning, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> @@ -71,6 +72,7 @@ @@ -128,55 +131,169 @@ IsOn="{x:Bind ViewModel.UseHardwareHashUploadProvisioning, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />