From a435891fa3aece01b48b6b7ccece7e894908cbe8 Mon Sep 17 00:00:00 2001 From: Walter Luna Date: Wed, 23 Sep 2026 16:26:12 +0100 Subject: [PATCH 01/10] Enable portal-only admin consent for bulk onboarding Declare application roles and delegated scopes with -SkipGrant so administrators can grant consent in Entra without running the script. - Preserve existing API permissions and avoid duplicate declarations - Report declared permissions, consent failures, and portal links - Add regression coverage for permission merging and idempotency --- .../New-A365AutomationApp.ps1 | 177 ++++++++++++++++++ .../tests/PermissionDeclaration.Tests.ps1 | 153 +++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 diff --git a/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 b/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 index c09fa6c5..5e8ab14e 100644 --- a/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 +++ b/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 @@ -13,13 +13,24 @@ 1. Resolves the Microsoft Graph service principal and builds a name -> GUID map from its published appRoles. Nothing is hardcoded, so a tenant that has not yet surfaced a preview permission fails with a clear message instead of a mystery 400. +<<<<<<< Updated upstream 2. Creates (or reuses) the application registration. +======= + 2. Creates (or reuses) the application registration and declares the selected application + permissions and delegated scopes on it. +>>>>>>> Stashed changes 3. Creates (or reuses) its service principal - the app role grants attach to the SP, not the application. 4. Adds credentials: a client secret, a certificate, and/or a federated identity credential for workload identity federation (GitHub Actions, Azure DevOps, Kubernetes). +<<<<<<< Updated upstream 5. Grants each app role via POST /servicePrincipals/{graphSpId}/appRoleAssignedTo, which is admin consent. Already-granted roles are skipped, so re-running is safe. +======= + 5. Unless -SkipGrant, grants each app role via POST + /servicePrincipals/{graphSpId}/appRoleAssignedTo, which is admin consent. Already-granted + roles are skipped, so re-running is safe. +>>>>>>> Stashed changes 6. Reads the grants back and prints ready-to-paste invocations for the other scripts. Every step is idempotent: re-running reconciles rather than duplicates. @@ -94,7 +105,12 @@ the Registration and All scenarios. .PARAMETER SkipGrant +<<<<<<< Updated upstream Create the app and credentials but do not grant the app roles. +======= + Declare the selected application permissions and delegated scopes, but do not grant consent. + An administrator can finish from the Entra portal without adding permissions manually. +>>>>>>> Stashed changes .PARAMETER OutputPath Writes a JSON summary to this path. The client secret is NOT written to it. @@ -221,6 +237,10 @@ $script:MicrosoftGraphAppId = '00000003-0000-0000-c000-000000000000' function Test-HasProperty { param($Object, [Parameter(Mandatory)][string] $Name) if ($null -eq $Object) { return $false } +<<<<<<< Updated upstream +======= + if ($Object -is [System.Collections.IDictionary]) { return $Object.Contains($Name) } +>>>>>>> Stashed changes $properties = $Object.PSObject.Properties if ($null -eq $properties) { return $false } foreach ($property in $properties) { @@ -229,6 +249,79 @@ function Test-HasProperty { return $false } +<<<<<<< Updated upstream +======= +function Merge-GraphRequiredResourceAccess { + param( + [object[]] $ExistingAccess = @(), + [Parameter(Mandatory)][string] $ResourceAppId, + [object[]] $ApplicationRoles = @(), + [object[]] $DelegatedScopes = @() + ) + + $graphAccess = [System.Collections.Generic.List[object]]::new() + $otherResources = [System.Collections.Generic.List[object]]::new() + + foreach ($entry in @($ExistingAccess)) { + if (-not (Test-HasProperty $entry 'resourceAppId')) { continue } + + $resourceAccess = @() + if (Test-HasProperty $entry 'resourceAccess') { + $resourceAccess = @($entry.resourceAccess | ForEach-Object { + @{ id = [string]$_.id; type = [string]$_.type } + }) + } + + if ([string]$entry.resourceAppId -eq $ResourceAppId) { + foreach ($access in $resourceAccess) { $graphAccess.Add($access) } + } + else { + $otherResources.Add(@{ + resourceAppId = [string]$entry.resourceAppId + resourceAccess = $resourceAccess + }) + } + } + + $existingKeys = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($access in $graphAccess) { + [void]$existingKeys.Add(('{0}|{1}' -f $access.type, $access.id)) + } + + $rolesAdded = [System.Collections.Generic.List[object]]::new() + foreach ($role in @($ApplicationRoles)) { + if ($existingKeys.Add(('Role|{0}' -f $role.Id))) { + $graphAccess.Add(@{ id = [string]$role.Id; type = 'Role' }) + $rolesAdded.Add($role) + } + } + + $scopesAdded = [System.Collections.Generic.List[object]]::new() + foreach ($scope in @($DelegatedScopes)) { + if ($existingKeys.Add(('Scope|{0}' -f $scope.Id))) { + $graphAccess.Add(@{ id = [string]$scope.Id; type = 'Scope' }) + $scopesAdded.Add($scope) + } + } + + $requiredResourceAccess = [System.Collections.Generic.List[object]]::new() + foreach ($entry in $otherResources) { $requiredResourceAccess.Add($entry) } + if ($graphAccess.Count -gt 0) { + $requiredResourceAccess.Add(@{ + resourceAppId = $ResourceAppId + resourceAccess = @($graphAccess) + }) + } + + return [pscustomobject]@{ + Changed = ($rolesAdded.Count -gt 0 -or $scopesAdded.Count -gt 0) + RolesAdded = @($rolesAdded) + ScopesAdded = @($scopesAdded) + RequiredResourceAccess = @($requiredResourceAccess) + } +} + +>>>>>>> Stashed changes function Get-GraphErrorInfo { param($ErrorRecord) @@ -1115,6 +1208,7 @@ else { $applicationObjectId = [string]$application.id $applicationAppId = [string]$application.appId +<<<<<<< Updated upstream # Request the delegated scopes on the app object so an admin can consent to them in one action. if ($delegatedToRequest.Count -gt 0) { $current = Invoke-Graph -Method GET -Uri "/applications/$applicationObjectId`?`$select=requiredResourceAccess" @@ -1151,6 +1245,32 @@ if ($delegatedToRequest.Count -gt 0) { else { Write-Host ' Delegated scopes already requested on the application.' -ForegroundColor Gray } +======= +# Declare permissions even with -SkipGrant so an administrator can consent from the portal. +$current = Invoke-Graph -Method GET -Uri "/applications/$applicationObjectId`?`$select=requiredResourceAccess" +$existingAccess = @() +if (Test-HasProperty $current 'requiredResourceAccess') { $existingAccess = @($current.requiredResourceAccess) } + +$permissionMerge = Merge-GraphRequiredResourceAccess -ExistingAccess $existingAccess ` + -ResourceAppId $script:MicrosoftGraphAppId -ApplicationRoles $resolved ` + -DelegatedScopes $delegatedToRequest + +if ($permissionMerge.Changed) { + $payload = @{ requiredResourceAccess = $permissionMerge.RequiredResourceAccess } + $changeDescription = "+$($permissionMerge.RolesAdded.Count) application permission(s), +$($permissionMerge.ScopesAdded.Count) delegated scope(s)" + if ($PSCmdlet.ShouldProcess($DisplayName, "PATCH requiredResourceAccess ($changeDescription)")) { + Invoke-Graph -Method PATCH -Uri "/applications/$applicationObjectId" -Body $payload | Out-Null + if ($permissionMerge.RolesAdded.Count -gt 0) { + Write-Host " Declared application permission(s): $(($permissionMerge.RolesAdded | ForEach-Object { $_.Name }) -join ', ')" -ForegroundColor Green + } + if ($permissionMerge.ScopesAdded.Count -gt 0) { + Write-Host " Requested delegated scope(s): $(($permissionMerge.ScopesAdded | ForEach-Object { $_.Name }) -join ', ')" -ForegroundColor Green + } + } +} +else { + Write-Host ' Application permissions and delegated scopes already declared on the application.' -ForegroundColor Gray +>>>>>>> Stashed changes } # --------------------------------------------------------------------------- @@ -1294,7 +1414,11 @@ $alreadyHeld = @() $failedGrant = @() if ($SkipGrant) { +<<<<<<< Updated upstream Write-Host ' Skipped by -SkipGrant.' -ForegroundColor Yellow +======= + Write-Host ' Skipped by -SkipGrant. The selected permissions are declared for portal consent.' -ForegroundColor Yellow +>>>>>>> Stashed changes } else { $assigned = Invoke-Graph -Method GET ` @@ -1421,22 +1545,60 @@ if ($plainSecret) { } } +<<<<<<< Updated upstream if ($delegatedToRequest.Count -gt 0) { Write-Host '' Write-Host ' Delegated scopes were REQUESTED but still need admin consent. Grant them here:' -ForegroundColor Yellow Write-Host " https://login.microsoftonline.com/$($ctx.TenantId)/adminconsent?client_id=$applicationAppId" -ForegroundColor Cyan +======= +$portalPermissionsUrl = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Permissions/objectId/$servicePrincipalId/appId/$applicationAppId" + +if ($SkipGrant -or $delegatedToRequest.Count -gt 0 -or $failedGrant.Count -gt 0) { + Write-Host '' + if ($failedGrant.Count -gt 0) { + Write-Host 'ACTION REQUIRED - an administrator must finish granting these permissions.' -ForegroundColor Yellow + foreach ($failure in $failedGrant) { + Write-Host " app role $($failure.Name) on Microsoft Graph" -ForegroundColor Yellow + Write-Host " $($failure.Error)" -ForegroundColor DarkGray + } + Write-Host ' The permissions are declared on the app but grant no claims until consent succeeds.' -ForegroundColor Yellow + } + elseif ($SkipGrant) { + Write-Host ' Permissions were declared but not consented. An administrator can grant everything' -ForegroundColor Yellow + Write-Host ' from the portal without adding permissions manually:' -ForegroundColor Yellow + } + else { + Write-Host ' If the requested delegated scopes are not already consented, grant them here:' -ForegroundColor Yellow + } + Write-Host " Portal : Enterprise applications > $DisplayName > Security > Permissions > Grant admin consent" -ForegroundColor Cyan + Write-Host " $portalPermissionsUrl" -ForegroundColor Cyan + Write-Host " Link : https://login.microsoftonline.com/$($ctx.TenantId)/adminconsent?client_id=$applicationAppId" -ForegroundColor Cyan +>>>>>>> Stashed changes } # The custom security attribute permissions are the one pair on this app that a directory role can # still block. Saying so here turns a guaranteed later 403 into something actionable, because the # failure names no permission when it happens. if ($Scenario -in 'AgentIdentity', 'All') { +<<<<<<< Updated upstream $csaGranted = @($resolved | Where-Object { $_.Name -like 'CustomSecAttribute*' }) if ($csaGranted.Count -gt 0) { Write-Host '' Write-Host ' Custom security attributes:' -ForegroundColor Cyan Write-Host (' Application roles granted: {0}' -f (($csaGranted | ForEach-Object { $_.Name }) -join ', ')) -ForegroundColor Gray Write-Host ' That is all an UNATTENDED (app-only) run needs.' -ForegroundColor Gray +======= + $csaRoles = @($resolved | Where-Object { $_.Name -like 'CustomSecAttribute*' }) + if ($csaRoles.Count -gt 0) { + Write-Host '' + Write-Host ' Custom security attributes:' -ForegroundColor Cyan + $csaGrantFailed = @($failedGrant | Where-Object { $_.Name -like 'CustomSecAttribute*' }).Count -gt 0 + $csaState = if ($SkipGrant -or $csaGrantFailed) { 'declared for admin consent' } else { 'granted' } + Write-Host (' Application roles {0}: {1}' -f $csaState, (($csaRoles | ForEach-Object { $_.Name }) -join ', ')) -ForegroundColor Gray + if (-not $SkipGrant -and -not $csaGrantFailed) { + Write-Host ' That is all an UNATTENDED (app-only) run needs.' -ForegroundColor Gray + } +>>>>>>> Stashed changes Write-Host ' An INTERACTIVE run needs more: the signed-in user must also hold the' -ForegroundColor Yellow Write-Host ' Attribute Assignment Administrator directory role. No application permission' -ForegroundColor Yellow Write-Host ' grants it, and Global Administrator does NOT include it.' -ForegroundColor Yellow @@ -1466,12 +1628,27 @@ $summary = [ordered]@{ applicationObjectId = $applicationObjectId servicePrincipalId = $servicePrincipalId scenario = $Scenario +<<<<<<< Updated upstream +======= + grantSkipped = [bool]$SkipGrant + appRolesDeclared = @($resolved | ForEach-Object { $_.Name }) + appRolesAddedToRequest = @($permissionMerge.RolesAdded | ForEach-Object { $_.Name }) +>>>>>>> Stashed changes appRolesGranted = @($grantedNow) appRolesAlreadyHeld = @($alreadyHeld) appRolesVerified = @($verifiedNames) appRolesUnresolved = @($missing) +<<<<<<< Updated upstream + delegatedScopesRequested = @($delegatedToRequest | ForEach-Object { $_.Name }) + adminConsentUrl = "https://login.microsoftonline.com/$($ctx.TenantId)/adminconsent?client_id=$applicationAppId" +======= + delegatedScopesDeclared = @($delegatedToRequest | ForEach-Object { $_.Name }) delegatedScopesRequested = @($delegatedToRequest | ForEach-Object { $_.Name }) + delegatedScopesAddedToRequest = @($permissionMerge.ScopesAdded | ForEach-Object { $_.Name }) + consentFailures = @($failedGrant) adminConsentUrl = "https://login.microsoftonline.com/$($ctx.TenantId)/adminconsent?client_id=$applicationAppId" + portalPermissionsUrl = $portalPermissionsUrl +>>>>>>> Stashed changes keyVault = $script:KeyVaultResult generatedUtc = [DateTimeOffset]::UtcNow.ToString('o') } diff --git a/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 b/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 new file mode 100644 index 00000000..d2060bb0 --- /dev/null +++ b/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 @@ -0,0 +1,153 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# + Verifies that automation-app permissions are declared in the shape required for portal-based + admin consent, without making live Microsoft Graph calls. +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force + +function Get-A365ExtractedFunctionSource { + param( + [Parameter(Mandatory)][string] $Path, + [Parameter(Mandatory)][string[]] $FunctionName + ) + + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref] $tokens, [ref] $parseErrors) + if ($parseErrors.Count -gt 0) { + throw "'$Path' has $($parseErrors.Count) parse error(s); cannot extract permission helpers." + } + + $functions = $ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $FunctionName -contains $node.Name + }, $true) + + foreach ($name in $FunctionName) { + if (@($functions | Where-Object Name -eq $name).Count -eq 0) { + throw "Function '$name' was not found in '$Path'." + } + } + + return ($functions | ForEach-Object { $_.Extent.Text }) -join "`n`n" +} + +function New-A365Permission { + param([Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][string] $Id) + return [pscustomobject]@{ Name = $Name; Id = $Id } +} + +$script:AutomationAppPath = (Resolve-Path (Join-Path $PSScriptRoot '..' 'New-A365AutomationApp.ps1')).ProviderPath +$functionSource = Get-A365ExtractedFunctionSource -Path $script:AutomationAppPath ` + -FunctionName @('Test-HasProperty', 'Merge-GraphRequiredResourceAccess') +. ([scriptblock]::Create($functionSource)) + +$graphAppId = '00000003-0000-0000-c000-000000000000' + +Test-Case 'Test-HasProperty supports Graph objects and dictionary-shaped merge output' { + Assert-False (Test-HasProperty $null 'value') 'Null must not report any property.' + Assert-True (Test-HasProperty ([pscustomobject]@{ value = 1 }) 'value') 'Graph response objects must expose their properties.' + Assert-False (Test-HasProperty ([pscustomobject]@{ value = 1 }) 'missing') 'Missing Graph object properties must return false.' + Assert-True (Test-HasProperty @{ value = 1 } 'value') 'Dictionary output from the merge helper must expose its keys.' + Assert-False (Test-HasProperty @{ value = 1 } 'missing') 'Missing dictionary keys must return false.' +} + +Test-Case 'Permission declaration adds application roles and delegated scopes with their correct types' { + $result = Merge-GraphRequiredResourceAccess -ResourceAppId $graphAppId ` + -ApplicationRoles @(New-A365Permission -Name 'Application.Read.All' -Id 'role-1') ` + -DelegatedScopes @(New-A365Permission -Name 'User.Read' -Id 'scope-1') + + Assert-True $result.Changed 'A new role and scope must require a requiredResourceAccess update.' + Assert-Count $result.RolesAdded 1 'The application role must be reported as newly declared.' + Assert-Count $result.ScopesAdded 1 'The delegated scope must be reported as newly declared.' + Assert-Count $result.RequiredResourceAccess 1 'Microsoft Graph must produce one resource entry.' + + $access = @($result.RequiredResourceAccess[0].resourceAccess) + $roleEntry = $access | Where-Object { $_.id -eq 'role-1' -and $_.type -eq 'Role' } + $scopeEntry = $access | Where-Object { $_.id -eq 'scope-1' -and $_.type -eq 'Scope' } + Assert-True (@($roleEntry).Count -eq 1) ` + "Application permissions must be declared with type 'Role' so the portal can consent them." + Assert-True (@($scopeEntry).Count -eq 1) ` + "Delegated permissions must be declared with type 'Scope' so the portal can consent them." + Assert-True ($roleEntry.Keys -ccontains 'id') "The Graph PATCH contract requires the lowercase JSON key 'id'." + Assert-True ($roleEntry.Keys -ccontains 'type') "The Graph PATCH contract requires the lowercase JSON key 'type'." +} + +Test-Case 'Permission declaration preserves existing Graph and non-Graph permissions' { + $existing = @( + [pscustomobject]@{ + resourceAppId = $graphAppId + resourceAccess = @([pscustomobject]@{ id = 'existing-role'; type = 'Role' }) + }, + [pscustomobject]@{ + resourceAppId = '11111111-2222-3333-4444-555555555555' + resourceAccess = @([pscustomobject]@{ id = 'external-scope'; type = 'Scope' }) + } + ) + + $result = Merge-GraphRequiredResourceAccess -ExistingAccess $existing -ResourceAppId $graphAppId ` + -ApplicationRoles @( + (New-A365Permission -Name 'Existing' -Id 'existing-role'), + (New-A365Permission -Name 'New' -Id 'new-role') + ) + + $graph = $result.RequiredResourceAccess | Where-Object resourceAppId -eq $graphAppId + $external = $result.RequiredResourceAccess | Where-Object resourceAppId -eq '11111111-2222-3333-4444-555555555555' + + Assert-Count $graph.resourceAccess 2 'Existing Graph permissions must remain while missing roles are added.' + Assert-Count $external.resourceAccess 1 'Permissions for other resource APIs must be preserved.' + Assert-Equal 'external-scope' $external.resourceAccess[0].id 'The existing non-Graph permission must remain unchanged.' + Assert-Count $result.RolesAdded 1 'Only the missing application role should be reported as added.' +} + +Test-Case 'Permission declaration is idempotent' { + $first = Merge-GraphRequiredResourceAccess -ResourceAppId $graphAppId ` + -ApplicationRoles @(New-A365Permission -Name 'Application.Read.All' -Id 'role-1') ` + -DelegatedScopes @(New-A365Permission -Name 'User.Read' -Id 'scope-1') + + $second = Merge-GraphRequiredResourceAccess -ExistingAccess $first.RequiredResourceAccess ` + -ResourceAppId $graphAppId ` + -ApplicationRoles @(New-A365Permission -Name 'Application.Read.All' -Id 'role-1') ` + -DelegatedScopes @(New-A365Permission -Name 'User.Read' -Id 'scope-1') + + Assert-False $second.Changed 'A rerun must not PATCH requiredResourceAccess when every permission is already declared.' + Assert-Count $second.RolesAdded 0 'A rerun must not duplicate application roles.' + Assert-Count $second.ScopesAdded 0 'A rerun must not duplicate delegated scopes.' + Assert-Count $second.RequiredResourceAccess[0].resourceAccess 2 'A rerun must preserve exactly one copy of each permission.' +} + +Test-Case 'Permission declaration distinguishes Role and Scope entries even when their ids match' { + $existing = @([pscustomobject]@{ + resourceAppId = $graphAppId + resourceAccess = @([pscustomobject]@{ id = 'shared-id'; type = 'Role' }) + }) + + $result = Merge-GraphRequiredResourceAccess -ExistingAccess $existing -ResourceAppId $graphAppId ` + -DelegatedScopes @(New-A365Permission -Name 'Shared.Scope' -Id 'shared-id') + + $access = @($result.RequiredResourceAccess[0].resourceAccess) + Assert-True $result.Changed 'Role and Scope are distinct requiredResourceAccess entries.' + Assert-Count (@($access | Where-Object { $_.id -eq 'shared-id' -and $_.type -eq 'Role' })) 1 ` + 'The existing Role entry must remain present exactly once.' + Assert-Count (@($access | Where-Object { $_.id -eq 'shared-id' -and $_.type -eq 'Scope' })) 1 ` + 'The Scope entry must be added even when a Role has the same id.' +} + +Test-Case '-SkipGrant declaration has a structural guard before the direct grant branch' { + $source = Get-Content -LiteralPath $script:AutomationAppPath -Raw + $declarationIndex = $source.IndexOf('$permissionMerge = Merge-GraphRequiredResourceAccess') + $skipGrantIndex = $source.IndexOf('if ($SkipGrant)', $declarationIndex) + + Assert-True ($declarationIndex -ge 0) 'The script must invoke the requiredResourceAccess merge.' + Assert-True ($skipGrantIndex -gt $declarationIndex) ` + 'Structural proxy: permission declaration must appear before -SkipGrant bypasses direct app-role assignment.' +} + +Get-A365TestResults From b44028653d9af3b593d5df4c8a941a04a1b080ec Mon Sep 17 00:00:00 2001 From: Walter Luna Date: Wed, 23 Sep 2026 16:41:32 +0100 Subject: [PATCH 02/10] fix merge conflicts --- .../New-A365AutomationApp.ps1 | 86 ------------------- 1 file changed, 86 deletions(-) diff --git a/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 b/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 index 5e8ab14e..645e6f2b 100644 --- a/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 +++ b/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 @@ -13,24 +13,15 @@ 1. Resolves the Microsoft Graph service principal and builds a name -> GUID map from its published appRoles. Nothing is hardcoded, so a tenant that has not yet surfaced a preview permission fails with a clear message instead of a mystery 400. -<<<<<<< Updated upstream - 2. Creates (or reuses) the application registration. -======= 2. Creates (or reuses) the application registration and declares the selected application permissions and delegated scopes on it. ->>>>>>> Stashed changes 3. Creates (or reuses) its service principal - the app role grants attach to the SP, not the application. 4. Adds credentials: a client secret, a certificate, and/or a federated identity credential for workload identity federation (GitHub Actions, Azure DevOps, Kubernetes). -<<<<<<< Updated upstream - 5. Grants each app role via POST /servicePrincipals/{graphSpId}/appRoleAssignedTo, which is - admin consent. Already-granted roles are skipped, so re-running is safe. -======= 5. Unless -SkipGrant, grants each app role via POST /servicePrincipals/{graphSpId}/appRoleAssignedTo, which is admin consent. Already-granted roles are skipped, so re-running is safe. ->>>>>>> Stashed changes 6. Reads the grants back and prints ready-to-paste invocations for the other scripts. Every step is idempotent: re-running reconciles rather than duplicates. @@ -105,12 +96,8 @@ the Registration and All scenarios. .PARAMETER SkipGrant -<<<<<<< Updated upstream - Create the app and credentials but do not grant the app roles. -======= Declare the selected application permissions and delegated scopes, but do not grant consent. An administrator can finish from the Entra portal without adding permissions manually. ->>>>>>> Stashed changes .PARAMETER OutputPath Writes a JSON summary to this path. The client secret is NOT written to it. @@ -237,10 +224,7 @@ $script:MicrosoftGraphAppId = '00000003-0000-0000-c000-000000000000' function Test-HasProperty { param($Object, [Parameter(Mandatory)][string] $Name) if ($null -eq $Object) { return $false } -<<<<<<< Updated upstream -======= if ($Object -is [System.Collections.IDictionary]) { return $Object.Contains($Name) } ->>>>>>> Stashed changes $properties = $Object.PSObject.Properties if ($null -eq $properties) { return $false } foreach ($property in $properties) { @@ -249,8 +233,6 @@ function Test-HasProperty { return $false } -<<<<<<< Updated upstream -======= function Merge-GraphRequiredResourceAccess { param( [object[]] $ExistingAccess = @(), @@ -321,7 +303,6 @@ function Merge-GraphRequiredResourceAccess { } } ->>>>>>> Stashed changes function Get-GraphErrorInfo { param($ErrorRecord) @@ -1208,44 +1189,6 @@ else { $applicationObjectId = [string]$application.id $applicationAppId = [string]$application.appId -<<<<<<< Updated upstream -# Request the delegated scopes on the app object so an admin can consent to them in one action. -if ($delegatedToRequest.Count -gt 0) { - $current = Invoke-Graph -Method GET -Uri "/applications/$applicationObjectId`?`$select=requiredResourceAccess" - $existingAccess = @() - if (Test-HasProperty $current 'requiredResourceAccess') { $existingAccess = @($current.requiredResourceAccess) } - - $graphEntry = $existingAccess | Where-Object { $_.resourceAppId -eq $script:MicrosoftGraphAppId } | Select-Object -First 1 - $existingIds = @() - if ($graphEntry -and (Test-HasProperty $graphEntry 'resourceAccess')) { - $existingIds = @($graphEntry.resourceAccess | ForEach-Object { [string]$_.id }) - } - - $toAdd = @($delegatedToRequest | Where-Object { $existingIds -notcontains $_.Id }) - if ($toAdd.Count -gt 0) { - $resourceAccess = @() - if ($graphEntry -and (Test-HasProperty $graphEntry 'resourceAccess')) { - foreach ($ra in @($graphEntry.resourceAccess)) { - $resourceAccess += @{ id = [string]$ra.id; type = [string]$ra.type } - } - } - foreach ($scope in $toAdd) { $resourceAccess += @{ id = $scope.Id; type = 'Scope' } } - - $others = @($existingAccess | Where-Object { $_.resourceAppId -ne $script:MicrosoftGraphAppId } | ForEach-Object { - @{ resourceAppId = [string]$_.resourceAppId - resourceAccess = @($_.resourceAccess | ForEach-Object { @{ id = [string]$_.id; type = [string]$_.type } }) } - }) - $payload = @{ requiredResourceAccess = @($others + @{ resourceAppId = $script:MicrosoftGraphAppId; resourceAccess = $resourceAccess }) } - - if ($PSCmdlet.ShouldProcess($DisplayName, "PATCH requiredResourceAccess (+$($toAdd.Count) delegated scope(s))")) { - Invoke-Graph -Method PATCH -Uri "/applications/$applicationObjectId" -Body $payload | Out-Null - Write-Host " Requested delegated scope(s): $(($toAdd | ForEach-Object { $_.Name }) -join ', ')" -ForegroundColor Green - } - } - else { - Write-Host ' Delegated scopes already requested on the application.' -ForegroundColor Gray - } -======= # Declare permissions even with -SkipGrant so an administrator can consent from the portal. $current = Invoke-Graph -Method GET -Uri "/applications/$applicationObjectId`?`$select=requiredResourceAccess" $existingAccess = @() @@ -1270,7 +1213,6 @@ if ($permissionMerge.Changed) { } else { Write-Host ' Application permissions and delegated scopes already declared on the application.' -ForegroundColor Gray ->>>>>>> Stashed changes } # --------------------------------------------------------------------------- @@ -1414,11 +1356,7 @@ $alreadyHeld = @() $failedGrant = @() if ($SkipGrant) { -<<<<<<< Updated upstream - Write-Host ' Skipped by -SkipGrant.' -ForegroundColor Yellow -======= Write-Host ' Skipped by -SkipGrant. The selected permissions are declared for portal consent.' -ForegroundColor Yellow ->>>>>>> Stashed changes } else { $assigned = Invoke-Graph -Method GET ` @@ -1545,12 +1483,6 @@ if ($plainSecret) { } } -<<<<<<< Updated upstream -if ($delegatedToRequest.Count -gt 0) { - Write-Host '' - Write-Host ' Delegated scopes were REQUESTED but still need admin consent. Grant them here:' -ForegroundColor Yellow - Write-Host " https://login.microsoftonline.com/$($ctx.TenantId)/adminconsent?client_id=$applicationAppId" -ForegroundColor Cyan -======= $portalPermissionsUrl = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Permissions/objectId/$servicePrincipalId/appId/$applicationAppId" if ($SkipGrant -or $delegatedToRequest.Count -gt 0 -or $failedGrant.Count -gt 0) { @@ -1573,21 +1505,12 @@ if ($SkipGrant -or $delegatedToRequest.Count -gt 0 -or $failedGrant.Count -gt 0) Write-Host " Portal : Enterprise applications > $DisplayName > Security > Permissions > Grant admin consent" -ForegroundColor Cyan Write-Host " $portalPermissionsUrl" -ForegroundColor Cyan Write-Host " Link : https://login.microsoftonline.com/$($ctx.TenantId)/adminconsent?client_id=$applicationAppId" -ForegroundColor Cyan ->>>>>>> Stashed changes } # The custom security attribute permissions are the one pair on this app that a directory role can # still block. Saying so here turns a guaranteed later 403 into something actionable, because the # failure names no permission when it happens. if ($Scenario -in 'AgentIdentity', 'All') { -<<<<<<< Updated upstream - $csaGranted = @($resolved | Where-Object { $_.Name -like 'CustomSecAttribute*' }) - if ($csaGranted.Count -gt 0) { - Write-Host '' - Write-Host ' Custom security attributes:' -ForegroundColor Cyan - Write-Host (' Application roles granted: {0}' -f (($csaGranted | ForEach-Object { $_.Name }) -join ', ')) -ForegroundColor Gray - Write-Host ' That is all an UNATTENDED (app-only) run needs.' -ForegroundColor Gray -======= $csaRoles = @($resolved | Where-Object { $_.Name -like 'CustomSecAttribute*' }) if ($csaRoles.Count -gt 0) { Write-Host '' @@ -1598,7 +1521,6 @@ if ($Scenario -in 'AgentIdentity', 'All') { if (-not $SkipGrant -and -not $csaGrantFailed) { Write-Host ' That is all an UNATTENDED (app-only) run needs.' -ForegroundColor Gray } ->>>>>>> Stashed changes Write-Host ' An INTERACTIVE run needs more: the signed-in user must also hold the' -ForegroundColor Yellow Write-Host ' Attribute Assignment Administrator directory role. No application permission' -ForegroundColor Yellow Write-Host ' grants it, and Global Administrator does NOT include it.' -ForegroundColor Yellow @@ -1628,27 +1550,19 @@ $summary = [ordered]@{ applicationObjectId = $applicationObjectId servicePrincipalId = $servicePrincipalId scenario = $Scenario -<<<<<<< Updated upstream -======= grantSkipped = [bool]$SkipGrant appRolesDeclared = @($resolved | ForEach-Object { $_.Name }) appRolesAddedToRequest = @($permissionMerge.RolesAdded | ForEach-Object { $_.Name }) ->>>>>>> Stashed changes appRolesGranted = @($grantedNow) appRolesAlreadyHeld = @($alreadyHeld) appRolesVerified = @($verifiedNames) appRolesUnresolved = @($missing) -<<<<<<< Updated upstream - delegatedScopesRequested = @($delegatedToRequest | ForEach-Object { $_.Name }) - adminConsentUrl = "https://login.microsoftonline.com/$($ctx.TenantId)/adminconsent?client_id=$applicationAppId" -======= delegatedScopesDeclared = @($delegatedToRequest | ForEach-Object { $_.Name }) delegatedScopesRequested = @($delegatedToRequest | ForEach-Object { $_.Name }) delegatedScopesAddedToRequest = @($permissionMerge.ScopesAdded | ForEach-Object { $_.Name }) consentFailures = @($failedGrant) adminConsentUrl = "https://login.microsoftonline.com/$($ctx.TenantId)/adminconsent?client_id=$applicationAppId" portalPermissionsUrl = $portalPermissionsUrl ->>>>>>> Stashed changes keyVault = $script:KeyVaultResult generatedUtc = [DateTimeOffset]::UtcNow.ToString('o') } From 78efcfbb489f22fed4a2300d5baa3423834eaee4 Mon Sep 17 00:00:00 2001 From: Walter Luna Date: Thu, 24 Sep 2026 15:32:20 +0100 Subject: [PATCH 03/10] implement copilot suggestions --- .../New-A365AutomationApp.ps1 | 132 +++- scripts/bulk-agent-registration/readme.md | 20 +- .../tests/PermissionDeclaration.Tests.ps1 | 575 +++++++++++++++++- 3 files changed, 691 insertions(+), 36 deletions(-) diff --git a/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 b/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 index 645e6f2b..f7aca5fe 100644 --- a/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 +++ b/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 @@ -97,10 +97,13 @@ .PARAMETER SkipGrant Declare the selected application permissions and delegated scopes, but do not grant consent. - An administrator can finish from the Entra portal without adding permissions manually. + After the declarations are applied, an administrator can finish from the Entra portal + without adding permissions manually. Existing duplicate Graph declarations are reconciled. .PARAMETER OutputPath Writes a JSON summary to this path. The client secret is NOT written to it. + Declared permissions reflect existing declarations or a successful PATCH. Planned additions + and permissionDeclarationStatus are reported separately; WhatIf does not write the file. .PARAMETER ClientId Application (client) ID to authenticate as, or the client app id for -Interactive. @@ -243,6 +246,8 @@ function Merge-GraphRequiredResourceAccess { $graphAccess = [System.Collections.Generic.List[object]]::new() $otherResources = [System.Collections.Generic.List[object]]::new() + $existingKeys = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $duplicatesRemoved = 0 foreach ($entry in @($ExistingAccess)) { if (-not (Test-HasProperty $entry 'resourceAppId')) { continue } @@ -255,7 +260,14 @@ function Merge-GraphRequiredResourceAccess { } if ([string]$entry.resourceAppId -eq $ResourceAppId) { - foreach ($access in $resourceAccess) { $graphAccess.Add($access) } + foreach ($access in $resourceAccess) { + if ($existingKeys.Add(('{0}|{1}' -f $access.type, $access.id))) { + $graphAccess.Add($access) + } + else { + $duplicatesRemoved++ + } + } } else { $otherResources.Add(@{ @@ -265,11 +277,6 @@ function Merge-GraphRequiredResourceAccess { } } - $existingKeys = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) - foreach ($access in $graphAccess) { - [void]$existingKeys.Add(('{0}|{1}' -f $access.type, $access.id)) - } - $rolesAdded = [System.Collections.Generic.List[object]]::new() foreach ($role in @($ApplicationRoles)) { if ($existingKeys.Add(('Role|{0}' -f $role.Id))) { @@ -296,13 +303,25 @@ function Merge-GraphRequiredResourceAccess { } return [pscustomobject]@{ - Changed = ($rolesAdded.Count -gt 0 -or $scopesAdded.Count -gt 0) + Changed = ($duplicatesRemoved -gt 0 -or $rolesAdded.Count -gt 0 -or $scopesAdded.Count -gt 0) + DuplicatesRemoved = $duplicatesRemoved RolesAdded = @($rolesAdded) ScopesAdded = @($scopesAdded) RequiredResourceAccess = @($requiredResourceAccess) } } +function Test-PermissionDeclarationApproval { + param( + [Parameter(Mandatory)][System.Management.Automation.PSCmdlet] $Command, + [Parameter(Mandatory)][string] $Target, + [Parameter(Mandatory)][string] $Action + ) + + # Use the caller's approval state without introducing another confirmation prompt. + return $Command.ShouldProcess($Target, $Action) +} + function Get-GraphErrorInfo { param($ErrorRecord) @@ -1198,23 +1217,54 @@ $permissionMerge = Merge-GraphRequiredResourceAccess -ExistingAccess $existingAc -ResourceAppId $script:MicrosoftGraphAppId -ApplicationRoles $resolved ` -DelegatedScopes $delegatedToRequest +$effectiveAccess = $existingAccess +$rolesAddedToRequest = @() +$scopesAddedToRequest = @() +$permissionDeclarationStatus = 'Unchanged' + if ($permissionMerge.Changed) { $payload = @{ requiredResourceAccess = $permissionMerge.RequiredResourceAccess } - $changeDescription = "+$($permissionMerge.RolesAdded.Count) application permission(s), +$($permissionMerge.ScopesAdded.Count) delegated scope(s)" - if ($PSCmdlet.ShouldProcess($DisplayName, "PATCH requiredResourceAccess ($changeDescription)")) { + $changeDescription = "+$($permissionMerge.RolesAdded.Count) application permission(s), +$($permissionMerge.ScopesAdded.Count) delegated scope(s), remove $($permissionMerge.DuplicatesRemoved) duplicate declaration(s)" + if (Test-PermissionDeclarationApproval -Command $PSCmdlet -Target $DisplayName ` + -Action "PATCH requiredResourceAccess ($changeDescription)") { Invoke-Graph -Method PATCH -Uri "/applications/$applicationObjectId" -Body $payload | Out-Null - if ($permissionMerge.RolesAdded.Count -gt 0) { - Write-Host " Declared application permission(s): $(($permissionMerge.RolesAdded | ForEach-Object { $_.Name }) -join ', ')" -ForegroundColor Green + $effectiveAccess = $permissionMerge.RequiredResourceAccess + $rolesAddedToRequest = $permissionMerge.RolesAdded + $scopesAddedToRequest = $permissionMerge.ScopesAdded + $permissionDeclarationStatus = 'Applied' + if ($rolesAddedToRequest.Count -gt 0) { + Write-Host " Declared application permission(s): $(($rolesAddedToRequest | ForEach-Object { $_.Name }) -join ', ')" -ForegroundColor Green + } + if ($scopesAddedToRequest.Count -gt 0) { + Write-Host " Requested delegated scope(s): $(($scopesAddedToRequest | ForEach-Object { $_.Name }) -join ', ')" -ForegroundColor Green } - if ($permissionMerge.ScopesAdded.Count -gt 0) { - Write-Host " Requested delegated scope(s): $(($permissionMerge.ScopesAdded | ForEach-Object { $_.Name }) -join ', ')" -ForegroundColor Green + if ($permissionMerge.DuplicatesRemoved -gt 0) { + Write-Host " Removed $($permissionMerge.DuplicatesRemoved) duplicate Graph permission declaration(s)." -ForegroundColor Green } } + else { + $permissionDeclarationStatus = if ($WhatIfPreference) { 'WhatIf' } else { 'Declined' } + Write-Host " Permission declaration update not applied ($permissionDeclarationStatus); existing declarations are unchanged." -ForegroundColor Yellow + } } else { Write-Host ' Application permissions and delegated scopes already declared on the application.' -ForegroundColor Gray } +# Only the read or a successful PATCH establishes which selected permissions are declared. +$effectiveKeys = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) +foreach ($entry in $effectiveAccess) { + if ((Test-HasProperty $entry 'resourceAppId') -and $entry.resourceAppId -eq $script:MicrosoftGraphAppId -and + (Test-HasProperty $entry 'resourceAccess')) { + foreach ($access in @($entry.resourceAccess)) { + [void]$effectiveKeys.Add(('{0}|{1}' -f $access.type, $access.id)) + } + } +} +$declaredRoles = @($resolved | Where-Object { $effectiveKeys.Contains(('Role|{0}' -f $_.Id)) }) +$declaredScopes = @($delegatedToRequest | Where-Object { $effectiveKeys.Contains(('Scope|{0}' -f $_.Id)) }) +$allSelectedPermissionsDeclared = $declaredRoles.Count -eq $resolved.Count -and $declaredScopes.Count -eq $delegatedToRequest.Count + # --------------------------------------------------------------------------- # Step 4 - create or reuse the service principal # --------------------------------------------------------------------------- @@ -1356,7 +1406,8 @@ $alreadyHeld = @() $failedGrant = @() if ($SkipGrant) { - Write-Host ' Skipped by -SkipGrant. The selected permissions are declared for portal consent.' -ForegroundColor Yellow + Write-Host ' Consent changes skipped by -SkipGrant.' -ForegroundColor Yellow + Write-Host " Currently declared: $($declaredRoles.Count)/$($resolved.Count) selected application permission(s), $($declaredScopes.Count)/$($delegatedToRequest.Count) selected delegated scope(s)." -ForegroundColor Gray } else { $assigned = Invoke-Graph -Method GET ` @@ -1437,6 +1488,7 @@ Write-Host " Object ID : $applicationObjectId" -ForegroundColor Gra Write-Host " Service principal : $servicePrincipalId" -ForegroundColor Gray Write-Host " Granted now : $($grantedNow.Count)" -ForegroundColor Green Write-Host " Already held : $($alreadyHeld.Count)" -ForegroundColor Gray +Write-Host " Permission declarations : $permissionDeclarationStatus" -ForegroundColor Gray if ($failedGrant.Count -gt 0) { Write-Host " Failed : $($failedGrant.Count)" -ForegroundColor Red } @@ -1493,14 +1545,22 @@ if ($SkipGrant -or $delegatedToRequest.Count -gt 0 -or $failedGrant.Count -gt 0) Write-Host " app role $($failure.Name) on Microsoft Graph" -ForegroundColor Yellow Write-Host " $($failure.Error)" -ForegroundColor DarkGray } - Write-Host ' The permissions are declared on the app but grant no claims until consent succeeds.' -ForegroundColor Yellow + } + if (-not $allSelectedPermissionsDeclared) { + Write-Host " Some selected permissions are not declared (declaration status: $permissionDeclarationStatus)." -ForegroundColor Yellow + Write-Host ' Re-run and approve the declaration update before consenting to the full selected set.' -ForegroundColor Yellow + Write-Host ' Portal consent currently covers only permissions already declared on the app:' -ForegroundColor Yellow } elseif ($SkipGrant) { - Write-Host ' Permissions were declared but not consented. An administrator can grant everything' -ForegroundColor Yellow + Write-Host ' The selected permissions are declared; this run did not change their consent.' -ForegroundColor Yellow + Write-Host ' If consent is still needed, an administrator can grant it' -ForegroundColor Yellow Write-Host ' from the portal without adding permissions manually:' -ForegroundColor Yellow } + elseif ($failedGrant.Count -gt 0) { + Write-Host ' The selected permissions are declared on the app but grant no claims until consent succeeds.' -ForegroundColor Yellow + } else { - Write-Host ' If the requested delegated scopes are not already consented, grant them here:' -ForegroundColor Yellow + Write-Host ' If the declared delegated scopes are not already consented, grant them here:' -ForegroundColor Yellow } Write-Host " Portal : Enterprise applications > $DisplayName > Security > Permissions > Grant admin consent" -ForegroundColor Cyan Write-Host " $portalPermissionsUrl" -ForegroundColor Cyan @@ -1515,10 +1575,20 @@ if ($Scenario -in 'AgentIdentity', 'All') { if ($csaRoles.Count -gt 0) { Write-Host '' Write-Host ' Custom security attributes:' -ForegroundColor Cyan - $csaGrantFailed = @($failedGrant | Where-Object { $_.Name -like 'CustomSecAttribute*' }).Count -gt 0 - $csaState = if ($SkipGrant -or $csaGrantFailed) { 'declared for admin consent' } else { 'granted' } - Write-Host (' Application roles {0}: {1}' -f $csaState, (($csaRoles | ForEach-Object { $_.Name }) -join ', ')) -ForegroundColor Gray - if (-not $SkipGrant -and -not $csaGrantFailed) { + $knownGrantedNames = @($grantedNow) + @($alreadyHeld) + @($verifiedNames) + $csaGranted = @($csaRoles | Where-Object { $knownGrantedNames -contains $_.Name }) + $csaDeclared = @($declaredRoles | Where-Object { $_.Name -like 'CustomSecAttribute*' -and $knownGrantedNames -notcontains $_.Name }) + $csaUndeclared = @($csaRoles | Where-Object { $knownGrantedNames -notcontains $_.Name -and -not $effectiveKeys.Contains(('Role|{0}' -f $_.Id)) }) + if ($csaGranted.Count -gt 0) { + Write-Host " Application roles granted: $(($csaGranted | ForEach-Object { $_.Name }) -join ', ')" -ForegroundColor Gray + } + if ($csaDeclared.Count -gt 0) { + Write-Host " Application roles declared; consent not confirmed by this run: $(($csaDeclared | ForEach-Object { $_.Name }) -join ', ')" -ForegroundColor Gray + } + if ($csaUndeclared.Count -gt 0) { + Write-Host " Application roles not declared: $(($csaUndeclared | ForEach-Object { $_.Name }) -join ', ')" -ForegroundColor Yellow + } + if ($csaGranted.Count -eq $csaRoles.Count) { Write-Host ' That is all an UNATTENDED (app-only) run needs.' -ForegroundColor Gray } Write-Host ' An INTERACTIVE run needs more: the signed-in user must also hold the' -ForegroundColor Yellow @@ -1551,15 +1621,18 @@ $summary = [ordered]@{ servicePrincipalId = $servicePrincipalId scenario = $Scenario grantSkipped = [bool]$SkipGrant - appRolesDeclared = @($resolved | ForEach-Object { $_.Name }) - appRolesAddedToRequest = @($permissionMerge.RolesAdded | ForEach-Object { $_.Name }) + permissionDeclarationStatus = $permissionDeclarationStatus + appRolesDeclared = @($declaredRoles | ForEach-Object { $_.Name }) + appRolesAddedToRequest = @($rolesAddedToRequest | ForEach-Object { $_.Name }) + appRolesPlannedToAdd = @($permissionMerge.RolesAdded | ForEach-Object { $_.Name }) appRolesGranted = @($grantedNow) appRolesAlreadyHeld = @($alreadyHeld) appRolesVerified = @($verifiedNames) appRolesUnresolved = @($missing) - delegatedScopesDeclared = @($delegatedToRequest | ForEach-Object { $_.Name }) + delegatedScopesDeclared = @($declaredScopes | ForEach-Object { $_.Name }) delegatedScopesRequested = @($delegatedToRequest | ForEach-Object { $_.Name }) - delegatedScopesAddedToRequest = @($permissionMerge.ScopesAdded | ForEach-Object { $_.Name }) + delegatedScopesAddedToRequest = @($scopesAddedToRequest | ForEach-Object { $_.Name }) + delegatedScopesPlannedToAdd = @($permissionMerge.ScopesAdded | ForEach-Object { $_.Name }) consentFailures = @($failedGrant) adminConsentUrl = "https://login.microsoftonline.com/$($ctx.TenantId)/adminconsent?client_id=$applicationAppId" portalPermissionsUrl = $portalPermissionsUrl @@ -1571,7 +1644,12 @@ if ($OutputPath) { # Deliberately excludes the secret. $summary | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $OutputPath -Encoding utf8 Write-Host '' - Write-Host " Summary written to $OutputPath (the client secret is NOT included)." -ForegroundColor Green + if ($WhatIfPreference) { + Write-Host ' Summary not written (-WhatIf).' -ForegroundColor Yellow + } + else { + Write-Host " Summary written to $OutputPath (the client secret is NOT included)." -ForegroundColor Green + } } Write-Host '' diff --git a/scripts/bulk-agent-registration/readme.md b/scripts/bulk-agent-registration/readme.md index dcb22349..7a049d5b 100644 --- a/scripts/bulk-agent-registration/readme.md +++ b/scripts/bulk-agent-registration/readme.md @@ -107,7 +107,9 @@ Unattended runs authenticate as an Entra application. `New-A365AutomationApp.ps1 -NewClientSecret ``` -> **Important: App roles are granted by default.** `New-A365AutomationApp.ps1` grants the app roles as part of the run — there is no `-GrantAdminConsent` switch. Use `-SkipGrant` to create the application and its credentials WITHOUT granting them. If the caller lacks the directory role needed to consent, the script reports what is outstanding and prints a consent link. +> **Important: App roles are granted by default.** `New-A365AutomationApp.ps1` grants the app roles as part of the run - there is no `-GrantAdminConsent` switch. `-SkipGrant` still declares the selected application permissions and delegated scopes, but leaves consent to an administrator using the Entra portal. The declarations must already exist or their update must be approved and succeed; a dry run or declined update does not prepare missing permissions for portal consent. + +Permission declarations are additive: existing unique Graph permissions and other APIs' permissions are preserved. Duplicate Graph entries with the same permission ID and type are consolidated, even when no new permissions are needed. This does not revoke existing consent grants. Scenarios are additive — run the script once per scenario, or use `All`: @@ -225,6 +227,22 @@ ACTION REQUIRED - an administrator must finish granting these permissions. The same information appears in the JSON report as `adminConsentUrl`, `portalPermissionsUrl` and `consentFailures`, and the orchestrator repeats it once at the end of a multi-phase run under `summary.consentActionRequired`. +### 4.2 Permission declaration reports and dry runs + +The automation-app summary distinguishes permissions present on the application from changes proposed for the current run: + +| Fields | Meaning | +| --- | --- | +| `permissionDeclarationStatus` | `Unchanged` when no update is needed, `Applied` after a successful declaration update, `WhatIf` when a needed update is only previewed, or `Declined` when confirmation is refused. | +| `appRolesDeclared`, `delegatedScopesDeclared` | Selected permissions already declared on the app or included in a successful update; declaration does not imply admin consent. | +| `appRolesAddedToRequest`, `delegatedScopesAddedToRequest` | Permissions added by a successful declaration update in this run; empty for dry runs, declined confirmation, or no change. | +| `appRolesPlannedToAdd`, `delegatedScopesPlannedToAdd` | Missing permissions proposed for this run, whether or not the update is approved. | +| `delegatedScopesRequested` | Requested delegated scopes, regardless of whether they have been declared or consented. | + +Planned lists describe the original proposal, not outstanding work after a successful update. A duplicate-only cleanup is `Applied` when it succeeds, but its added-permission lists remain empty. With `-WhatIf` or declined confirmation, existing declarations remain visible and proposed additions are not reported as applied. + +Portal consent can cover only permissions actually declared on the app. If selected permissions are still missing because an update was previewed or declined, rerun without `-WhatIf` and approve the update before sending the administrator the consent link. Authentication and read-only Graph lookups can still occur during a dry run; when the app or service principal does not exist, later steps remain skipped rather than reporting declarations for an object that was not created. + ## 5. Authentication Exactly one authentication method must be supplied. Supplying two is refused up front. diff --git a/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 b/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 index d2060bb0..611f5e93 100644 --- a/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 +++ b/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 @@ -46,11 +46,251 @@ function New-A365Permission { $script:AutomationAppPath = (Resolve-Path (Join-Path $PSScriptRoot '..' 'New-A365AutomationApp.ps1')).ProviderPath $functionSource = Get-A365ExtractedFunctionSource -Path $script:AutomationAppPath ` - -FunctionName @('Test-HasProperty', 'Merge-GraphRequiredResourceAccess') + -FunctionName @('Test-HasProperty', 'Merge-GraphRequiredResourceAccess', 'Test-PermissionDeclarationApproval') . ([scriptblock]::Create($functionSource)) +$script:NativeDeclarationApproval = (Get-Command Test-PermissionDeclarationApproval).ScriptBlock + +# Keep the real entry point and replace only the external I/O and approval boundaries in memory. +$tokens = $null +$parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($script:AutomationAppPath, [ref]$tokens, [ref]$parseErrors) +$testSource = $ast.Extent.Text +$stubbedFunctions = @('Connect-GraphSession', 'Invoke-Graph', 'Test-PermissionDeclarationApproval') +foreach ($definition in @($ast.EndBlock.Statements | Where-Object { + $_ -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $_.Name -in $stubbedFunctions +} | Sort-Object { $_.Extent.StartOffset } -Descending)) { + $testSource = $testSource.Remove($definition.Extent.StartOffset, $definition.Extent.EndOffset - $definition.Extent.StartOffset) +} +$script:AutomationAppUnderTest = [scriptblock]::Create($testSource) $graphAppId = '00000003-0000-0000-c000-000000000000' +function Assert-A365PermissionNames { + param([object[]] $Expected, $Actual) + Assert-NotNull $Actual 'Permission name arrays must not become null.' + Assert-Count $Actual $Expected.Count + Assert-Equal ($Expected -join '|') ($Actual -join '|') 'Permission names and order must match.' +} + +function Assert-A365Access { + param([object[]] $Expected, [object[]] $Actual) + Assert-Count $Actual $Expected.Count 'Resource entries must be preserved.' + for ($i = 0; $i -lt $Expected.Count; $i++) { + Assert-Equal $Expected[$i].resourceAppId $Actual[$i].resourceAppId + Assert-Count $Actual[$i].resourceAccess $Expected[$i].resourceAccess.Count + for ($j = 0; $j -lt $Expected[$i].resourceAccess.Count; $j++) { + Assert-True ($Expected[$i].resourceAccess[$j].id -ceq $Actual[$i].resourceAccess[$j].id) 'Keep the first id spelling and entry order.' + Assert-True ($Expected[$i].resourceAccess[$j].type -ceq $Actual[$i].resourceAccess[$j].type) 'Keep the first type spelling and entry order.' + } + } +} + +function New-A365DeclarationFixture { + $roleNames = @('CustomSecAttributeAssignment.ReadWrite.All', 'CustomSecAttributeDefinition.Read.All') + return [pscustomobject]@{ + RoleNames = $roleNames + ScopeNames = $roleNames + Graph = [pscustomobject]@{ + id = 'graph-sp' + appRoles = @( + @{ id = 'role-a'; value = $roleNames[0]; allowedMemberTypes = @('Application') } + @{ id = 'role-b'; value = $roleNames[1]; allowedMemberTypes = @('Application') } + ) + oauth2PermissionScopes = @( + @{ id = 'scope-a'; value = $roleNames[0] } + @{ id = 'scope-b'; value = $roleNames[1] } + ) + } + ExistingAccess = @( + @{ resourceAppId = $graphAppId; resourceAccess = @( + @{ id = 'ROLE-A'; type = 'role' } + @{ id = 'scope-a'; type = 'Scope' } + @{ id = 'unselected'; type = 'Role' } + ) } + @{ resourceAppId = 'other-api'; resourceAccess = @( + @{ id = 'role-b'; type = 'Role' } + @{ id = 'scope-b'; type = 'Scope' } + @{ id = 'scope-b'; type = 'Scope' } + ) } + ) + Calls = [System.Collections.Generic.List[object]]::new() + Approvals = [System.Collections.Generic.List[object]]::new() + Console = [System.Collections.Generic.List[string]]::new() + Reports = [System.Collections.Generic.List[string]]::new() + ReportAttempts = 0 + HeldIds = [System.Collections.Generic.List[string]]::new() + Decline = $false + PatchFails = $false + MissingApplication = $false + MissingPrincipal = $false + } +} + +function Invoke-A365DeclarationScenario { + param( + [Parameter(Mandatory)] $State, + [switch] $DryRun, + [bool] $SkipConsent = $true, + [switch] $WriteReport + ) + + function Connect-GraphSession { + param($TenantId, $DelegatedScope) + return [pscustomobject]@{ TenantId = $TenantId } + } + + function Invoke-Graph { + param($Method, $Uri, $Body, [switch] $TolerateNotFound, [switch] $TolerateConflict) + $State.Calls.Add([pscustomobject]@{ Method = $Method; Uri = $Uri; Body = $Body }) + if ($Method -eq 'GET') { + switch -Exact ($Uri) { + "/servicePrincipals(appId='$graphAppId')?`$select=id,appRoles,oauth2PermissionScopes" { return $State.Graph } + "/applications(appId='automation-app')?`$select=id,appId,displayName" { + return [pscustomobject]@{ id = 'app-object'; appId = 'automation-app'; displayName = 'Test automation' } + } + "/applications/app-object?`$select=requiredResourceAccess" { + return [pscustomobject]@{ requiredResourceAccess = $State.ExistingAccess } + } + "/servicePrincipals(appId='automation-app')?`$select=id,appId,displayName" { + if ($State.MissingPrincipal) { return $null } + return [pscustomobject]@{ id = 'app-sp'; appId = 'automation-app'; displayName = 'Test automation' } + } + "/servicePrincipals/app-sp/appRoleAssignments?`$select=appRoleId,resourceId&`$top=999" { + return @{ value = @($State.HeldIds | ForEach-Object { @{ appRoleId = $_; resourceId = 'graph-sp' } }) } + } + "/servicePrincipals/app-sp/appRoleAssignments?`$select=appRoleId&`$top=999" { + return @{ value = @($State.HeldIds | ForEach-Object { @{ appRoleId = $_ } }) } + } + } + if ($State.MissingApplication -and $Uri.StartsWith('/applications?$filter=')) { return @{ value = @() } } + } + if ($Method -eq 'PATCH' -and $Uri -eq '/applications/app-object') { + if ($State.PatchFails) { throw 'Mock declaration PATCH failed.' } + return $null + } + if ($Method -eq 'POST' -and $Uri -eq '/servicePrincipals/graph-sp/appRoleAssignedTo') { + $State.HeldIds.Add($Body.appRoleId) + return @{ id = 'mock-assignment' } + } + throw "Unexpected Graph call: $Method $Uri" + } + + function Test-PermissionDeclarationApproval { + param($Command, $Target, $Action) + $State.Approvals.Add(@{ Target = $Target; Action = $Action }) + if ($State.Decline) { return $false } + return & $script:NativeDeclarationApproval -Command $Command -Target $Target -Action $Action + } + + function Write-Host { + param($Object, $ForegroundColor) + $State.Console.Add([string]$Object) + } + + function Write-Warning { + param($Message) + $State.Console.Add([string]$Message) + } + + function Set-Content { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)] $Value, $LiteralPath, $Encoding) + process { + $State.ReportAttempts++ + if ($PSCmdlet.ShouldProcess($LiteralPath, 'Write test summary')) { + $State.Reports.Add([string]$Value) + } + } + } + + $arguments = @{ + TenantId = 'test-tenant' + AppId = 'automation-app' + DisplayName = 'Test automation' + Scenario = 'AgentIdentity' + SkipAppRole = @( + 'AgentIdentity.Create.All', 'AgentIdentity.Read.All', 'AgentIdentity.ReadWrite.All', + 'AgentIdentityBlueprint.Read.All', 'Application.Read.All', 'User.Read.All', + 'Group.Read.All', 'Application.ReadWrite.All', 'Directory.Read.All' + ) + SkipGrant = $SkipConsent + Confirm = $false + WhatIf = [bool]$DryRun + } + if ($State.MissingApplication) { $arguments.Remove('AppId') } + if ($WriteReport) { $arguments.OutputPath = 'mock-permission-summary.json' } + + $summary = & $script:AutomationAppUnderTest @arguments + return [pscustomobject]@{ + Summary = $summary + Json = ($summary | ConvertTo-Json -Depth 25) + State = $State + Console = ($State.Console -join "`n") + } +} + +function Assert-A365SummarySchema { + param([Parameter(Mandatory)] $Run) + $json = $Run.Json | ConvertFrom-Json -AsHashtable + foreach ($name in @( + 'appRolesDeclared', 'appRolesAddedToRequest', 'appRolesPlannedToAdd', 'appRolesGranted', + 'appRolesAlreadyHeld', 'appRolesVerified', 'appRolesUnresolved', 'delegatedScopesDeclared', + 'delegatedScopesRequested', 'delegatedScopesAddedToRequest', 'delegatedScopesPlannedToAdd', 'consentFailures' + )) { + Assert-True ($json[$name] -is [array]) "$name must serialize as a JSON array, including empty/singleton values." + } + Assert-True ($json.grantSkipped -is [bool]) 'grantSkipped must remain a JSON boolean.' + Assert-True ($json.permissionDeclarationStatus -is [string]) 'The declaration status must serialize as a string.' + Assert-A365PermissionNames $Run.State.ScopeNames $json.delegatedScopesRequested + Assert-Equal 'https://login.microsoftonline.com/test-tenant/adminconsent?client_id=automation-app' $json.adminConsentUrl + Assert-Equal 'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Permissions/objectId/app-sp/appId/automation-app' $json.portalPermissionsUrl + return $json +} + +function Assert-A365DeclarationPatch { + param($State, [object[]] $Expected) + $writes = @($State.Calls | Where-Object Method -ne 'GET') + Assert-Count $writes 1 'Declaration reconciliation must make exactly one write when consent is skipped.' + Assert-Equal 'PATCH' $writes[0].Method + Assert-Equal '/applications/app-object' $writes[0].Uri + $body = $writes[0].Body | ConvertTo-Json -Depth 25 | ConvertFrom-Json -AsHashtable + Assert-Count $body.Keys 1 + Assert-True ($body.Keys -ccontains 'requiredResourceAccess') 'PATCH must use the exact Graph property name.' + Assert-A365Access $Expected $body.requiredResourceAccess + foreach ($resource in $body.requiredResourceAccess) { + Assert-Count $resource.Keys 2 + Assert-True ($resource.Keys -ccontains 'resourceAppId') + Assert-True ($resource.Keys -ccontains 'resourceAccess') + foreach ($access in $resource.resourceAccess) { + Assert-Count $access.Keys 2 + Assert-True ($access.Keys -ccontains 'id') + Assert-True ($access.Keys -ccontains 'type') + } + } +} + +function Assert-A365UnappliedDeclarations { + param($Run, [string] $Status) + $json = Assert-A365SummarySchema $Run + Assert-Equal $Status $json.permissionDeclarationStatus + Assert-A365PermissionNames @($Run.State.RoleNames[0]) $json.appRolesDeclared + Assert-A365PermissionNames @($Run.State.ScopeNames[0]) $json.delegatedScopesDeclared + Assert-A365PermissionNames @($Run.State.RoleNames[1]) $json.appRolesPlannedToAdd + Assert-A365PermissionNames @($Run.State.ScopeNames[1]) $json.delegatedScopesPlannedToAdd + Assert-A365PermissionNames @() $json.appRolesAddedToRequest + Assert-A365PermissionNames @() $json.delegatedScopesAddedToRequest + Assert-Count (@($Run.State.Calls | Where-Object Method -ne 'GET')) 0 'An unapplied reconciliation must not write to Graph.' + Assert-Count $Run.State.Approvals 1 'A proposed change must have only one declaration approval boundary.' + Assert-True ($Run.Console -match "update not applied \($Status\)") + Assert-True ($Run.Console -match 'Some selected permissions are not declared') + Assert-True ($Run.Console -match 'Re-run and approve the declaration update') + Assert-True ($Run.Console -match 'Application roles not declared: CustomSecAttributeDefinition.Read.All') + Assert-True ($Run.Console -match 'Application roles declared; consent not confirmed by this run: CustomSecAttributeAssignment.ReadWrite.All') + Assert-False ($Run.Console -match 'Declared application permission\(s\):|Requested delegated scope\(s\):|Removed \d+ duplicate') + Assert-False ($Run.Console -match 'selected permissions are declared|Permissions were declared|without adding permissions manually') + Assert-False ($Run.Console -match 'Application roles granted:|all an UNATTENDED') 'Unapproved changes must not be described as granted.' +} + Test-Case 'Test-HasProperty supports Graph objects and dictionary-shaped merge output' { Assert-False (Test-HasProperty $null 'value') 'Null must not report any property.' Assert-True (Test-HasProperty ([pscustomobject]@{ value = 1 }) 'value') 'Graph response objects must expose their properties.' @@ -140,14 +380,333 @@ Test-Case 'Permission declaration distinguishes Role and Scope entries even when 'The Scope entry must be added even when a Role has the same id.' } -Test-Case '-SkipGrant declaration has a structural guard before the direct grant branch' { - $source = Get-Content -LiteralPath $script:AutomationAppPath -Raw - $declarationIndex = $source.IndexOf('$permissionMerge = Merge-GraphRequiredResourceAccess') - $skipGrantIndex = $source.IndexOf('if ($SkipGrant)', $declarationIndex) +Test-Case 'Existing Graph duplicates alone require cleanup, preserving first spelling and Role versus Scope' { + $existing = @( + @{ resourceAppId = $graphAppId; resourceAccess = @( + @{ id = 'Shared-ID'; type = 'Role' } + @{ id = 'shared-id'; type = 'role' } + @{ id = 'SHARED-ID'; type = 'Scope' } + @{ id = 'shared-id'; type = 'SCOPE' } + ) } + @{ resourceAppId = $graphAppId.ToUpperInvariant(); resourceAccess = @( + @{ id = 'SHARED-ID'; type = 'ROLE' } + @{ id = 'another'; type = 'Scope' } + ) } + ) + $before = ConvertTo-Json -InputObject $existing -Depth 25 + $result = Merge-GraphRequiredResourceAccess -ExistingAccess $existing -ResourceAppId $graphAppId + Assert-True $result.Changed 'Duplicate removal alone must trigger reconciliation.' + Assert-Equal 3 $result.DuplicatesRemoved + Assert-Count $result.RolesAdded 0 + Assert-Count $result.ScopesAdded 0 + Assert-A365Access @(@{ resourceAppId = $graphAppId; resourceAccess = @( + @{ id = 'Shared-ID'; type = 'Role' } + @{ id = 'SHARED-ID'; type = 'Scope' } + @{ id = 'another'; type = 'Scope' } + ) }) $result.RequiredResourceAccess + Assert-Equal $before (ConvertTo-Json -InputObject $existing -Depth 25) 'Cleanup must not mutate its input.' + + $second = Merge-GraphRequiredResourceAccess -ExistingAccess $result.RequiredResourceAccess -ResourceAppId $graphAppId + Assert-False $second.Changed + Assert-Equal 0 $second.DuplicatesRemoved + Assert-Count $second.RolesAdded 0 + Assert-Count $second.ScopesAdded 0 + Assert-A365Access $result.RequiredResourceAccess $second.RequiredResourceAccess +} + +Test-Case 'Existing duplicate cleanup and new requests share case-insensitive identity semantics' { + $existing = @(@{ resourceAppId = $graphAppId; resourceAccess = @( + @{ id = 'same'; type = 'Role' } + @{ id = 'SAME'; type = 'ROLE' } + @{ id = 'scope'; type = 'Scope' } + @{ id = 'SCOPE'; type = 'scope' } + ) }) + $roles = @( + (New-A365Permission 'Existing' 'SAME') + (New-A365Permission 'New' 'new') + (New-A365Permission 'NewAgain' 'NEW') + ) + $scopes = @( + (New-A365Permission 'ExistingScope' 'SCOPE') + (New-A365Permission 'SameIdDifferentType' 'same') + (New-A365Permission 'ScopeAgain' 'SAME') + ) + $before = ConvertTo-Json -InputObject @($existing, $roles, $scopes) -Depth 25 + $result = Merge-GraphRequiredResourceAccess -ExistingAccess $existing -ResourceAppId $graphAppId -ApplicationRoles $roles -DelegatedScopes $scopes + Assert-True $result.Changed + Assert-Equal 2 $result.DuplicatesRemoved 'Only duplicate existing declarations count as cleanup.' + Assert-A365PermissionNames @('New') @($result.RolesAdded.Name) + Assert-A365PermissionNames @('SameIdDifferentType') @($result.ScopesAdded.Name) + Assert-A365Access @(@{ resourceAppId = $graphAppId; resourceAccess = @( + @{ id = 'same'; type = 'Role' } + @{ id = 'scope'; type = 'Scope' } + @{ id = 'new'; type = 'Role' } + @{ id = 'same'; type = 'Scope' } + ) }) $result.RequiredResourceAccess + Assert-Equal $before (ConvertTo-Json -InputObject @($existing, $roles, $scopes) -Depth 25) +} + +Test-Case 'Cleanup preserves unique Graph permissions and non-Graph duplicates without sharing mutable declarations' { + $existing = @( + [pscustomobject]@{ resourceAppId = $graphAppId; resourceAccess = @( + [pscustomobject]@{ id = 'keep'; type = 'Role' } + [pscustomobject]@{ id = 'KEEP'; type = 'Role' } + [pscustomobject]@{ id = 'unselected'; type = 'Scope' } + ) } + @{ resourceAppId = 'other-api'; resourceAccess = @( + @{ id = 'duplicate'; type = 'Role' } + @{ id = 'duplicate'; type = 'Role' } + ) } + @{ resourceAppId = 'other-api'; resourceAccess = @(@{ id = 'duplicate'; type = 'Role' }) } + ) + $before = ConvertTo-Json -InputObject $existing -Depth 25 + $result = Merge-GraphRequiredResourceAccess -ExistingAccess $existing -ResourceAppId $graphAppId + Assert-Equal 1 $result.DuplicatesRemoved 'Non-Graph duplicates must not count as cleanup.' + Assert-A365Access @( + $existing[1] + $existing[2] + @{ resourceAppId = $graphAppId; resourceAccess = @($existing[0].resourceAccess[0], $existing[0].resourceAccess[2]) } + ) $result.RequiredResourceAccess + $result.RequiredResourceAccess[0].resourceAccess[0].id = 'changed-external' + $result.RequiredResourceAccess[1].resourceAppId = 'changed-resource' + $result.RequiredResourceAccess[2].resourceAccess[0].id = 'changed-graph' + Assert-Equal $before (ConvertTo-Json -InputObject $existing -Depth 25) 'Returned declarations must not alias input declarations.' +} + +Test-Case 'Accepted declaration update PATCHes the exact merged payload and serializes actual plus planned state' { + $state = New-A365DeclarationFixture + $state.ExistingAccess[0].resourceAccess += @{ id = 'role-a'; type = 'ROLE' } + $before = ConvertTo-Json -InputObject $state.ExistingAccess -Depth 25 + $run = Invoke-A365DeclarationScenario -State $state -WriteReport + $json = Assert-A365SummarySchema $run + Assert-A365DeclarationPatch $state @( + $state.ExistingAccess[1] + @{ resourceAppId = $graphAppId; resourceAccess = @( + @{ id = 'ROLE-A'; type = 'role' } + @{ id = 'scope-a'; type = 'Scope' } + @{ id = 'unselected'; type = 'Role' } + @{ id = 'role-b'; type = 'Role' } + @{ id = 'scope-b'; type = 'Scope' } + ) } + ) + Assert-Count $state.Approvals 1 + Assert-Equal 'Test automation' $state.Approvals[0].Target + Assert-Equal 'PATCH requiredResourceAccess (+1 application permission(s), +1 delegated scope(s), remove 1 duplicate declaration(s))' $state.Approvals[0].Action + Assert-Equal 'Applied' $json.permissionDeclarationStatus + Assert-A365PermissionNames $state.RoleNames $json.appRolesDeclared + Assert-A365PermissionNames $state.ScopeNames $json.delegatedScopesDeclared + Assert-A365PermissionNames @($state.RoleNames[1]) $json.appRolesAddedToRequest + Assert-A365PermissionNames @($state.ScopeNames[1]) $json.delegatedScopesAddedToRequest + Assert-A365PermissionNames @($state.RoleNames[1]) $json.appRolesPlannedToAdd + Assert-A365PermissionNames @($state.ScopeNames[1]) $json.delegatedScopesPlannedToAdd + Assert-True $json.grantSkipped + foreach ($field in 'appRolesGranted', 'appRolesAlreadyHeld', 'appRolesVerified', 'consentFailures') { + Assert-Count $json[$field] 0 'Declaring permissions must not be reported as granting consent.' + } + Assert-Count $state.Reports 1 + Assert-Equal $run.Json $state.Reports[0] 'The report must serialize the actual returned summary.' + Assert-Equal $before (ConvertTo-Json -InputObject $state.ExistingAccess -Depth 25) + Assert-True ($run.Console -match 'Removed 1 duplicate Graph') + Assert-True ($run.Console -match 'Currently declared: 2/2 selected application permission') + Assert-True ($run.Console -match 'without adding permissions manually') + Assert-False ($run.Console -match 'Application roles granted:|all an UNATTENDED') +} + +Test-Case 'Accepted duplicate-only cleanup is Applied and a cleaned second run performs no PATCH or approval' { + $state = New-A365DeclarationFixture + $state.ExistingAccess[0].resourceAccess += @( + @{ id = 'role-b'; type = 'Role' } + @{ id = 'scope-b'; type = 'Scope' } + @{ id = 'role-a'; type = 'Role' } + @{ id = 'SCOPE-B'; type = 'scope' } + ) + $run = Invoke-A365DeclarationScenario -State $state + $json = Assert-A365SummarySchema $run + $expected = @( + $state.ExistingAccess[1] + @{ resourceAppId = $graphAppId; resourceAccess = @($state.ExistingAccess[0].resourceAccess[0..4]) } + ) + Assert-A365DeclarationPatch $state $expected + Assert-Count $state.Approvals 1 + Assert-Equal 'PATCH requiredResourceAccess (+0 application permission(s), +0 delegated scope(s), remove 2 duplicate declaration(s))' $state.Approvals[0].Action + Assert-Equal 'Applied' $json.permissionDeclarationStatus + Assert-A365PermissionNames $state.RoleNames $json.appRolesDeclared + Assert-A365PermissionNames $state.ScopeNames $json.delegatedScopesDeclared + foreach ($field in 'appRolesAddedToRequest', 'delegatedScopesAddedToRequest', 'appRolesPlannedToAdd', 'delegatedScopesPlannedToAdd') { + Assert-Count $json[$field] 0 + } + Assert-True ($run.Console -match 'Removed 2 duplicate Graph') + Assert-False ($run.Console -match 'Declared application permission\(s\):|Requested delegated scope\(s\):') + + $next = New-A365DeclarationFixture + $next.ExistingAccess = ($state.Calls | Where-Object Method -eq 'PATCH').Body.requiredResourceAccess + $rerun = Invoke-A365DeclarationScenario -State $next + $rerunJson = Assert-A365SummarySchema $rerun + Assert-Equal 'Unchanged' $rerunJson.permissionDeclarationStatus + Assert-Count (@($next.Calls | Where-Object Method -ne 'GET')) 0 + Assert-Count $next.Approvals 0 + Assert-A365PermissionNames $next.RoleNames $rerunJson.appRolesDeclared + Assert-A365PermissionNames $next.ScopeNames $rerunJson.delegatedScopesDeclared +} + +Test-Case 'Native WhatIf keeps existing declarations and planned additions without PATCH or report-file write' { + $state = New-A365DeclarationFixture + $run = Invoke-A365DeclarationScenario -State $state -DryRun -WriteReport + Assert-A365UnappliedDeclarations $run 'WhatIf' + Assert-Equal 1 $state.ReportAttempts 'The normal report path must still respect native WhatIf.' + Assert-Count $state.Reports 0 'WhatIf must not be bypassed to write a report.' + Assert-True ($run.Console -match 'Summary not written \(-WhatIf\)') + Assert-False ($run.Console -match 'Summary written to') + Assert-True ($run.Console -match 'Currently declared: 1/2 selected application permission') +} - Assert-True ($declarationIndex -ge 0) 'The script must invoke the requiredResourceAccess merge.' - Assert-True ($skipGrantIndex -gt $declarationIndex) ` - 'Structural proxy: permission declaration must appear before -SkipGrant bypasses direct app-role assignment.' +Test-Case 'Declined confirmation serializes existing declarations and plans without claiming approval or consent' { + $state = New-A365DeclarationFixture + $state.Decline = $true + $run = Invoke-A365DeclarationScenario -State $state -WriteReport + Assert-A365UnappliedDeclarations $run 'Declined' + Assert-Count $state.Reports 1 + Assert-Equal $run.Json $state.Reports[0] +} + +Test-Case 'Skipped duplicate-only cleanup retains actual declarations but still reports the unapplied reconciliation' { + foreach ($status in 'WhatIf', 'Declined') { + $state = New-A365DeclarationFixture + $state.ExistingAccess[0].resourceAccess += @( + @{ id = 'role-b'; type = 'Role' } + @{ id = 'scope-b'; type = 'Scope' } + @{ id = 'ROLE-B'; type = 'role' } + ) + $state.Decline = $status -eq 'Declined' + $run = Invoke-A365DeclarationScenario -State $state -DryRun:($status -eq 'WhatIf') + $json = Assert-A365SummarySchema $run + Assert-Equal $status $json.permissionDeclarationStatus + Assert-A365PermissionNames $state.RoleNames $json.appRolesDeclared + Assert-A365PermissionNames $state.ScopeNames $json.delegatedScopesDeclared + foreach ($field in 'appRolesAddedToRequest', 'delegatedScopesAddedToRequest', 'appRolesPlannedToAdd', 'delegatedScopesPlannedToAdd') { + Assert-Count $json[$field] 0 + } + Assert-Count (@($state.Calls | Where-Object Method -ne 'GET')) 0 + Assert-Count $state.Approvals 1 + Assert-True ($state.Approvals[0].Action -match 'remove 1 duplicate declaration') + Assert-True ($run.Console -match "update not applied \($status\)") + Assert-True ($run.Console -match 'without adding permissions manually') 'Existing complete declarations are still ready for portal consent.' + Assert-False ($run.Console -match 'Removed 1 duplicate|Some selected permissions are not declared') + } +} + +Test-Case 'Wrong-type Graph entries and matching non-Graph ids cannot inflate actual declared permissions' { + $state = New-A365DeclarationFixture + $state.ExistingAccess[0].resourceAccess = @( + @{ id = 'role-a'; type = 'Scope' } + @{ id = 'role-b'; type = 'Scope' } + @{ id = 'scope-a'; type = 'Role' } + @{ id = 'scope-b'; type = 'Role' } + ) + $run = Invoke-A365DeclarationScenario -State $state -DryRun + $json = Assert-A365SummarySchema $run + Assert-Equal 'WhatIf' $json.permissionDeclarationStatus + Assert-A365PermissionNames @() $json.appRolesDeclared + Assert-A365PermissionNames @() $json.delegatedScopesDeclared + Assert-A365PermissionNames @() $json.appRolesAddedToRequest + Assert-A365PermissionNames @() $json.delegatedScopesAddedToRequest + Assert-A365PermissionNames $state.RoleNames $json.appRolesPlannedToAdd + Assert-A365PermissionNames $state.ScopeNames $json.delegatedScopesPlannedToAdd + Assert-Count (@($state.Calls | Where-Object Method -ne 'GET')) 0 + Assert-True ($run.Console -match 'Currently declared: 0/2 selected application permission') + Assert-False ($run.Console -match 'Application roles declared;|Application roles granted:|without adding permissions manually') +} + +Test-Case 'Unchanged declarations stay actual under WhatIf, with no approval or planned additions' { + $state = New-A365DeclarationFixture + $state.ExistingAccess[0].resourceAccess += @( + @{ id = 'role-b'; type = 'Role' } + @{ id = 'scope-b'; type = 'Scope' } + ) + $run = Invoke-A365DeclarationScenario -State $state -DryRun + $json = Assert-A365SummarySchema $run + Assert-Equal 'Unchanged' $json.permissionDeclarationStatus + Assert-Count $state.Approvals 0 + Assert-Count (@($state.Calls | Where-Object Method -ne 'GET')) 0 + Assert-A365PermissionNames $state.RoleNames $json.appRolesDeclared + Assert-A365PermissionNames $state.ScopeNames $json.delegatedScopesDeclared + foreach ($field in 'appRolesAddedToRequest', 'delegatedScopesAddedToRequest', 'appRolesPlannedToAdd', 'delegatedScopesPlannedToAdd') { + Assert-Count $json[$field] 0 + } + Assert-True ($run.Console -match 'already declared on the application') + Assert-False ($run.Console -match 'update not applied|Some selected permissions are not declared|Application roles granted:') +} + +Test-Case 'PATCH failure propagates with no summary, report, or declaration success messages' { + $state = New-A365DeclarationFixture + $state.PatchFails = $true + $outputs = [System.Collections.Generic.List[object]]::new() + Assert-Throws { + Invoke-A365DeclarationScenario -State $state -WriteReport | ForEach-Object { $outputs.Add($_) } + } 'Mock declaration PATCH failed' + Assert-Count (@($state.Calls | Where-Object Method -eq 'PATCH')) 1 + Assert-Count $state.Approvals 1 + Assert-Count $outputs 0 'A failed PATCH must not return a success-shaped result.' + Assert-Count $state.Reports 0 + Assert-Equal 0 $state.ReportAttempts + Assert-False (($state.Console -join "`n") -match 'Declared application permission\(s\):|Requested delegated scope\(s\):|Removed \d+ duplicate|Permission declarations : Applied|Done\.') +} + +Test-Case 'Native WhatIf without SkipGrant never labels custom security attribute roles as granted' { + $state = New-A365DeclarationFixture + $run = Invoke-A365DeclarationScenario -State $state -DryRun -SkipConsent:$false + Assert-A365UnappliedDeclarations $run 'WhatIf' + $json = $run.Json | ConvertFrom-Json -AsHashtable + Assert-False $json.grantSkipped 'grantSkipped continues to describe the switch, not WhatIf.' + Assert-Count $json.appRolesGranted 0 + Assert-Count $json.appRolesAlreadyHeld 0 + Assert-Count $json.appRolesVerified 0 +} + +Test-Case 'Successful grants retain their existing report semantics and custom security attribute guidance' { + $state = New-A365DeclarationFixture + $state.HeldIds.Add('role-a') + $run = Invoke-A365DeclarationScenario -State $state -SkipConsent:$false + $json = Assert-A365SummarySchema $run + Assert-False $json.grantSkipped + Assert-A365PermissionNames @($state.RoleNames[1]) $json.appRolesGranted + Assert-A365PermissionNames @($state.RoleNames[0]) $json.appRolesAlreadyHeld + Assert-A365PermissionNames $state.RoleNames $json.appRolesVerified + Assert-Count $json.consentFailures 0 + Assert-True ($run.Console -match 'Application roles granted:') + Assert-True ($run.Console -match 'all an UNATTENDED') + Assert-False ($run.Console -match 'Application roles not declared:|consent not confirmed by this run:') +} + +Test-Case 'Declining declarations does not change independent grant semantics or hide successful grants' { + $state = New-A365DeclarationFixture + $state.Decline = $true + $run = Invoke-A365DeclarationScenario -State $state -SkipConsent:$false + $json = Assert-A365SummarySchema $run + Assert-Equal 'Declined' $json.permissionDeclarationStatus + Assert-Count (@($state.Calls | Where-Object Method -eq 'PATCH')) 0 + Assert-Count (@($state.Calls | Where-Object Method -eq 'POST')) 2 + Assert-A365PermissionNames @($state.RoleNames[0]) $json.appRolesDeclared + Assert-A365PermissionNames @($state.RoleNames[1]) $json.appRolesPlannedToAdd + Assert-A365PermissionNames @() $json.appRolesAddedToRequest + Assert-A365PermissionNames $state.RoleNames $json.appRolesGranted + Assert-A365PermissionNames $state.RoleNames $json.appRolesVerified + Assert-True ($run.Console -match 'Application roles granted:') + Assert-True ($run.Console -match 'Some selected permissions are not declared') + Assert-False ($run.Console -match 'without adding permissions manually') +} + +Test-Case 'Native WhatIf preserves new application and service principal early returns without fabricating summaries' { + foreach ($missing in 'MissingApplication', 'MissingPrincipal') { + $state = New-A365DeclarationFixture + $state.$missing = $true + $run = Invoke-A365DeclarationScenario -State $state -DryRun -WriteReport + Assert-Null $run.Summary 'An uncreated app or principal must not produce a summary.' + Assert-Count (@($state.Calls | Where-Object Method -ne 'GET')) 0 + Assert-Equal 0 $state.ReportAttempts + Assert-Count $state.Reports 0 + Assert-True ($run.Console -match 'later steps are skipped') + } } Get-A365TestResults From d05cf87415f5612c7c9be9c68496f16bec713aaf Mon Sep 17 00:00:00 2001 From: Walter Luna Date: Thu, 1 Oct 2026 14:12:22 +0100 Subject: [PATCH 04/10] Retry automation app declaration after creation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 744dc687-79b1-4181-a626-57d46d06260f --- .../New-A365AutomationApp.ps1 | 8 ++- .../tests/PermissionDeclaration.Tests.ps1 | 52 ++++++++++++++++++- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 b/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 index f7aca5fe..c1c6fc76 100644 --- a/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 +++ b/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 @@ -1170,6 +1170,7 @@ if ($Scenario -in 'AgentIdentity', 'All') { Write-Step 3 'Creating the application registration' $application = $null +$applicationCreated = $false if ($AppId) { $application = Invoke-Graph -Method GET -Uri "/applications(appId='$AppId')?`$select=id,appId,displayName" -TolerateNotFound if (-not $application) { throw "No application found with appId '$AppId'." } @@ -1197,6 +1198,7 @@ else { } if ($PSCmdlet.ShouldProcess($DisplayName, 'POST /applications')) { $application = Invoke-Graph -Method POST -Uri '/applications' -Body $body + $applicationCreated = $true Write-Host " Created application '$DisplayName' (appId $($application.appId))" -ForegroundColor Green } else { @@ -1207,9 +1209,11 @@ else { $applicationObjectId = [string]$application.id $applicationAppId = [string]$application.appId +$replicationRetry = @{} +if ($applicationCreated) { $replicationRetry.RetryOnNotFound = $true } # Declare permissions even with -SkipGrant so an administrator can consent from the portal. -$current = Invoke-Graph -Method GET -Uri "/applications/$applicationObjectId`?`$select=requiredResourceAccess" +$current = Invoke-Graph -Method GET -Uri "/applications/$applicationObjectId`?`$select=requiredResourceAccess" @replicationRetry $existingAccess = @() if (Test-HasProperty $current 'requiredResourceAccess') { $existingAccess = @($current.requiredResourceAccess) } @@ -1227,7 +1231,7 @@ if ($permissionMerge.Changed) { $changeDescription = "+$($permissionMerge.RolesAdded.Count) application permission(s), +$($permissionMerge.ScopesAdded.Count) delegated scope(s), remove $($permissionMerge.DuplicatesRemoved) duplicate declaration(s)" if (Test-PermissionDeclarationApproval -Command $PSCmdlet -Target $DisplayName ` -Action "PATCH requiredResourceAccess ($changeDescription)") { - Invoke-Graph -Method PATCH -Uri "/applications/$applicationObjectId" -Body $payload | Out-Null + Invoke-Graph -Method PATCH -Uri "/applications/$applicationObjectId" -Body $payload @replicationRetry | Out-Null $effectiveAccess = $permissionMerge.RequiredResourceAccess $rolesAddedToRequest = $permissionMerge.RolesAdded $scopesAddedToRequest = $permissionMerge.ScopesAdded diff --git a/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 b/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 index 611f5e93..1c35fd2f 100644 --- a/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 +++ b/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 @@ -140,8 +140,20 @@ function Invoke-A365DeclarationScenario { } function Invoke-Graph { - param($Method, $Uri, $Body, [switch] $TolerateNotFound, [switch] $TolerateConflict) - $State.Calls.Add([pscustomobject]@{ Method = $Method; Uri = $Uri; Body = $Body }) + param( + $Method, + $Uri, + $Body, + [switch] $TolerateNotFound, + [switch] $TolerateConflict, + [switch] $RetryOnNotFound + ) + $State.Calls.Add([pscustomobject]@{ + Method = $Method + Uri = $Uri + Body = $Body + RetryOnNotFound = [bool]$RetryOnNotFound + }) if ($Method -eq 'GET') { switch -Exact ($Uri) { "/servicePrincipals(appId='$graphAppId')?`$select=id,appRoles,oauth2PermissionScopes" { return $State.Graph } @@ -164,6 +176,9 @@ function Invoke-A365DeclarationScenario { } if ($State.MissingApplication -and $Uri.StartsWith('/applications?$filter=')) { return @{ value = @() } } } + if ($Method -eq 'POST' -and $Uri -eq '/applications') { + return [pscustomobject]@{ id = 'app-object'; appId = 'automation-app'; displayName = 'Test automation' } + } if ($Method -eq 'PATCH' -and $Uri -eq '/applications/app-object') { if ($State.PatchFails) { throw 'Mock declaration PATCH failed.' } return $null @@ -696,6 +711,39 @@ Test-Case 'Declining declarations does not change independent grant semantics or Assert-False ($run.Console -match 'without adding permissions manually') } +Test-Case 'New application declaration reads and writes retry directory replication 404 responses' { + $state = New-A365DeclarationFixture + $state.MissingApplication = $true + Invoke-A365DeclarationScenario -State $state | Out-Null + + $declarationRead = @($state.Calls | Where-Object { + $_.Method -eq 'GET' -and + $_.Uri -eq '/applications/app-object?$select=requiredResourceAccess' + }) + Assert-Count $declarationRead 1 + Assert-True $declarationRead[0].RetryOnNotFound 'The immediate post-create declaration read must tolerate replication lag.' + + $declarationWrites = @($state.Calls | Where-Object { + $_.Method -eq 'PATCH' -and $_.Uri -eq '/applications/app-object' + }) + Assert-Count $declarationWrites 1 + Assert-True $declarationWrites[0].RetryOnNotFound 'The immediate post-create declaration write must tolerate replication lag.' +} + +Test-Case 'Existing application declaration reconciliation does not use creation-only retries' { + $state = New-A365DeclarationFixture + Invoke-A365DeclarationScenario -State $state | Out-Null + + $declarationCalls = @($state.Calls | Where-Object { + $_.Uri -eq '/applications/app-object?$select=requiredResourceAccess' -or + ($_.Method -eq 'PATCH' -and $_.Uri -eq '/applications/app-object') + }) + Assert-Count $declarationCalls 2 + foreach ($call in $declarationCalls) { + Assert-False $call.RetryOnNotFound 'Existing objects must preserve the normal not-found behavior.' + } +} + Test-Case 'Native WhatIf preserves new application and service principal early returns without fabricating summaries' { foreach ($missing in 'MissingApplication', 'MissingPrincipal') { $state = New-A365DeclarationFixture From e2cee256a449cee259d44df0b8eb2d54eda30df6 Mon Sep 17 00:00:00 2001 From: Walter Luna Date: Thu, 1 Oct 2026 14:21:50 +0100 Subject: [PATCH 05/10] Normalize bulk onboarding authentication Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 744dc687-79b1-4181-a626-57d46d06260f --- .../A365-AutomationOrchestrator.ps1 | 74 +++-- .../A365-BulkOnboarding.ps1 | 43 +-- .../New-A365AgentBlueprint.ps1 | 54 ++-- .../New-A365AgentIdentity.ps1 | 39 +-- .../New-A365AgentRegistration.ps1 | 84 ++---- .../New-A365AgentUser.ps1 | 252 ++++++++---------- .../New-A365AutomationApp.ps1 | 196 ++++++++------ .../Remove-A365AgentIdentity.ps1 | 109 +++++--- .../Remove-A365AgentRegistration.ps1 | 185 +++++-------- .../Remove-A365AgentUser.ps1 | 120 ++++++--- .../Remove-A365Blueprint.ps1 | 114 +++++--- .../Update-A365AgentIdentity.ps1 | 8 +- .../Update-A365AgentRegistration.ps1 | 8 +- .../Update-A365AgentUser.ps1 | 25 +- .../Update-A365Blueprint.ps1 | 8 +- 15 files changed, 649 insertions(+), 670 deletions(-) diff --git a/scripts/bulk-agent-registration/A365-AutomationOrchestrator.ps1 b/scripts/bulk-agent-registration/A365-AutomationOrchestrator.ps1 index 1035bb84..9d1054c7 100644 --- a/scripts/bulk-agent-registration/A365-AutomationOrchestrator.ps1 +++ b/scripts/bulk-agent-registration/A365-AutomationOrchestrator.ps1 @@ -245,8 +245,6 @@ -AgentUserPrincipalName is rejected here: the UPN cannot be changed after creation, and the account is identified by -UpdateAgentUser itself. - Like -NewAgentUser this requires app-only authentication. - Cannot be combined with -NewAgentUser. .PARAMETER UpdateAgentRegistration @@ -436,7 +434,7 @@ .PARAMETER KeyVaultAccessToken A bearer token for https://vault.azure.net, needed only when one cannot be derived from - the Graph credential - with -AccessToken, or -Interactive without a signed-in Azure + the Graph credential - for example with -Interactive without a signed-in Azure session. .PARAMETER BlueprintManagedIdentityPrincipalId Principal id of a managed identity to federate to the blueprint, so the agent can get @@ -519,9 +517,8 @@ forwarded to the step script as its -AgentIdentityId, the service principal OBJECT ID, and lands in the create call as identityParentId. - -NewAgentUser requires app-only authentication: New-A365AgentUser.ps1 is - client-credentials only and has no -Interactive mode, so the combination is rejected up - front rather than failing after the earlier phases have run. + New-A365AgentUser.ps1 supports both app-only credentials and interactive delegated + authentication. Delegated runs require the documented Graph scopes and directory roles. .PARAMETER AgentUserDisplayName Display name for the agent user. Optional: when omitted, New-A365AgentUser.ps1 defaults @@ -585,8 +582,8 @@ plaintext. Off by default: a report file is easy to commit, sync or forward by accident, and Graph only ever returns this value once. - The credentials used to AUTHENTICATE this run (-ClientSecret, -CertificatePassword, - -AccessToken) are redacted even with this switch. They are already known to whoever started + The credentials used to AUTHENTICATE this run (-ClientSecret, -CertificatePassword) are + redacted even with this switch. They are already known to whoever started the run, and they are usually far longer-lived than the secret being reported. On Windows the report file is created with an ACL granting only the current user when this @@ -617,9 +614,6 @@ .PARAMETER UseManagedIdentity Authenticate with the host's managed identity. -.PARAMETER AccessToken - A pre-acquired Graph access token, as a SecureString or a plain string. - .PARAMETER Interactive Sign in as a user. @@ -746,8 +740,7 @@ -AgentUserUsageLocation US -AgentUserAssignLicense ` -AgentUserLicenseSkuPartNumber Microsoft_365_Copilot - All four scenarios. -NewAgentUser needs app-only auth, because New-A365AgentUser.ps1 - is client-credentials only. + All four scenarios. Note there is no identity parameter here: the agent user is bound to the agent identity this run creates, which the pipeline forwards automatically. @@ -993,7 +986,6 @@ param( [string] $CertificatePath, [object] $CertificatePassword, [switch] $UseManagedIdentity, - [object] $AccessToken, [switch] $Interactive, [switch] $SkipPermissionCheck, @@ -2104,40 +2096,47 @@ if ($updAgentUser) { # unattended run cannot silently stall on a sign-in prompt. $authModes = @() if ($Interactive) { $authModes += 'Interactive' } -if ($AccessToken) { $authModes += 'AccessToken' } if ($UseManagedIdentity) { $authModes += 'ManagedIdentity' } if ($CertificateThumbprint -or $Certificate -or $CertificatePath) { $authModes += 'Certificate' } if ($ClientSecret -or $env:A365_CLIENT_SECRET) { $authModes += 'ClientSecret' } if ($authModes.Count -eq 0) { - throw 'No authentication method was specified. To run as an application pass -ClientId with -ClientSecret, -CertificateThumbprint, -Certificate or -CertificatePath (or use -UseManagedIdentity / -AccessToken). To sign in as a user pass -Interactive.' + throw 'No authentication method was specified. To run as an application pass -ClientId with -ClientSecret, -CertificateThumbprint, -Certificate or -CertificatePath (or use -UseManagedIdentity). To sign in as a user pass -Interactive.' } if ($authModes.Count -gt 1) { throw "Conflicting authentication options ($($authModes -join ', ')). Supply exactly one." } +$certificateSourceCount = @( + [bool]$CertificateThumbprint, + ($null -ne $Certificate), + [bool]$CertificatePath +).Where({ $_ }).Count +if ($certificateSourceCount -gt 1) { + throw 'Supply exactly one certificate source: -CertificateThumbprint, -Certificate, or -CertificatePath.' +} +if ($CertificatePassword -and (-not $CertificatePath)) { + throw '-CertificatePassword can be used only with -CertificatePath.' +} +if (($authModes[0] -in @('ClientSecret', 'Certificate')) -and [string]::IsNullOrWhiteSpace($ClientId)) { + throw "-ClientId is required for $($authModes[0]) authentication." +} +if ($UseManagedIdentity -and (-not [string]::IsNullOrWhiteSpace($ClientId))) { + throw '-UseManagedIdentity supports only the system-assigned managed identity; do not pass -ClientId.' +} +if ($Interactive -and ($phaseAgentUser -or $rmAgentUser) -and [string]::IsNullOrWhiteSpace($ClientId)) { + throw '-ClientId is required for interactive AgentUser operations because the caller-controlled public client must be authorized for the AgentUser preview scopes.' +} $authMode = $authModes[0] $isAppOnly = $authMode -in @('ClientSecret', 'Certificate', 'ManagedIdentity') -# New-A365AgentUser.ps1 is client-credentials only: it has no -Interactive and no -# -SkipPermissionCheck parameter. Reject the impossible combination now rather than letting -# phases 1-4 create objects and then failing on a parameter that does not exist. -if (($runAgentUser -or $updAgentUser) -and -not $isAppOnly) { - $auSwitch = if ($runAgentUser) { '-NewAgentUser' } else { '-UpdateAgentUser' } - throw "$auSwitch requires app-only authentication, but this run authenticates as '$authMode'. " + - 'New-A365AgentUser.ps1 supports client secret, certificate and managed identity only. ' + - 'Re-run with -ClientId plus -ClientSecret / -CertificateThumbprint / -UseManagedIdentity, ' + - 'and use -AgentRegistrationAuth Interactive if the registry step needs a signed-in user.' -} - # Shared auth splat, forwarded verbatim to each step. $authSplat = @{} foreach ($k in 'ClientId', 'ClientSecret', 'CertificateThumbprint', 'Certificate', 'CertificatePath', - 'CertificatePassword', 'UseManagedIdentity', 'AccessToken', 'Interactive', 'SkipPermissionCheck') { + 'CertificatePassword', 'UseManagedIdentity', 'Interactive', 'SkipPermissionCheck') { if ($PSBoundParameters.ContainsKey($k)) { $authSplat[$k] = $PSBoundParameters[$k] } } -# Step 4 authenticates separately when asked. The registry APIs read ownerIds/createdBy from -# /me, so an app-only token often cannot drive them at all. +# Step 4 can authenticate separately when a delegated registration phase is preferred. $registrationAuthSplat = $authSplat if ($AgentRegistrationAuth -eq 'Interactive') { $registrationAuthSplat = @{ Interactive = $true } @@ -2145,23 +2144,18 @@ if ($AgentRegistrationAuth -eq 'Interactive') { if ($SkipPermissionCheck) { $registrationAuthSplat.SkipPermissionCheck = $true } } -# Phase 3 takes the same credentials minus the two parameters its script does not declare. -# Splatting an undeclared parameter is a hard bind error, so filter rather than forward. +# Phase 3 supports the same authentication methods but has no permission-check bypass. $agentUserAuthSplat = @{} foreach ($k in $authSplat.Keys) { - if ($k -in @('Interactive', 'SkipPermissionCheck')) { continue } + if ($k -eq 'SkipPermissionCheck') { continue } $agentUserAuthSplat[$k] = $authSplat[$k] } -# The removal scripts declare a narrower auth surface again: no -Certificate, -CertificatePath, -# -CertificatePassword, -UseManagedIdentity or -SkipPermissionCheck. Forward only what they -# accept, for the same reason - an undeclared parameter is a hard bind error, and it would -# surface only once a destructive phase had already started. +# Removal scripts use the same credential surface but do not expose permission-check bypass. $removalAuthSplat = @{} foreach ($k in $authSplat.Keys) { - if ($k -in @('ClientId', 'ClientSecret', 'CertificateThumbprint', 'AccessToken', 'Interactive')) { - $removalAuthSplat[$k] = $authSplat[$k] - } + if ($k -eq 'SkipPermissionCheck') { continue } + $removalAuthSplat[$k] = $authSplat[$k] } # Every phase merges one of these splats into its argument hash, so adding the log settings diff --git a/scripts/bulk-agent-registration/A365-BulkOnboarding.ps1 b/scripts/bulk-agent-registration/A365-BulkOnboarding.ps1 index fb7ad1f2..0afe6583 100644 --- a/scripts/bulk-agent-registration/A365-BulkOnboarding.ps1 +++ b/scripts/bulk-agent-registration/A365-BulkOnboarding.ps1 @@ -140,9 +140,6 @@ Authenticate with the host's managed identity, forwarded verbatim to every row's orchestrator call. -.PARAMETER AccessToken - A pre-acquired Graph access token, forwarded verbatim to every row's orchestrator call. - .PARAMETER Interactive Sign in as a user, forwarded verbatim to every row's orchestrator call. @@ -151,7 +148,7 @@ call. Exactly one authentication method (-ClientSecret, -CertificateThumbprint, -Certificate, - -CertificatePath, -UseManagedIdentity, -AccessToken or -Interactive) must be supplied - + -CertificatePath, -UseManagedIdentity, or -Interactive) must be supplied - see A365-AutomationOrchestrator.ps1's own help for the full description of each. .PARAMETER LogPath @@ -208,7 +205,6 @@ param( [string] $CertificatePath, [object] $CertificatePassword, [switch] $UseManagedIdentity, - [object] $AccessToken, [switch] $Interactive, [switch] $SkipPermissionCheck, @@ -430,15 +426,13 @@ if (-not $shouldRun) { } # --------------------------------------------------------------------------- -# Real run. Exactly one authentication method is required up front - failing every row -# with the identical error the orchestrator would give is no more informative than failing -# once here, and this way nothing is attempted at all. Skipped for the test invoker, which -# supplies its own fake authentication surface. +# Real run. Validate that exactly one supported authentication method is selected before any +# row is attempted. Skipped for the test invoker, which supplies its own fake auth surface. # --------------------------------------------------------------------------- $authSplat = @{} foreach ($k in 'ClientId', 'ClientSecret', 'CertificateThumbprint', 'Certificate', 'CertificatePath', - 'CertificatePassword', 'UseManagedIdentity', 'AccessToken', 'Interactive', 'SkipPermissionCheck') { + 'CertificatePassword', 'UseManagedIdentity', 'Interactive', 'SkipPermissionCheck') { if ($PSBoundParameters.ContainsKey($k)) { $authSplat[$k] = $PSBoundParameters[$k] } } if (-not $OrchestratorInvoker) { @@ -449,22 +443,35 @@ if (-not $OrchestratorInvoker) { try { $authModes = @() if ($Interactive) { $authModes += 'Interactive' } - if ($AccessToken) { $authModes += 'AccessToken' } if ($UseManagedIdentity) { $authModes += 'ManagedIdentity' } if ($CertificateThumbprint -or $Certificate -or $CertificatePath) { $authModes += 'Certificate' } if ($ClientSecret -or $env:A365_CLIENT_SECRET) { $authModes += 'ClientSecret' } if ($authModes.Count -eq 0) { - throw 'No authentication method was specified. Pass -ClientId with -ClientSecret, -CertificateThumbprint, -Certificate or -CertificatePath (or use -UseManagedIdentity / -AccessToken), or pass -Interactive to sign in as a user.' + throw 'No authentication method was specified. Pass -ClientId with -ClientSecret, -CertificateThumbprint, -Certificate or -CertificatePath (or use -UseManagedIdentity), or pass -Interactive to sign in as a user.' } if ($authModes.Count -gt 1) { throw "Conflicting authentication options ($($authModes -join ', ')). Supply exactly one." } - # New-A365AgentUser.ps1 is client-credentials only (mirrors the orchestrator's own - # precondition). Every AgentUser row would fail identically, so refuse once, up front, - # instead of once per row. - $isAppOnly = $authModes[0] -in @('ClientSecret', 'Certificate', 'ManagedIdentity') - if (-not $isAppOnly -and @($plan.Nodes | Where-Object { $_.ObjectType -eq 'AgentUser' }).Count -gt 0) { - throw "The CSV has AgentUser row(s), which require app-only authentication, but this run authenticates as '$($authModes[0])'. Re-run with -ClientId plus -ClientSecret / -CertificateThumbprint / -UseManagedIdentity." + $certificateSourceCount = @( + [bool]$CertificateThumbprint, + ($null -ne $Certificate), + [bool]$CertificatePath + ).Where({ $_ }).Count + if ($certificateSourceCount -gt 1) { + throw 'Supply exactly one certificate source: -CertificateThumbprint, -Certificate, or -CertificatePath.' + } + if ($CertificatePassword -and (-not $CertificatePath)) { + throw '-CertificatePassword can be used only with -CertificatePath.' + } + if (($authModes[0] -in @('ClientSecret', 'Certificate')) -and [string]::IsNullOrWhiteSpace($ClientId)) { + throw "-ClientId is required for $($authModes[0]) authentication." + } + if ($UseManagedIdentity -and (-not [string]::IsNullOrWhiteSpace($ClientId))) { + throw '-UseManagedIdentity supports only the system-assigned managed identity; do not pass -ClientId.' + } + if ($Interactive -and [string]::IsNullOrWhiteSpace($ClientId) -and + @($plan.Nodes | Where-Object ObjectType -eq 'AgentUser').Count -gt 0) { + throw '-ClientId is required for interactive AgentUser onboarding because the caller-controlled public client must be authorized for the AgentUser preview scopes.' } } catch { diff --git a/scripts/bulk-agent-registration/New-A365AgentBlueprint.ps1 b/scripts/bulk-agent-registration/New-A365AgentBlueprint.ps1 index 988d5182..abc52d19 100644 --- a/scripts/bulk-agent-registration/New-A365AgentBlueprint.ps1 +++ b/scripts/bulk-agent-registration/New-A365AgentBlueprint.ps1 @@ -26,7 +26,7 @@ AUTHENTICATION The script is built to run unattended as an application. Pass -ClientId together with one of -ClientSecret, -CertificateThumbprint, -Certificate or -CertificatePath, or use - -UseManagedIdentity / -AccessToken. In that mode permissions come from Microsoft Graph + -UseManagedIdentity. In that mode permissions come from Microsoft Graph APPLICATION app roles granted to the app registration - delegated scopes are neither requested nor honoured - and the script verifies up front that every role it needs is actually granted. Use New-A365AutomationApp.ps1 to create that app registration. @@ -51,7 +51,7 @@ .PARAMETER ClientId Application (client) ID to authenticate as. Required for client secret and certificate auth, - optional for a user-assigned managed identity or a custom -Interactive app. + optional for a custom -Interactive app. .PARAMETER ClientSecret Client secret, as a SecureString or a plain string. May also be supplied through the A365_CLIENT_SECRET @@ -70,11 +70,7 @@ Password for -CertificatePath, as a SecureString or a plain string. .PARAMETER UseManagedIdentity - Authenticate with the host's managed identity. Add -ClientId for a user-assigned identity. - -.PARAMETER AccessToken - A Microsoft Graph access token, as a SecureString or a plain string, for callers that mint - tokens themselves. + Authenticate with the host's SYSTEM-assigned managed identity. .PARAMETER Interactive Sign in as a user with delegated scopes instead of running as an application. @@ -125,7 +121,8 @@ .PARAMETER ManagedIdentityPrincipalId Principal (object) ID of a user-assigned managed identity to register as a federated identity - credential. This is the recommended production credential. + credential on the created blueprint. This configures the onboarded entity; it does not + authenticate this script. .PARAMETER FederatedCredentialName Name of the federated identity credential created for -ManagedIdentityPrincipalId. Defaults @@ -196,8 +193,7 @@ .PARAMETER KeyVaultAccessToken A bearer token for https://vault.azure.net, for the cases where one cannot be derived - from the Graph credential: -AccessToken (a Graph token is audience-bound and cannot be - exchanged) and -Interactive without a signed-in Azure session. + from the Graph credential, such as -Interactive without a signed-in Azure session. .PARAMETER LogPath Write a timestamped log of this run. A path that names an existing directory (or ends in @@ -276,7 +272,6 @@ param( [string] $CertificatePath, [object] $CertificatePassword, [switch] $UseManagedIdentity, - [object] $AccessToken, [switch] $Interactive, [switch] $SkipPermissionCheck, @@ -1578,9 +1573,9 @@ function Get-KeyVaultToken { Obtains a token for the Key Vault data plane, using the SAME credential the caller gave for Graph wherever that is possible. - The one case that cannot work is -AccessToken: a Graph access token is issued for the - Graph audience and the vault rejects it outright, and there is no way to exchange one - for the other. That mode therefore requires -KeyVaultAccessToken. + Graph and Key Vault use different token audiences. Interactive Graph sign-in yields a + Graph-audience token only, so minting the vault token requires an Azure sign-in context + or an explicit -KeyVaultAccessToken. #> param( [Parameter(Mandatory)][string] $TenantId, @@ -1674,9 +1669,6 @@ function Get-KeyVaultToken { } throw 'Interactive Graph sign-in cannot mint a Key Vault token: Connect-MgGraph issues Graph-audience tokens only. Sign in to Azure first ("az login" or "Connect-AzAccount"), or pass -KeyVaultAccessToken.' } - 'AccessToken' { - throw '-AccessToken supplies a Microsoft Graph token, which the Key Vault data plane rejects (verified: HTTP 401). A token is audience-bound and cannot be exchanged. Pass -KeyVaultAccessToken with a token for https://vault.azure.net, or authenticate with -ClientSecret / -Certificate / -UseManagedIdentity so one can be obtained for you.' - } default { throw "Cannot obtain a Key Vault token for authentication mode '$AuthMode'. Pass -KeyVaultAccessToken." } @@ -1827,7 +1819,6 @@ function Connect-GraphSession { [string] $CertificatePath, [object] $CertificatePassword, [switch] $UseManagedIdentity, - [object] $AccessToken, [switch] $Interactive, [string[]] $DelegatedScope = @(), [string[]] $RequiredAppRole = @(), @@ -1852,7 +1843,6 @@ finally { # Accept plain strings as well as SecureStrings, and warn about the trade-off once. $secretWasPlainText = $ClientSecret -is [string] $ClientSecret = ConvertTo-SecureStringValue -Value $ClientSecret -Name 'ClientSecret' - $AccessToken = ConvertTo-SecureStringValue -Value $AccessToken -Name 'AccessToken' $CertificatePassword = ConvertTo-SecureStringValue -Value $CertificatePassword -Name 'CertificatePassword' # Keeps the secret out of command lines, shell history and transcripts. @@ -1864,19 +1854,30 @@ finally { Write-Warning 'A plain-text -ClientSecret was passed on the command line, where it is visible to shell history and transcripts. Prefer $env:A365_CLIENT_SECRET or a SecureString.' } + $certificateSourceCount = @( + [bool]$CertificateThumbprint, + ($null -ne $Certificate), + [bool]$CertificatePath + ).Where({ $_ }).Count + if ($certificateSourceCount -gt 1) { + throw 'Supply exactly one certificate source: -CertificateThumbprint, -Certificate, or -CertificatePath.' + } + if ($CertificatePassword -and (-not $CertificatePath)) { + throw '-CertificatePassword can be used only with -CertificatePath.' + } + $modes = @() if ($Interactive) { $modes += 'Interactive' } - if ($AccessToken) { $modes += 'AccessToken' } if ($UseManagedIdentity) { $modes += 'ManagedIdentity' } if ($CertificateThumbprint -or $Certificate -or $CertificatePath) { $modes += 'Certificate' } if ($ClientSecret) { $modes += 'ClientSecret' } if ($modes.Count -gt 1) { - throw "Conflicting authentication options ($($modes -join ', ')). Supply exactly one of -ClientSecret, -CertificateThumbprint/-Certificate/-CertificatePath, -UseManagedIdentity, -AccessToken or -Interactive." + throw "Conflicting authentication options ($($modes -join ', ')). Supply exactly one of -ClientSecret, -CertificateThumbprint/-Certificate/-CertificatePath, -UseManagedIdentity or -Interactive." } if ($modes.Count -eq 0) { $lead = if ($ClientId) { '-ClientId was supplied without a credential.' } else { 'No authentication method was specified.' } - throw "$lead To run as an application pass -ClientId with -ClientSecret, -CertificateThumbprint, -Certificate or -CertificatePath (or use -UseManagedIdentity / -AccessToken). To sign in as a user pass -Interactive." + throw "$lead To run as an application pass -ClientId with -ClientSecret, -CertificateThumbprint, -Certificate or -CertificatePath (or use -UseManagedIdentity). To sign in as a user pass -Interactive." } $mode = $modes[0] @@ -1914,10 +1915,9 @@ finally { } 'ManagedIdentity' { $connect.Identity = $true - if ($ClientId) { $connect.ClientId = $ClientId } # user-assigned identity - } - 'AccessToken' { - $connect.AccessToken = $AccessToken + if ($ClientId) { + throw '-UseManagedIdentity supports only the system-assigned managed identity; do not pass -ClientId.' + } } 'Interactive' { $connect.TenantId = $TenantId @@ -2058,7 +2058,7 @@ $ctx = Connect-GraphSession -TenantId $TenantId ` -ClientId $ClientId -ClientSecret $ClientSecret ` -CertificateThumbprint $CertificateThumbprint -Certificate $Certificate ` -CertificatePath $CertificatePath -CertificatePassword $CertificatePassword ` - -UseManagedIdentity:$UseManagedIdentity -AccessToken $AccessToken ` + -UseManagedIdentity:$UseManagedIdentity ` -Interactive:$Interactive -SkipPermissionCheck:$SkipPermissionCheck ` -DelegatedScope $delegatedScopes -RequiredAppRole $appRoles diff --git a/scripts/bulk-agent-registration/New-A365AgentIdentity.ps1 b/scripts/bulk-agent-registration/New-A365AgentIdentity.ps1 index 126f6bc2..bf11a75d 100644 --- a/scripts/bulk-agent-registration/New-A365AgentIdentity.ps1 +++ b/scripts/bulk-agent-registration/New-A365AgentIdentity.ps1 @@ -29,7 +29,7 @@ AUTHENTICATION The script is built to run unattended as an application. Pass -ClientId together with one of -ClientSecret, -CertificateThumbprint, -Certificate or -CertificatePath, or use - -UseManagedIdentity / -AccessToken. In that mode permissions come from Microsoft Graph + -UseManagedIdentity. In that mode permissions come from Microsoft Graph APPLICATION app roles granted to the app registration - delegated scopes are neither requested nor honoured - and the script verifies up front that every role it needs is actually granted. Use New-A365AutomationApp.ps1 to create that app registration. @@ -53,7 +53,7 @@ .PARAMETER ClientId Application (client) ID to authenticate as. Required for client secret and certificate auth, - optional for a user-assigned managed identity or a custom -Interactive app. + optional for a custom -Interactive app. .PARAMETER ClientSecret Client secret, as a SecureString or a plain string. May also be supplied through the A365_CLIENT_SECRET @@ -72,11 +72,7 @@ Password for -CertificatePath, as a SecureString or a plain string. .PARAMETER UseManagedIdentity - Authenticate with the host's managed identity. Add -ClientId for a user-assigned identity. - -.PARAMETER AccessToken - A Microsoft Graph access token, as a SecureString or a plain string, for callers that mint - tokens themselves. + Authenticate with the host's SYSTEM-assigned managed identity. .PARAMETER Interactive Sign in as a user with delegated scopes instead of running as an application. @@ -349,7 +345,6 @@ param( [string] $CertificatePath, [object] $CertificatePassword, [switch] $UseManagedIdentity, - [object] $AccessToken, [switch] $Interactive, [switch] $SkipPermissionCheck, @@ -1968,7 +1963,6 @@ function Connect-GraphSession { [string] $CertificatePath, [object] $CertificatePassword, [switch] $UseManagedIdentity, - [object] $AccessToken, [switch] $Interactive, [string[]] $DelegatedScope = @(), [string[]] $RequiredAppRole = @(), @@ -1993,7 +1987,6 @@ finally { # Accept plain strings as well as SecureStrings, and warn about the trade-off once. $secretWasPlainText = $ClientSecret -is [string] $ClientSecret = ConvertTo-SecureStringValue -Value $ClientSecret -Name 'ClientSecret' - $AccessToken = ConvertTo-SecureStringValue -Value $AccessToken -Name 'AccessToken' $CertificatePassword = ConvertTo-SecureStringValue -Value $CertificatePassword -Name 'CertificatePassword' # Keeps the secret out of command lines, shell history and transcripts. @@ -2005,19 +1998,30 @@ finally { Write-Warning 'A plain-text -ClientSecret was passed on the command line, where it is visible to shell history and transcripts. Prefer $env:A365_CLIENT_SECRET or a SecureString.' } + $certificateSourceCount = @( + [bool]$CertificateThumbprint, + ($null -ne $Certificate), + [bool]$CertificatePath + ).Where({ $_ }).Count + if ($certificateSourceCount -gt 1) { + throw 'Supply exactly one certificate source: -CertificateThumbprint, -Certificate, or -CertificatePath.' + } + if ($CertificatePassword -and (-not $CertificatePath)) { + throw '-CertificatePassword can be used only with -CertificatePath.' + } + $modes = @() if ($Interactive) { $modes += 'Interactive' } - if ($AccessToken) { $modes += 'AccessToken' } if ($UseManagedIdentity) { $modes += 'ManagedIdentity' } if ($CertificateThumbprint -or $Certificate -or $CertificatePath) { $modes += 'Certificate' } if ($ClientSecret) { $modes += 'ClientSecret' } if ($modes.Count -gt 1) { - throw "Conflicting authentication options ($($modes -join ', ')). Supply exactly one of -ClientSecret, -CertificateThumbprint/-Certificate/-CertificatePath, -UseManagedIdentity, -AccessToken or -Interactive." + throw "Conflicting authentication options ($($modes -join ', ')). Supply exactly one of -ClientSecret, -CertificateThumbprint/-Certificate/-CertificatePath, -UseManagedIdentity or -Interactive." } if ($modes.Count -eq 0) { $lead = if ($ClientId) { '-ClientId was supplied without a credential.' } else { 'No authentication method was specified.' } - throw "$lead To run as an application pass -ClientId with -ClientSecret, -CertificateThumbprint, -Certificate or -CertificatePath (or use -UseManagedIdentity / -AccessToken). To sign in as a user pass -Interactive." + throw "$lead To run as an application pass -ClientId with -ClientSecret, -CertificateThumbprint, -Certificate or -CertificatePath (or use -UseManagedIdentity). To sign in as a user pass -Interactive." } $mode = $modes[0] @@ -2055,10 +2059,9 @@ finally { } 'ManagedIdentity' { $connect.Identity = $true - if ($ClientId) { $connect.ClientId = $ClientId } # user-assigned identity - } - 'AccessToken' { - $connect.AccessToken = $AccessToken + if ($ClientId) { + throw '-UseManagedIdentity supports only the system-assigned managed identity; do not pass -ClientId.' + } } 'Interactive' { $connect.TenantId = $TenantId @@ -2219,7 +2222,7 @@ $ctx = Connect-GraphSession -TenantId $TenantId ` -ClientId $ClientId -ClientSecret $ClientSecret ` -CertificateThumbprint $CertificateThumbprint -Certificate $Certificate ` -CertificatePath $CertificatePath -CertificatePassword $CertificatePassword ` - -UseManagedIdentity:$UseManagedIdentity -AccessToken $AccessToken ` + -UseManagedIdentity:$UseManagedIdentity ` -Interactive:$Interactive -SkipPermissionCheck:$SkipPermissionCheck ` -DelegatedScope $delegatedScopes -RequiredAppRole $appRoles diff --git a/scripts/bulk-agent-registration/New-A365AgentRegistration.ps1 b/scripts/bulk-agent-registration/New-A365AgentRegistration.ps1 index 5dd1dd22..8af168b9 100644 --- a/scripts/bulk-agent-registration/New-A365AgentRegistration.ps1 +++ b/scripts/bulk-agent-registration/New-A365AgentRegistration.ps1 @@ -47,7 +47,7 @@ AUTHENTICATION Same model as New-A365AgentBlueprint.ps1 and New-A365AgentIdentity.ps1: pick exactly one of -ClientSecret, -CertificateThumbprint/-Certificate/-CertificatePath, -UseManagedIdentity, - -AccessToken or -Interactive. There is no implicit fallback, so an unattended run can never + or -Interactive. There is no implicit fallback, so an unattended run can never stall on a sign-in prompt. APPLICATION (APP-ONLY) PERMISSIONS ARE SUPPORTED. @@ -177,10 +177,7 @@ Password for -CertificatePath, as a SecureString or a plain string. .PARAMETER UseManagedIdentity - Authenticate with the host's managed identity. Pass -ClientId for a user-assigned identity. - -.PARAMETER AccessToken - A pre-acquired Graph access token, as a SecureString or a plain string. + Authenticate with the host's SYSTEM-assigned managed identity. .PARAMETER Interactive Sign in as a user. Pair with -ClientId for an app that has the preview scopes consented. @@ -330,7 +327,6 @@ param( [string] $CertificatePath, [object] $CertificatePassword, [switch] $UseManagedIdentity, - [object] $AccessToken, [switch] $Interactive, [switch] $SkipPermissionCheck, @@ -1095,47 +1091,6 @@ function ConvertTo-SecureStringValue { return $secure } -# Reads the appid (v1 tokens) or azp (v2 tokens) claim out of a JWT access token. Returns '' for -# anything that is not a readable JWT - this is a best-effort convenience, never a security check, -# and the signature is deliberately NOT validated because the token is one we already hold. -function Get-JwtAppId { - [OutputType([string])] - param([object] $Token) - - if ($null -eq $Token) { return '' } - - $raw = '' - if ($Token -is [securestring]) { - $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Token) - try { $raw = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } - finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } - } - else { $raw = [string]$Token } - - if ([string]::IsNullOrWhiteSpace($raw)) { return '' } - - $parts = $raw.Split('.') - if ($parts.Count -lt 2) { return '' } - - try { - $segment = $parts[1].Replace('-', '+').Replace('_', '/') - switch ($segment.Length % 4) { - 2 { $segment += '==' } - 3 { $segment += '=' } - 1 { return '' } - } - $claims = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($segment)) | ConvertFrom-Json - foreach ($name in 'appid', 'azp') { - if ((Test-HasProperty $claims $name) -and $claims.$name) { return [string]$claims.$name } - } - } - catch { - Write-Verbose "Could not decode the access token to read its appid claim: $($_.Exception.Message)" - } - - return '' -} - function Connect-GraphSession { [CmdletBinding()] [OutputType([pscustomobject])] @@ -1148,7 +1103,6 @@ function Connect-GraphSession { [string] $CertificatePath, [object] $CertificatePassword, [switch] $UseManagedIdentity, - [object] $AccessToken, [switch] $Interactive, [string[]] $DelegatedScope = @(), [string[]] $RequiredAppRole = @(), @@ -1173,7 +1127,6 @@ finally { # Accept plain strings as well as SecureStrings, and warn about the trade-off once. $secretWasPlainText = $ClientSecret -is [string] $ClientSecret = ConvertTo-SecureStringValue -Value $ClientSecret -Name 'ClientSecret' - $AccessToken = ConvertTo-SecureStringValue -Value $AccessToken -Name 'AccessToken' $CertificatePassword = ConvertTo-SecureStringValue -Value $CertificatePassword -Name 'CertificatePassword' # Keeps the secret out of command lines, shell history and transcripts. @@ -1185,19 +1138,30 @@ finally { Write-Warning 'A plain-text -ClientSecret was passed on the command line, where it is visible to shell history and transcripts. Prefer $env:A365_CLIENT_SECRET or a SecureString.' } + $certificateSourceCount = @( + [bool]$CertificateThumbprint, + ($null -ne $Certificate), + [bool]$CertificatePath + ).Where({ $_ }).Count + if ($certificateSourceCount -gt 1) { + throw 'Supply exactly one certificate source: -CertificateThumbprint, -Certificate, or -CertificatePath.' + } + if ($CertificatePassword -and (-not $CertificatePath)) { + throw '-CertificatePassword can be used only with -CertificatePath.' + } + $modes = @() if ($Interactive) { $modes += 'Interactive' } - if ($AccessToken) { $modes += 'AccessToken' } if ($UseManagedIdentity) { $modes += 'ManagedIdentity' } if ($CertificateThumbprint -or $Certificate -or $CertificatePath) { $modes += 'Certificate' } if ($ClientSecret) { $modes += 'ClientSecret' } if ($modes.Count -gt 1) { - throw "Conflicting authentication options ($($modes -join ', ')). Supply exactly one of -ClientSecret, -CertificateThumbprint/-Certificate/-CertificatePath, -UseManagedIdentity, -AccessToken or -Interactive." + throw "Conflicting authentication options ($($modes -join ', ')). Supply exactly one of -ClientSecret, -CertificateThumbprint/-Certificate/-CertificatePath, -UseManagedIdentity or -Interactive." } if ($modes.Count -eq 0) { $lead = if ($ClientId) { '-ClientId was supplied without a credential.' } else { 'No authentication method was specified.' } - throw "$lead To run as an application pass -ClientId with -ClientSecret, -CertificateThumbprint, -Certificate or -CertificatePath (or use -UseManagedIdentity / -AccessToken). To sign in as a user pass -Interactive." + throw "$lead To run as an application pass -ClientId with -ClientSecret, -CertificateThumbprint, -Certificate or -CertificatePath (or use -UseManagedIdentity). To sign in as a user pass -Interactive." } $mode = $modes[0] @@ -1235,10 +1199,9 @@ finally { } 'ManagedIdentity' { $connect.Identity = $true - if ($ClientId) { $connect.ClientId = $ClientId } # user-assigned identity - } - 'AccessToken' { - $connect.AccessToken = $AccessToken + if ($ClientId) { + throw '-UseManagedIdentity supports only the system-assigned managed identity; do not pass -ClientId.' + } } 'Interactive' { $connect.TenantId = $TenantId @@ -1265,13 +1228,8 @@ finally { } # The caller's own appId is the default for managedByAppId, so resolve it as reliably as - # possible. Get-MgContext reports it for every mode except a raw -AccessToken, where the token's - # own appid/azp claim is the only source. + # possible from the Graph context. $script:CallerAppId = $ctxClient - if ([string]::IsNullOrWhiteSpace($script:CallerAppId) -and $mode -eq 'AccessToken') { - $script:CallerAppId = Get-JwtAppId -Token $AccessToken - if ($script:CallerAppId) { $ctxClient = $script:CallerAppId } - } if ([string]::IsNullOrWhiteSpace($script:CallerAppId)) { Write-Warning 'Could not determine the calling application id; managedByAppId will be omitted unless -ManagedByAppId is supplied.' } @@ -1570,7 +1528,7 @@ $connectArgs = @{ } foreach ($name in 'ClientId', 'ClientSecret', 'CertificateThumbprint', 'Certificate', 'CertificatePath', 'CertificatePassword', 'UseManagedIdentity', - 'AccessToken', 'Interactive') { + 'Interactive') { if ($PSBoundParameters.ContainsKey($name)) { $connectArgs[$name] = $PSBoundParameters[$name] } } diff --git a/scripts/bulk-agent-registration/New-A365AgentUser.ps1 b/scripts/bulk-agent-registration/New-A365AgentUser.ps1 index 10d0b9dc..3dd9e19d 100644 --- a/scripts/bulk-agent-registration/New-A365AgentUser.ps1 +++ b/scripts/bulk-agent-registration/New-A365AgentUser.ps1 @@ -1,7 +1,7 @@ <# .SYNOPSIS Creates a Microsoft Agent 365 Agent User (and optionally an Agent Identity) using only - Microsoft Graph REST API calls, authenticating app-only (application permissions). + Microsoft Graph REST API calls, authenticating with app-only or interactive delegated Graph access. .DESCRIPTION Replicates the Graph API calls the Agent 365 CLI ("a365 setup createinstance") performs, @@ -17,33 +17,25 @@ Licensing is opt-in: no license is assigned unless -AssignLicense is specified. - Authentication is application-only (client credentials). Supported methods: + Supported authentication methods: 1. Client secret -ClientSecret 2. Certificate from cert store -CertificateThumbprint [-CertificateStoreLocation] 3. Certificate from PFX file -CertificatePath [-CertificatePassword] 4. Certificate object -Certificate 5. Managed identity (system-assigned) -UseManagedIdentity - 6. Managed identity (user-assigned) -UseManagedIdentity -ManagedIdentityClientId - 7. Workload identity federation -FederatedTokenFile (AKS / Kubernetes) - 8. Federated client assertion -ClientAssertion (GitHub OIDC, any OIDC IdP) - 9. Azure CLI service principal token -UseAzureCli - 10. Azure PowerShell (Az) token -UseAzPowerShell - 11. Pre-acquired raw token -AccessToken - - When no auth parameter is supplied, the script auto-detects, in order: AZURE_CLIENT_SECRET, - AZURE_CLIENT_CERTIFICATE_PATH, AZURE_FEDERATED_TOKEN_FILE, GitHub Actions OIDC, then IMDS - managed identity. This makes the script drop-in compatible with standard Azure SDK - environment-variable conventions. + 6. Interactive delegated sign-in -Interactive + + When no auth parameter is supplied, the script auto-detects, in order: + AZURE_CLIENT_SECRET, AZURE_CLIENT_CERTIFICATE_PATH, then IMDS managed identity. .PARAMETER TenantId Directory (tenant) ID or verified domain. Required for client secret, certificate and - federated-credential auth; optional for -AccessToken (read from the token's 'tid' - claim), managed identity, -UseAzureCli and -UseAzPowerShell. + interactive delegated authentication; optional for managed identity. .PARAMETER ClientId - Application (client) ID authenticating to Graph. Required for client secret, certificate, - federated token, client assertion, and GitHub OIDC authentication. Managed identity, - Azure CLI, Az PowerShell, and access-token authentication do not require it. + Application (client) ID authenticating to Graph. Required for client secret and + certificate authentication. Also required for interactive delegated authentication so a + caller-controlled public client can request the AgentUser preview scopes. .PARAMETER ClientSecret Client secret, as a SecureString or a plain string. Auto-detected from @@ -67,29 +59,14 @@ An already-loaded X509Certificate2 to authenticate with. .PARAMETER UseManagedIdentity - Authenticate with the host's managed identity (system-assigned, or user-assigned with - -ManagedIdentityClientId). Supports the App Service/Container Apps/Functions + Authenticate with the host's system-assigned managed identity. Supports the + App Service/Container Apps/Functions IDENTITY_ENDPOINT protocol and Azure VM/VMSS IMDS. -.PARAMETER ManagedIdentityClientId - Client id of a user-assigned managed identity, used with -UseManagedIdentity. - -.PARAMETER FederatedTokenFile - Path to a workload-identity federation token file (AKS/Kubernetes), exchanged for a - Graph token via client assertion. Auto-detected from $env:AZURE_FEDERATED_TOKEN_FILE. - -.PARAMETER ClientAssertion - A pre-built JWT client assertion (e.g. from a GitHub OIDC or other OIDC identity - provider) to authenticate -ClientId with. - -.PARAMETER UseAzureCli - Obtain a Graph token from the signed-in Azure CLI ('az account get-access-token'). - -.PARAMETER UseAzPowerShell - Obtain a Graph token from the signed-in Az PowerShell session (Get-AzAccessToken). - -.PARAMETER AccessToken - A pre-acquired Microsoft Graph access token, as a SecureString or a plain string. +.PARAMETER Interactive + Uses interactive delegated sign-in and requests delegated Graph scopes needed for + agent-user creation/update, manager assignment, licensing, and optional permission + configuration. .PARAMETER Environment Azure cloud: 'AzurePublic' (default), 'AzureUSGovernment' or 'AzureChina'. Selects the @@ -329,8 +306,8 @@ -UserPrincipalName 'aria@contoso.com' -DisplayName 'Aria' .EXAMPLE - # Workload identity federation inside AKS, US Government cloud - .\New-A365AgentUser.ps1 -TenantId $tid -ClientId $cid -FederatedTokenFile $env:AZURE_FEDERATED_TOKEN_FILE ` + # Interactive delegated sign-in, US Government cloud + .\New-A365AgentUser.ps1 -TenantId $tid -ClientId $interactiveClientId -Interactive ` -Environment AzureUSGovernment -BlueprintAppId $bp -AgentIdentityDisplayName 'Aria Identity' ` -UserPrincipalName 'aria@contoso.us' -DisplayName 'Aria' @@ -380,8 +357,8 @@ [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param( # ---------- Tenant / application ---------- - # Required for confidential-client flows (secret, certificate, federated credential, assertion). - # Optional for -AccessToken (read from the token's 'tid' claim), managed identity, az CLI and Az PowerShell. + # Required for confidential-client flows (secret, certificate) and interactive delegated auth. + # Optional for managed identity. [Parameter()] [string] $TenantId, @@ -397,12 +374,7 @@ param( [Parameter()] [object] $CertificatePassword, # string or SecureString [Parameter()] [System.Security.Cryptography.X509Certificates.X509Certificate2] $Certificate, [Parameter()] [switch] $UseManagedIdentity, - [Parameter()] [string] $ManagedIdentityClientId, - [Parameter()] [string] $FederatedTokenFile, - [Parameter()] [string] $ClientAssertion, - [Parameter()] [switch] $UseAzureCli, - [Parameter()] [switch] $UseAzPowerShell, - [Parameter()] [object] $AccessToken, # string or SecureString + [Parameter()] [switch] $Interactive, # ---------- Cloud ---------- [Parameter()] @@ -1007,31 +979,12 @@ function Get-TokenEndpoint { return "$($script:Authority)/$($script:TenantId)/oauth2/v2.0/token" } -function Get-TokenTenantClaim { - # Extracts the 'tid' claim so -AccessToken callers do not have to repeat the tenant id. - param([string] $Token) - try { - $parts = $Token.Split('.') - if ($parts.Count -lt 2) { return $null } - $p = $parts[1].Replace('-', '+').Replace('_', '/') - switch ($p.Length % 4) { 2 { $p += '==' } 3 { $p += '=' } } - $claims = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($p)) | ConvertFrom-Json - if ($claims.PSObject.Properties.Name -contains 'tid') { return $claims.tid } - } catch { Write-Verbose "Could not read the 'tid' claim from the supplied access token." } - return $null -} - function Assert-TenantRequirement { <# Confidential-client flows must target a specific tenant authority. The other flows either carry the tenant inside the token or inherit it from the ambient sign-in context. #> - $tenantRequired = @('ClientSecret', 'CertificateThumbprint', 'CertificatePath', 'Certificate', - 'FederatedTokenFile', 'ClientAssertion', 'GitHubOidc') - - if ($script:AuthMethod -eq 'AccessToken' -and [string]::IsNullOrWhiteSpace($script:TenantId)) { - $script:TenantId = Get-TokenTenantClaim (ConvertTo-PlainText $AccessToken) - } + $tenantRequired = @('ClientSecret', 'CertificateThumbprint', 'CertificatePath', 'Certificate', 'Interactive') if ($script:AuthMethod -in $tenantRequired -and [string]::IsNullOrWhiteSpace($script:TenantId)) { throw "-TenantId is required for the '$($script:AuthMethod)' authentication method." @@ -1043,17 +996,17 @@ function Resolve-AuthMethod { Determines which credential type to use. Explicit parameters win; otherwise fall back to the standard Azure SDK environment variables so the script works unchanged in CI. #> + if ($CertificatePassword -and (-not $CertificatePath) -and (-not $env:AZURE_CLIENT_CERTIFICATE_PATH)) { + throw '-CertificatePassword can be used only with -CertificatePath or AZURE_CLIENT_CERTIFICATE_PATH.' + } + $explicit = @() if ($ClientSecret) { $explicit += 'ClientSecret' } if ($CertificateThumbprint) { $explicit += 'CertificateThumbprint' } if ($CertificatePath) { $explicit += 'CertificatePath' } if ($Certificate) { $explicit += 'Certificate' } if ($UseManagedIdentity) { $explicit += 'ManagedIdentity' } - if ($FederatedTokenFile) { $explicit += 'FederatedTokenFile' } - if ($ClientAssertion) { $explicit += 'ClientAssertion' } - if ($UseAzureCli) { $explicit += 'AzureCli' } - if ($UseAzPowerShell) { $explicit += 'AzPowerShell' } - if ($AccessToken) { $explicit += 'AccessToken' } + if ($Interactive) { $explicit += 'Interactive' } if ($explicit.Count -gt 1) { throw "Specify exactly one authentication method. Found: $($explicit -join ', ')." @@ -1063,8 +1016,6 @@ function Resolve-AuthMethod { # Auto-detect from environment (Azure SDK conventions). if ($env:AZURE_CLIENT_SECRET) { $script:ClientSecret = $env:AZURE_CLIENT_SECRET; return 'ClientSecret' } if ($env:AZURE_CLIENT_CERTIFICATE_PATH) { $script:CertificatePath = $env:AZURE_CLIENT_CERTIFICATE_PATH; return 'CertificatePath' } - if ($env:AZURE_FEDERATED_TOKEN_FILE) { $script:FederatedTokenFile = $env:AZURE_FEDERATED_TOKEN_FILE; return 'FederatedTokenFile' } - if ($env:ACTIONS_ID_TOKEN_REQUEST_URL -and $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN) { return 'GitHubOidc' } if ($env:IDENTITY_ENDPOINT -or $env:MSI_ENDPOINT) { return 'ManagedIdentity' } throw @' @@ -1073,11 +1024,9 @@ No authentication method supplied and none could be auto-detected. Provide one of: -ClientSecret -CertificateThumbprint -CertificatePath -Certificate - -UseManagedIdentity -FederatedTokenFile - -ClientAssertion -UseAzureCli - -UseAzPowerShell -AccessToken + -UseManagedIdentity -Interactive -Or set AZURE_CLIENT_SECRET / AZURE_CLIENT_CERTIFICATE_PATH / AZURE_FEDERATED_TOKEN_FILE. +Or set AZURE_CLIENT_SECRET / AZURE_CLIENT_CERTIFICATE_PATH. '@ } @@ -1173,21 +1122,21 @@ function Get-ManagedIdentityToken { the Azure VM / VMSS IMDS protocol. #> $resource = $script:Graph + if ($ClientId -and $script:AuthMethod -eq 'ManagedIdentity') { + throw '-UseManagedIdentity supports only the system-assigned managed identity; do not pass -ClientId.' + } if ($env:IDENTITY_ENDPOINT -and $env:IDENTITY_HEADER) { $uri = "$($env:IDENTITY_ENDPOINT)?api-version=2019-08-01&resource=$([uri]::EscapeDataString($resource))" - if ($ManagedIdentityClientId) { $uri += "&client_id=$([uri]::EscapeDataString($ManagedIdentityClientId))" } $headers = @{ 'X-IDENTITY-HEADER' = $env:IDENTITY_HEADER } $r = Invoke-RestMethod -Method Get -Uri $uri -Headers $headers -ErrorAction Stop } elseif ($env:MSI_ENDPOINT -and $env:MSI_SECRET) { $uri = "$($env:MSI_ENDPOINT)?api-version=2017-09-01&resource=$([uri]::EscapeDataString($resource))" - if ($ManagedIdentityClientId) { $uri += "&clientid=$([uri]::EscapeDataString($ManagedIdentityClientId))" } $r = Invoke-RestMethod -Method Get -Uri $uri -Headers @{ Secret = $env:MSI_SECRET } -ErrorAction Stop } else { $uri = "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=$([uri]::EscapeDataString($resource))" - if ($ManagedIdentityClientId) { $uri += "&client_id=$([uri]::EscapeDataString($ManagedIdentityClientId))" } $r = Invoke-RestMethod -Method Get -Uri $uri -Headers @{ Metadata = 'true' } -TimeoutSec 10 -ErrorAction Stop } @@ -1199,36 +1148,75 @@ function Get-ManagedIdentityToken { return $r.access_token, $expires } -function Get-GitHubOidcAssertion { - $uri = "$($env:ACTIONS_ID_TOKEN_REQUEST_URL)&audience=api://AzureADTokenExchange" - $headers = @{ Authorization = "Bearer $($env:ACTIONS_ID_TOKEN_REQUEST_TOKEN)" } - $r = Invoke-RestMethod -Method Get -Uri $uri -Headers $headers -ErrorAction Stop - return $r.value -} +function Get-InteractiveDelegatedScopes { + $scopes = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($scope in @( + 'User.Read', + 'User.Read.All', + 'User.ReadWrite.All', + 'Directory.Read.All', + 'Organization.Read.All', + 'Application.Read.All', + 'AgentIdentity.Read.All', + 'AgentIdentity.ReadWrite.All', + 'AgentIdUser.ReadWrite.All', + 'LicenseAssignment.ReadWrite.All', + 'DelegatedPermissionGrant.ReadWrite.All', + 'AppRoleAssignment.ReadWrite.All')) { + $null = $scopes.Add($scope) + } + + if ($ConfigurePermissions) { + $null = $scopes.Add('Application.ReadWrite.All') + } -function Get-AzureCliToken { - $az = Get-Command az -ErrorAction SilentlyContinue - if (-not $az) { throw "Azure CLI ('az') was not found on PATH. Install it or choose a different authentication method." } - $azArgs = @('account', 'get-access-token', '--resource', $script:Graph, '--output', 'json') - if (-not [string]::IsNullOrWhiteSpace($script:TenantId)) { $azArgs += @('--tenant', $script:TenantId) } - $raw = & az @azArgs 2>&1 - if ($LASTEXITCODE -ne 0) { throw "az account get-access-token failed: $raw" } - $parsed = ($raw | Out-String) | ConvertFrom-Json - $expires = try { [DateTimeOffset]::Parse($parsed.expiresOn) } catch { [DateTimeOffset]::UtcNow.AddMinutes(55) } - return $parsed.accessToken, $expires + return @($scopes) } -function Get-AzPowerShellToken { - if (-not (Get-Command Get-AzAccessToken -ErrorAction SilentlyContinue)) { - throw "Get-AzAccessToken was not found. Install the Az.Accounts module or choose a different authentication method." - } - $azParams = @{ ResourceUrl = $script:Graph; ErrorAction = 'Stop' } - if (-not [string]::IsNullOrWhiteSpace($script:TenantId)) { $azParams['TenantId'] = $script:TenantId } - $t = Get-AzAccessToken @azParams - # Az 14+ returns the token as a SecureString. - $token = ConvertTo-PlainText $t.Token - $expires = try { [DateTimeOffset] $t.ExpiresOn } catch { [DateTimeOffset]::UtcNow.AddMinutes(55) } - return $token, $expires +function Get-InteractiveToken { + $client = if ([string]::IsNullOrWhiteSpace($ClientId)) { '04b07795-8ddb-461a-bbee-02f9e1bf7b46' } else { $ClientId } + $scopes = Get-InteractiveDelegatedScopes + $deviceCodeResponse = Invoke-RestMethod -Method Post -Uri "$($script:Authority)/$($script:TenantId)/oauth2/v2.0/devicecode" -Body @{ + client_id = $client + scope = ($scopes -join ' ') + } -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop + + Write-Host '' + Write-Host $deviceCodeResponse.message -ForegroundColor Yellow + Write-Host '' + + $expiresOn = [DateTimeOffset]::UtcNow.AddSeconds([int]$deviceCodeResponse.expires_in) + $interval = [Math]::Max([int]$deviceCodeResponse.interval, 5) + + while ([DateTimeOffset]::UtcNow -lt $expiresOn) { + Start-Sleep -Seconds $interval + try { + $tokenResponse = Invoke-RestMethod -Method Post -Uri "$($script:Authority)/$($script:TenantId)/oauth2/v2.0/token" -Body @{ + grant_type = 'urn:ietf:params:oauth:grant-type:device_code' + client_id = $client + device_code = $deviceCodeResponse.device_code + } -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop + return $tokenResponse.access_token, [DateTimeOffset]::UtcNow.AddSeconds([int]$tokenResponse.expires_in) + } + catch { + $status = Get-ErrorStatusCode $_ + $raw = Get-ErrorResponseBody $_ + $err = $null + if ($raw) { + try { $err = $raw | ConvertFrom-Json } catch { } + } + $code = if ($err -and $err.error) { [string]$err.error } else { '' } + if ($status -eq 400 -and ($code -in @('authorization_pending', 'slow_down'))) { + if ($code -eq 'slow_down') { $interval += 5 } + continue + } + + $msg = if ($err -and $err.error_description) { [string]$err.error_description } else { $_.Exception.Message } + throw "Interactive delegated sign-in failed: $msg" + } + } + + throw 'Interactive delegated sign-in timed out before authorization completed.' } function Get-GraphToken { @@ -1242,8 +1230,7 @@ function Get-GraphToken { return $script:CachedToken } - $needsClientId = @('ClientSecret', 'CertificateThumbprint', 'CertificatePath', 'Certificate', - 'FederatedTokenFile', 'ClientAssertion', 'GitHubOidc') + $needsClientId = @('ClientSecret', 'CertificateThumbprint', 'CertificatePath', 'Certificate', 'Interactive') if ($script:AuthMethod -in $needsClientId -and [string]::IsNullOrWhiteSpace($ClientId)) { throw "-ClientId is required for the '$($script:AuthMethod)' authentication method." } @@ -1268,29 +1255,8 @@ function Get-GraphToken { client_assertion = (New-ClientAssertionJwt -Cert $cert) } } - { $_ -in 'FederatedTokenFile', 'ClientAssertion', 'GitHubOidc' } { - $assertion = switch ($script:AuthMethod) { - 'FederatedTokenFile' { - if (-not (Test-Path -LiteralPath $FederatedTokenFile)) { - throw "Federated token file not found: $FederatedTokenFile" - } - (Get-Content -LiteralPath $FederatedTokenFile -Raw).Trim() - } - 'ClientAssertion' { $ClientAssertion } - 'GitHubOidc' { Get-GitHubOidcAssertion } - } - $token, $exp = Invoke-TokenEndpoint @{ - client_id = $ClientId - scope = $script:GraphScope - grant_type = 'client_credentials' - client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' - client_assertion = $assertion - } - } 'ManagedIdentity' { $token, $exp = Get-ManagedIdentityToken } - 'AzureCli' { $token, $exp = Get-AzureCliToken } - 'AzPowerShell' { $token, $exp = Get-AzPowerShellToken } - 'AccessToken' { $token = ConvertTo-PlainText $AccessToken; $exp = [DateTimeOffset]::UtcNow.AddMinutes(55) } + 'Interactive' { $token, $exp = Get-InteractiveToken } default { throw "Unsupported authentication method '$($script:AuthMethod)'." } } @@ -1330,13 +1296,14 @@ function Show-TokenIdentity { $script:TokenIsDelegated = [bool]$scp if ($scp) { + Write-Detail "Authorization model: delegated scopes + signed-in user's directory roles." # A delegated token from an admin user is the RECOMMENDED way to bootstrap # -ConfigurePermissions, because the app has no permissions of its own yet. if ($ConfigurePermissions) { Write-Detail "Delegated token detected; permissions will be configured as the signed-in user." ([ConsoleColor]::DarkCyan) } else { - Write-Warning "The token is delegated ('scp'), not application-only. Agent provisioning calls expect an app-only token; they will run as the signed-in user and may be denied." + Write-Detail "Delegated token detected. Authorization now depends on both delegated scopes ('scp') and the signed-in user's directory roles." ([ConsoleColor]::DarkCyan) } } elseif ($roles.Count -eq 0) { @@ -1346,9 +1313,8 @@ This app-only token carries no application permissions ('roles' claim is absent) configure permissions either - the first Graph call will be denied. Bootstrap it one of these ways instead: - 1. Sign in as an admin USER and use that delegated token (simplest): - az login --tenant --allow-no-subscriptions - .\New-A365AgentUser.ps1 -UseAzureCli -ConfigurePermissions -ConfigureAppId $appId + 1. Sign in as an admin USER and use interactive delegated auth: + .\New-A365AgentUser.ps1 -TenantId -ClientId -Interactive -ConfigurePermissions -ConfigureAppId $appId 2. Have an admin consent Application.ReadWrite.All + AppRoleAssignment.ReadWrite.All to this app once in the Entra portal, then re-run app-only. "@ @@ -1357,6 +1323,9 @@ Bootstrap it one of these ways instead: Write-Warning "The token contains no 'roles' claim. Graph calls will likely fail with 403. Grant this app permissions first (see -ConfigurePermissions)." } } + else { + Write-Detail "Authorization model: application roles only. Signed-in user directory roles are not evaluated for app-only tokens." + } $script:TokenRoles = $roles } catch { Write-Verbose "Could not decode token claims: $($_.Exception.Message)" @@ -1521,8 +1490,7 @@ privileged. Use ONE of the following: 1. Delegated admin user (simplest - no pre-existing app permissions required): - az login --tenant $($script:TenantId) --allow-no-subscriptions - .\New-A365AgentUser.ps1 -UseAzureCli -ConfigurePermissions -ConfigureAppId $AppId + .\New-A365AgentUser.ps1 -TenantId $($script:TenantId) -ClientId -Interactive -ConfigurePermissions -ConfigureAppId $AppId The signed-in account must hold Application Administrator, Cloud Application Administrator or Global Administrator. @@ -2501,6 +2469,8 @@ $script:CachedToken = $null $script:CachedTokenExpiry = [DateTimeOffset]::MinValue $script:TokenRoles = @() $script:TokenIsDelegated = $false +$script:TenantId = $TenantId +$script:ClientId = $ClientId try { Resolve-CloudEndpoints @@ -2556,7 +2526,7 @@ try { $script:AuthMethod = Resolve-AuthMethod Assert-TenantRequirement - Write-Step 'Authenticating to Microsoft Graph (application-only)' + Write-Step 'Authenticating to Microsoft Graph' Write-Detail "Cloud : $Environment" Write-Detail "Graph : $($script:Graph)" Write-Detail "Authority : $($script:Authority)" diff --git a/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 b/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 index c1c6fc76..91740037 100644 --- a/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 +++ b/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 @@ -17,8 +17,7 @@ permissions and delegated scopes on it. 3. Creates (or reuses) its service principal - the app role grants attach to the SP, not the application. - 4. Adds credentials: a client secret, a certificate, and/or a federated identity credential - for workload identity federation (GitHub Actions, Azure DevOps, Kubernetes). + 4. Adds credentials: a client secret and/or a certificate credential. 5. Unless -SkipGrant, grants each app role via POST /servicePrincipals/{graphSpId}/appRoleAssignedTo, which is admin consent. Already-granted roles are skipped, so re-running is safe. @@ -84,12 +83,6 @@ .PARAMETER CertificatePath Path to a .cer/.crt public certificate file to upload as a credential. -.PARAMETER FederatedCredential - One or more hashtables describing federated identity credentials, so the app can be used from - GitHub Actions or Azure DevOps with no stored secret at all. Keys: Name (required), - Issuer (required), Subject (required), Audience (defaults to api://AzureADTokenExchange), - Description. - .PARAMETER AddRegistrationDelegatedScopes Adds AgentRegistration.ReadWrite.All and AgentInstance.ReadWrite.All as delegated scopes on the application, for use as the client app of an interactive registration run. Defaults to on for @@ -116,11 +109,17 @@ Thumbprint of the certificate used to AUTHENTICATE this script (distinct from -CertificateThumbprint, which is the credential being added to the new app). -.PARAMETER UseManagedIdentity - Authenticate with the host's managed identity. +.PARAMETER AuthCertificate + X509Certificate2 object used to authenticate this script. -.PARAMETER AccessToken - A pre-acquired Graph access token, as a SecureString or a plain string. +.PARAMETER AuthCertificatePath + Path to a PFX certificate used to authenticate this script. + +.PARAMETER AuthCertificatePassword + Password for -AuthCertificatePath, as a SecureString or plain string. + +.PARAMETER UseManagedIdentity + Authenticate with the host's SYSTEM-assigned managed identity. .PARAMETER Interactive Sign in as a user. This is the normal choice for a one-time bootstrap. @@ -142,8 +141,7 @@ .PARAMETER KeyVaultAccessToken A bearer token for https://vault.azure.net, needed only when one cannot be derived from - the Graph credential: -AccessToken (audience-bound, cannot be exchanged), or -Interactive - without a signed-in Azure session. + the Graph credential, such as -Interactive without a signed-in Azure session. .EXAMPLE # Bootstrap the whole pipeline with a client secret. @@ -155,13 +153,6 @@ .\New-A365AutomationApp.ps1 -TenantId contoso.onmicrosoft.com -Interactive ` -Scenario Blueprint -CertificateThumbprint A1B2C3D4E5F60718293A4B5C6D7E8F9012345678 -.EXAMPLE - # Secretless CI from GitHub Actions. - .\New-A365AutomationApp.ps1 -TenantId contoso.onmicrosoft.com -Interactive -FederatedCredential @( - @{ Name='github-main'; Issuer='https://token.actions.githubusercontent.com' - Subject='repo:contoso/agents:ref:refs/heads/main' } - ) - .EXAMPLE # See what would change without touching the tenant. .\New-A365AutomationApp.ps1 -TenantId contoso.onmicrosoft.com -Interactive -WhatIf @@ -192,7 +183,6 @@ param( [ValidateRange(1, 24)][int] $SecretValidityMonths = 6, [string] $CertificateThumbprint, [string] $CertificatePath, - [hashtable[]] $FederatedCredential = @(), [bool] $AddRegistrationDelegatedScopes = $true, [switch] $SkipGrant, @@ -207,8 +197,10 @@ param( [string] $ClientId, [object] $ClientSecret, [string] $AuthCertificateThumbprint, + [System.Security.Cryptography.X509Certificates.X509Certificate2] $AuthCertificate, + [string] $AuthCertificatePath, + [object] $AuthCertificatePassword, [switch] $UseManagedIdentity, - [object] $AccessToken, [switch] $Interactive ) @@ -613,9 +605,9 @@ function Get-KeyVaultToken { Obtains a token for the Key Vault data plane, using the SAME credential the caller gave for Graph wherever that is possible. - The one case that cannot work is -AccessToken: a Graph access token is issued for the - Graph audience and the vault rejects it outright, and there is no way to exchange one - for the other. That mode therefore requires -KeyVaultAccessToken. + Graph and Key Vault use different token audiences. Interactive Graph sign-in yields a + Graph-audience token only, so minting the vault token requires an Azure sign-in context + or an explicit -KeyVaultAccessToken. #> param( [Parameter(Mandatory)][string] $TenantId, @@ -709,9 +701,6 @@ function Get-KeyVaultToken { } throw 'Interactive Graph sign-in cannot mint a Key Vault token: Connect-MgGraph issues Graph-audience tokens only. Sign in to Azure first ("az login" or "Connect-AzAccount"), or pass -KeyVaultAccessToken.' } - 'AccessToken' { - throw '-AccessToken supplies a Microsoft Graph token, which the Key Vault data plane rejects (verified: HTTP 401). A token is audience-bound and cannot be exchanged. Pass -KeyVaultAccessToken with a token for https://vault.azure.net, or authenticate with -ClientSecret / -Certificate / -UseManagedIdentity so one can be obtained for you.' - } default { throw "Cannot obtain a Key Vault token for authentication mode '$AuthMode'. Pass -KeyVaultAccessToken." } @@ -858,8 +847,10 @@ function Connect-GraphSession { [string] $ClientId, [object] $ClientSecret, [string] $CertificateThumbprint, + [System.Security.Cryptography.X509Certificates.X509Certificate2] $Certificate, + [string] $CertificatePath, + [object] $CertificatePassword, [switch] $UseManagedIdentity, - [object] $AccessToken, [switch] $Interactive, [string[]] $DelegatedScope = @() ) @@ -882,7 +873,6 @@ finally { # Accept plain strings as well as SecureStrings, and warn about the trade-off once. $secretWasPlainText = $ClientSecret -is [string] $ClientSecret = ConvertTo-SecureStringValue -Value $ClientSecret -Name 'ClientSecret' - $AccessToken = ConvertTo-SecureStringValue -Value $AccessToken -Name 'AccessToken' # Keeps the secret out of command lines, shell history and transcripts. if ((-not $ClientSecret) -and $env:A365_CLIENT_SECRET) { @@ -893,18 +883,42 @@ finally { Write-Warning 'A plain-text -ClientSecret was passed on the command line, where it is visible to shell history and transcripts. Prefer $env:A365_CLIENT_SECRET or a SecureString.' } + $certificateSources = @( + [bool]$CertificateThumbprint, + ($null -ne $Certificate), + [bool]$CertificatePath + ) | Where-Object { $_ } + if ($certificateSources.Count -gt 1) { + throw 'Supply exactly one certificate source: -AuthCertificateThumbprint, -AuthCertificate, or -AuthCertificatePath.' + } + if ($CertificatePassword -and (-not $CertificatePath)) { + throw '-AuthCertificatePassword can be used only with -AuthCertificatePath.' + } + + $loadedCertificate = $Certificate + if ($CertificatePath) { + if (-not (Test-Path -LiteralPath $CertificatePath -PathType Leaf)) { + throw "Authentication certificate file not found: $CertificatePath" + } + $password = ConvertTo-SecureStringValue -Value $CertificatePassword -Name 'AuthCertificatePassword' + $loadedCertificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + $CertificatePath, + $password, + [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet + ) + } + $modes = @() if ($Interactive) { $modes += 'Interactive' } - if ($AccessToken) { $modes += 'AccessToken' } if ($UseManagedIdentity) { $modes += 'ManagedIdentity' } - if ($CertificateThumbprint) { $modes += 'Certificate' } + if ($certificateSources.Count -eq 1) { $modes += 'Certificate' } if ($ClientSecret) { $modes += 'ClientSecret' } if ($modes.Count -gt 1) { - throw "Conflicting authentication options ($($modes -join ', ')). Supply exactly one of -ClientSecret, -AuthCertificateThumbprint, -UseManagedIdentity, -AccessToken or -Interactive." + throw "Conflicting authentication options ($($modes -join ', ')). Supply exactly one of -ClientSecret, a certificate source, -UseManagedIdentity or -Interactive." } if ($modes.Count -eq 0) { - throw 'No authentication method was specified. This script is normally a one-time bootstrap, so -Interactive is the usual choice. For unattended use pass -ClientId with -ClientSecret or -AuthCertificateThumbprint, or use -UseManagedIdentity / -AccessToken.' + throw 'No authentication method was specified. This script is normally a one-time bootstrap, so -Interactive is the usual choice. For unattended use pass -ClientId with -ClientSecret or a certificate source, or use -UseManagedIdentity.' } $mode = $modes[0] @@ -915,9 +929,18 @@ finally { $connect = @{ NoWelcome = $true; ErrorAction = 'Stop' } switch ($mode) { 'ClientSecret' { $connect.TenantId = $TenantId; $connect.ClientSecretCredential = [pscredential]::new($ClientId, $ClientSecret) } - 'Certificate' { $connect.TenantId = $TenantId; $connect.ClientId = $ClientId; $connect.CertificateThumbprint = $CertificateThumbprint } - 'ManagedIdentity' { $connect.Identity = $true; if ($ClientId) { $connect.ClientId = $ClientId } } - 'AccessToken' { $connect.AccessToken = $AccessToken } + 'Certificate' { + $connect.TenantId = $TenantId + $connect.ClientId = $ClientId + if ($CertificateThumbprint) { $connect.CertificateThumbprint = $CertificateThumbprint } + else { $connect.Certificate = $loadedCertificate } + } + 'ManagedIdentity' { + $connect.Identity = $true + if ($ClientId) { + throw '-UseManagedIdentity supports only the system-assigned managed identity; do not pass -ClientId.' + } + } 'Interactive' { $connect.TenantId = $TenantId if ($ClientId) { $connect.ClientId = $ClientId } @@ -945,15 +968,15 @@ finally { else { Write-Host " Connected as $ctxAccount in tenant $ctxTenant [delegated, $mode]" -ForegroundColor Green } # The Key Vault token is a SECOND token and has to be minted from the same credential. - # This script binds certificates by thumbprint only, so resolve the certificate from the - # store for that case; a miss is not fatal here because only a Key Vault write needs it. + # Resolve thumbprint credentials for Key Vault token acquisition; object/PFX credentials are + # already available as $loadedCertificate. $ctxCertificate = if ($CertificateThumbprint) { @( "Cert:\CurrentUser\My\$CertificateThumbprint", "Cert:\LocalMachine\My\$CertificateThumbprint" ) | ForEach-Object { Get-Item -LiteralPath $_ -ErrorAction SilentlyContinue } | Select-Object -First 1 - } else { $null } + } else { $loadedCertificate } [pscustomobject]@{ Mode = $mode; IsAppOnly = $isAppOnly; AuthType = $authType @@ -1040,6 +1063,23 @@ $script:RegistrationDelegatedScopes = @( 'User.ReadBasic.All' # GET /users/{upn} -> owner object ids from -Owner ) +# New-A365AgentUser.ps1 can run interactively (delegated), so these scopes are declared on the +# automation app for the user-create/update/manager/license and per-identity consent operations. +$script:AgentUserDelegatedScopes = @( + 'User.Read' + 'User.Read.All' + 'User.ReadWrite.All' + 'Directory.Read.All' + 'Organization.Read.All' + 'Application.Read.All' + 'AgentIdentity.Read.All' + 'AgentIdentity.ReadWrite.All' + 'AgentIdUser.ReadWrite.All' + 'LicenseAssignment.ReadWrite.All' + 'DelegatedPermissionGrant.ReadWrite.All' + 'AppRoleAssignment.ReadWrite.All' +) + # Custom security attributes are gated separately from every other directory permission, and the # gate applies to BOTH call shapes. New-A365AgentIdentity.ps1 asks for these as delegated scopes # whenever -CustomSecurityAttribute is used, so an interactive run using this app as its client @@ -1061,8 +1101,10 @@ $connectArgs = @{ if ($PSBoundParameters.ContainsKey('ClientId')) { $connectArgs.ClientId = $ClientId } if ($PSBoundParameters.ContainsKey('ClientSecret')) { $connectArgs.ClientSecret = $ClientSecret } if ($PSBoundParameters.ContainsKey('AuthCertificateThumbprint')) { $connectArgs.CertificateThumbprint = $AuthCertificateThumbprint } +if ($PSBoundParameters.ContainsKey('AuthCertificate')) { $connectArgs.Certificate = $AuthCertificate } +if ($PSBoundParameters.ContainsKey('AuthCertificatePath')) { $connectArgs.CertificatePath = $AuthCertificatePath } +if ($PSBoundParameters.ContainsKey('AuthCertificatePassword')) { $connectArgs.CertificatePassword = $AuthCertificatePassword } if ($PSBoundParameters.ContainsKey('UseManagedIdentity')) { $connectArgs.UseManagedIdentity = $UseManagedIdentity } -if ($PSBoundParameters.ContainsKey('AccessToken')) { $connectArgs.AccessToken = $AccessToken } if ($PSBoundParameters.ContainsKey('Interactive')) { $connectArgs.Interactive = $Interactive } $ctx = Connect-GraphSession @connectArgs @@ -1145,6 +1187,22 @@ if ($wantRegistrationScopes) { } } +if ($Scenario -in 'AgentUser', 'All') { + $agentUserScopeAdded = 0 + foreach ($name in $script:AgentUserDelegatedScopes) { + if (-not $scopeIdByName.ContainsKey($name)) { + Write-Warning "Delegated scope '$name' is not published in this tenant; skipping it. Interactive agent-user provisioning will not work without it." + continue + } + if (@($delegatedToRequest | Where-Object { $_.Name -eq $name }).Count -gt 0) { continue } + $delegatedToRequest += [pscustomobject]@{ Name = $name; Id = $scopeIdByName[$name] } + $agentUserScopeAdded++ + } + if ($agentUserScopeAdded -gt 0) { + Write-Host " Will request $agentUserScopeAdded delegated scope(s) for interactive agent-user provisioning." -ForegroundColor Gray + } +} + # The custom security attribute scopes ride with the AgentIdentity scenario, because that is the # phase that assigns them. Declared for the delegated case as well as the application case: an # interactive run gets its access from the scope, not from the app role. @@ -1192,9 +1250,10 @@ if ($application) { } else { $body = @{ - displayName = $DisplayName - signInAudience = 'AzureADMyOrg' - notes = 'Runs the Agent 365 Graph provisioning scripts unattended.' + displayName = $DisplayName + signInAudience = 'AzureADMyOrg' + notes = 'Runs the Agent 365 Graph provisioning scripts unattended.' + isFallbackPublicClient = ($delegatedToRequest.Count -gt 0) } if ($PSCmdlet.ShouldProcess($DisplayName, 'POST /applications')) { $application = Invoke-Graph -Method POST -Uri '/applications' -Body $body @@ -1213,10 +1272,18 @@ $replicationRetry = @{} if ($applicationCreated) { $replicationRetry.RetryOnNotFound = $true } # Declare permissions even with -SkipGrant so an administrator can consent from the portal. -$current = Invoke-Graph -Method GET -Uri "/applications/$applicationObjectId`?`$select=requiredResourceAccess" @replicationRetry +$current = Invoke-Graph -Method GET -Uri "/applications/$applicationObjectId`?`$select=requiredResourceAccess,isFallbackPublicClient" @replicationRetry $existingAccess = @() if (Test-HasProperty $current 'requiredResourceAccess') { $existingAccess = @($current.requiredResourceAccess) } +if ($delegatedToRequest.Count -gt 0 -and + ((-not (Test-HasProperty $current 'isFallbackPublicClient')) -or (-not $current.isFallbackPublicClient))) { + if ($PSCmdlet.ShouldProcess($DisplayName, 'PATCH isFallbackPublicClient (enable interactive public-client flows)')) { + Invoke-Graph -Method PATCH -Uri "/applications/$applicationObjectId" -Body @{ isFallbackPublicClient = $true } @replicationRetry | Out-Null + Write-Host ' Enabled public-client flows for interactive delegated authentication.' -ForegroundColor Green + } +} + $permissionMerge = Merge-GraphRequiredResourceAccess -ExistingAccess $existingAccess ` -ResourceAppId $script:MicrosoftGraphAppId -ApplicationRoles $resolved ` -DelegatedScopes $delegatedToRequest @@ -1363,41 +1430,8 @@ if ($CertificateThumbprint -or $CertificatePath) { } } -foreach ($fic in $FederatedCredential) { - foreach ($required in 'Name', 'Issuer', 'Subject') { - if (-not $fic.ContainsKey($required) -or [string]::IsNullOrWhiteSpace([string]$fic[$required])) { - throw "-FederatedCredential entries require a non-empty '$required' key." - } - } - $ficName = [string]$fic['Name'] - - $existing = Invoke-Graph -Method GET -Uri "/applications/$applicationObjectId/federatedIdentityCredentials" -TolerateNotFound - $present = $false - if ($existing -and (Test-HasProperty $existing 'value')) { - $present = @($existing.value | Where-Object { [string]$_.name -eq $ficName }).Count -gt 0 - } - - if ($present) { - Write-Host " Federated credential '$ficName' already exists." -ForegroundColor Gray - continue - } - - $body = @{ - name = $ficName - issuer = [string]$fic['Issuer'] - subject = [string]$fic['Subject'] - audiences = @(if ($fic.ContainsKey('Audience')) { [string]$fic['Audience'] } else { 'api://AzureADTokenExchange' }) - } - if ($fic.ContainsKey('Description')) { $body['description'] = [string]$fic['Description'] } - - if ($PSCmdlet.ShouldProcess($ficName, 'POST /applications/{id}/federatedIdentityCredentials')) { - Invoke-Graph -Method POST -Uri "/applications/$applicationObjectId/federatedIdentityCredentials" -Body $body | Out-Null - Write-Host " Federated credential '$ficName' created." -ForegroundColor Green - } -} - -if (-not ($NewClientSecret -or $CertificateThumbprint -or $CertificatePath -or $FederatedCredential.Count)) { - Write-Warning 'No credential was added. The application cannot authenticate until you add one (-NewClientSecret, -CertificateThumbprint/-CertificatePath or -FederatedCredential).' +if (-not ($NewClientSecret -or $CertificateThumbprint -or $CertificatePath)) { + Write-Warning 'No credential was added. The application cannot authenticate until you add one (-NewClientSecret, -CertificateThumbprint/-CertificatePath).' } # --------------------------------------------------------------------------- diff --git a/scripts/bulk-agent-registration/Remove-A365AgentIdentity.ps1 b/scripts/bulk-agent-registration/Remove-A365AgentIdentity.ps1 index 2537385e..8358afda 100644 --- a/scripts/bulk-agent-registration/Remove-A365AgentIdentity.ps1 +++ b/scripts/bulk-agent-registration/Remove-A365AgentIdentity.ps1 @@ -49,24 +49,16 @@ writes console output only. .PARAMETER TenantId - Directory (tenant) id. Optional for -Interactive / -UseDeviceCode; required for application - authentication. + Directory (tenant) id. Optional for -Interactive / -UseManagedIdentity; required for client secret + and certificate authentication. .PARAMETER Interactive Sign in as a user (delegated auth). Mutually exclusive with other authentication modes; -ClientId may select the app registration used for sign-in. -.PARAMETER UseDeviceCode - Sign in as a user via the device code flow, for hosts that cannot complete a browser - redirect. Falls back to a reduced delegated scope set if the Agent 365 preview scopes are - not enabled in the tenant. - -.PARAMETER UseExistingConnection - Reuse an already-established Connect-MgGraph session instead of connecting again. - .PARAMETER ClientId - Application (client) ID to authenticate as. Required with -ClientSecret or - -CertificateThumbprint. + Application (client) ID to authenticate as. Required with -ClientSecret or certificate + authentication. .PARAMETER ClientSecret Client secret, as a SecureString or a plain string. Requires -ClientId and -TenantId. @@ -75,8 +67,17 @@ Thumbprint of a certificate to authenticate -ClientId with. Requires -ClientId and -TenantId. -.PARAMETER AccessToken - A pre-acquired Graph access token, as a SecureString or a plain string. +.PARAMETER Certificate + An X509Certificate2 to authenticate -ClientId with. Requires -ClientId and -TenantId. + +.PARAMETER CertificatePath + Path to a .pfx file to authenticate -ClientId with. Requires -ClientId and -TenantId. + +.PARAMETER CertificatePassword + Password for -CertificatePath, as a SecureString or plain string. + +.PARAMETER UseManagedIdentity + Authenticate with the host's system-assigned managed identity. .PARAMETER LogPath Write a timestamped log of this run. A path that names an existing directory (or ends in @@ -98,7 +99,8 @@ Report what would be deleted, and delete nothing. .EXAMPLE - .\Remove-A365AgentUser.ps1 -AgentUserId $userId -Interactive -Force + .\Remove-A365AgentUser.ps1 -AgentUserId $userId -TenantId $tenantId ` + -ClientId $interactiveClientId -Interactive -Force .\Remove-A365AgentIdentity.ps1 -AgentIdentityId $identityId -Interactive -Force Retire an agent in the correct order: the agent user first, then the identity that owned it. @@ -129,13 +131,13 @@ param( [string] $TenantId, [switch] $Interactive, - [switch] $UseDeviceCode, - [switch] $UseExistingConnection, [string] $ClientId, [object] $ClientSecret, [string] $CertificateThumbprint, - [object] $AccessToken, - + [System.Security.Cryptography.X509Certificates.X509Certificate2] $Certificate, + [string] $CertificatePath, + [object] $CertificatePassword, + [switch] $UseManagedIdentity, # ===================================================================== # LOGGING # ===================================================================== @@ -684,21 +686,31 @@ $delegatedScopeSets = @( ) $ClientSecret = ConvertTo-SecureStringValue -Value $ClientSecret -Name 'ClientSecret' -$AccessToken = ConvertTo-SecureStringValue -Value $AccessToken -Name 'AccessToken' +$CertificatePassword = ConvertTo-SecureStringValue -Value $CertificatePassword -Name 'CertificatePassword' + +$certificateSourceCount = @( + [bool]$CertificateThumbprint, + ($null -ne $Certificate), + [bool]$CertificatePath +).Where({ $_ }).Count +if ($certificateSourceCount -gt 1) { + throw 'Supply exactly one certificate source: -CertificateThumbprint, -Certificate, or -CertificatePath.' +} +if ($CertificatePassword -and (-not $CertificatePath)) { + throw '-CertificatePassword can be used only with -CertificatePath.' +} $modes = @() -if ($UseExistingConnection) { $modes += 'ExistingConnection' } if ($Interactive) { $modes += 'Interactive' } -if ($UseDeviceCode) { $modes += 'DeviceCode' } -if ($AccessToken) { $modes += 'AccessToken' } -if ($CertificateThumbprint) { $modes += 'Certificate' } +if ($UseManagedIdentity) { $modes += 'ManagedIdentity' } +if ($CertificateThumbprint -or $Certificate -or $CertificatePath) { $modes += 'Certificate' } if ($ClientSecret) { $modes += 'ClientSecret' } if ($modes.Count -gt 1) { throw "Conflicting authentication options ($($modes -join ', ')). Supply exactly one." } if ($modes.Count -eq 0) { - throw 'No authentication method specified. Pass -Interactive (recommended), -UseDeviceCode, -UseExistingConnection, or -ClientId with -ClientSecret / -CertificateThumbprint.' + throw 'No authentication method specified. Pass -Interactive (recommended), -UseManagedIdentity, or -ClientId with -ClientSecret / -CertificateThumbprint / -Certificate / -CertificatePath.' } $mode = $modes[0] @@ -707,35 +719,49 @@ if ($mode -in @('ClientSecret', 'Certificate')) { if (-not $TenantId) { throw "-TenantId is required for $mode authentication." } } -if ($mode -ne 'ExistingConnection') { - $connect = @{ NoWelcome = $true; ErrorAction = 'Stop' } +$connect = @{ NoWelcome = $true; ErrorAction = 'Stop' } switch ($mode) { 'Interactive' { if ($TenantId) { $connect.TenantId = $TenantId } if ($ClientId) { $connect.ClientId = $ClientId } } - 'DeviceCode' { - $connect.UseDeviceCode = $true - if ($TenantId) { $connect.TenantId = $TenantId } - if ($ClientId) { $connect.ClientId = $ClientId } - } - 'AccessToken' { $connect.AccessToken = $AccessToken } 'ClientSecret' { $connect.TenantId = $TenantId $connect.ClientSecretCredential = [pscredential]::new($ClientId, $ClientSecret) } - 'Certificate' { - $connect.TenantId = $TenantId - $connect.ClientId = $ClientId - $connect.CertificateThumbprint = $CertificateThumbprint + 'Certificate' { + $connect.TenantId = $TenantId + $connect.ClientId = $ClientId + if ($Certificate) { + $connect.Certificate = $Certificate + } + elseif ($CertificatePath) { + if (-not (Test-Path -LiteralPath $CertificatePath)) { + throw "Certificate file not found: $CertificatePath" + } + $pfx = (Resolve-Path -LiteralPath $CertificatePath).ProviderPath + $connect.Certificate = if ($CertificatePassword) { + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword) + } + else { + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx) + } + } + else { + $connect.CertificateThumbprint = $CertificateThumbprint + } + } + 'ManagedIdentity' { + if ($ClientId) { throw '-ClientId cannot be used with -UseManagedIdentity. Only system-assigned managed identity is supported.' } + $connect.Identity = $true } } - if ($mode -in @('Interactive', 'DeviceCode')) { + if ($mode -eq 'Interactive') { # The Agent 365 delegated scopes are preview and do not exist in every tenant. Entra rejects # the WHOLE scope string with AADSTS70011 if any single scope is unknown, so fall back to a # universally valid set rather than failing outright. Scope validation happens before a - # device code is issued, so a failed attempt does not burn a code. + # interactive prompt is shown, so a failed attempt does not burn a code. $connected = $false for ($setIndex = 0; $setIndex -lt $delegatedScopeSets.Count; $setIndex++) { $connect.Scopes = $delegatedScopeSets[$setIndex] @@ -755,11 +781,10 @@ if ($mode -ne 'ExistingConnection') { else { Connect-MgGraph @connect } -} $mg = Get-MgContext if (-not $mg) { - throw 'Connect-MgGraph did not establish a Graph context. If you used -Interactive from a non-interactive host, the browser flow cannot complete - use -UseDeviceCode instead.' + throw 'Connect-MgGraph did not establish a Graph context. If you used -Interactive from a non-interactive host, the browser flow cannot complete - use an interactive host or run with app-only authentication.' } $authType = [string](Get-Value $mg 'AuthType' '') @@ -928,4 +953,4 @@ Write-Host 'Done.' -ForegroundColor Green if ($PassThru) { $plan } if ($failures.Count -gt 0) { Complete-A365Log -Outcome 'Failed'; exit 1 } -Complete-A365Log -Outcome 'Succeeded' \ No newline at end of file +Complete-A365Log -Outcome 'Succeeded' diff --git a/scripts/bulk-agent-registration/Remove-A365AgentRegistration.ps1 b/scripts/bulk-agent-registration/Remove-A365AgentRegistration.ps1 index 5edf22d9..fae1be70 100644 --- a/scripts/bulk-agent-registration/Remove-A365AgentRegistration.ps1 +++ b/scripts/bulk-agent-registration/Remove-A365AgentRegistration.ps1 @@ -63,8 +63,8 @@ Suppress confirmation prompts. Required for unattended runs. .PARAMETER TenantId - Directory (tenant) id. Optional for -Interactive / -UseDeviceCode; required for application - authentication. + Directory (tenant) id. Optional for -Interactive / -UseManagedIdentity; required for client secret + and certificate authentication. .PARAMETER Interactive Sign in as a user (delegated auth). Recommended for ad-hoc runs, and often required: the @@ -72,17 +72,9 @@ Mutually exclusive with other authentication modes; -ClientId may select the app registration used for sign-in. -.PARAMETER UseDeviceCode - Sign in as a user via the device code flow, for hosts that cannot complete a browser - redirect. Falls back to a reduced delegated scope set if the Agent 365 preview scopes are - not enabled in the tenant. - -.PARAMETER UseExistingConnection - Reuse an already-established Connect-MgGraph session instead of connecting again. - .PARAMETER ClientId - Application (client) ID to authenticate as. Required with -ClientSecret or - -CertificateThumbprint. + Application (client) ID to authenticate as. Required with -ClientSecret or certificate + authentication. .PARAMETER ClientSecret Client secret, as a SecureString or a plain string. Requires -ClientId and -TenantId. @@ -91,8 +83,17 @@ Thumbprint of a certificate to authenticate -ClientId with. Requires -ClientId and -TenantId. -.PARAMETER AccessToken - A pre-acquired Graph access token, as a SecureString or a plain string. +.PARAMETER Certificate + An X509Certificate2 to authenticate -ClientId with. Requires -ClientId and -TenantId. + +.PARAMETER CertificatePath + Path to a .pfx file to authenticate -ClientId with. Requires -ClientId and -TenantId. + +.PARAMETER CertificatePassword + Password for -CertificatePath, as a SecureString or plain string. + +.PARAMETER UseManagedIdentity + Authenticate with the host's system-assigned managed identity. .PARAMETER LogPath Write a timestamped log of this run. A path that names an existing directory (or ends in @@ -151,13 +152,13 @@ param( [string] $TenantId, [switch] $Interactive, - [switch] $UseDeviceCode, - [switch] $UseExistingConnection, [string] $ClientId, [object] $ClientSecret, [string] $CertificateThumbprint, - [object] $AccessToken, - + [System.Security.Cryptography.X509Certificates.X509Certificate2] $Certificate, + [string] $CertificatePath, + [object] $CertificatePassword, + [switch] $UseManagedIdentity, # ===================================================================== # LOGGING # ===================================================================== @@ -524,64 +525,6 @@ function Get-Value { return $Default } -function Get-JwtPayload { - <# - .SYNOPSIS - Best-effort decode of a JWT payload. Never validates the signature. - - .DESCRIPTION - Used only to describe the token the caller supplied - which application it belongs to and - whether it is app-only. Get-MgContext cannot answer either question for a raw -AccessToken: - it reports AuthType 'Delegated' for every token handed to it, including client-credentials - tokens that have no user at all. Mislabelling an app-only run as delegated sends the - permission advice down the wrong branch, so the token itself is the authority here. - #> - param([string] $Token) - - if ([string]::IsNullOrWhiteSpace($Token)) { return $null } - $parts = $Token.Split('.') - if ($parts.Count -lt 2) { return $null } - - try { - $payload = $parts[1].Replace('-', '+').Replace('_', '/') - # base64url drops the padding; restore it before decoding. - switch ($payload.Length % 4) { - 2 { $payload += '==' } - 3 { $payload += '=' } - 1 { return $null } - } - return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload)) | ConvertFrom-Json - } - catch { - return $null - } -} - -function Test-JwtIsAppOnly { - <# - .SYNOPSIS - Returns $true / $false when the token says so, $null when it cannot be determined. - #> - param($Claims) - - if ($null -eq $Claims) { return $null } - - # idtyp is the explicit answer when Entra emits it. - $idtyp = [string](Get-Value $Claims 'idtyp' '') - if ($idtyp -eq 'app') { return $true } - if ($idtyp -eq 'user') { return $false } - - # Otherwise: a delegated token always carries scopes; an app-only token carries roles and no - # user identity. Check for a user first, since a delegated token can carry roles too. - foreach ($userClaim in 'upn', 'unique_name', 'preferred_username') { - if (-not [string]::IsNullOrWhiteSpace([string](Get-Value $Claims $userClaim ''))) { return $false } - } - if (-not [string]::IsNullOrWhiteSpace([string](Get-Value $Claims 'scp' ''))) { return $false } - if ($null -ne (Get-Value $Claims 'roles' $null)) { return $true } - - return $null -} - function Get-GraphErrorInfo { param($ErrorRecord) @@ -1052,21 +995,31 @@ else { } $ClientSecret = ConvertTo-SecureStringValue -Value $ClientSecret -Name 'ClientSecret' -$AccessToken = ConvertTo-SecureStringValue -Value $AccessToken -Name 'AccessToken' +$CertificatePassword = ConvertTo-SecureStringValue -Value $CertificatePassword -Name 'CertificatePassword' + +$certificateSourceCount = @( + [bool]$CertificateThumbprint, + ($null -ne $Certificate), + [bool]$CertificatePath +).Where({ $_ }).Count +if ($certificateSourceCount -gt 1) { + throw 'Supply exactly one certificate source: -CertificateThumbprint, -Certificate, or -CertificatePath.' +} +if ($CertificatePassword -and (-not $CertificatePath)) { + throw '-CertificatePassword can be used only with -CertificatePath.' +} $modes = @() -if ($UseExistingConnection) { $modes += 'ExistingConnection' } if ($Interactive) { $modes += 'Interactive' } -if ($UseDeviceCode) { $modes += 'DeviceCode' } -if ($AccessToken) { $modes += 'AccessToken' } -if ($CertificateThumbprint) { $modes += 'Certificate' } +if ($UseManagedIdentity) { $modes += 'ManagedIdentity' } +if ($CertificateThumbprint -or $Certificate -or $CertificatePath) { $modes += 'Certificate' } if ($ClientSecret) { $modes += 'ClientSecret' } if ($modes.Count -gt 1) { throw "Conflicting authentication options ($($modes -join ', ')). Supply exactly one." } if ($modes.Count -eq 0) { - throw 'No authentication method specified. Pass -Interactive (recommended), -UseDeviceCode, -UseExistingConnection, or -ClientId with -ClientSecret / -CertificateThumbprint.' + throw 'No authentication method specified. Pass -Interactive (recommended), -UseManagedIdentity, or -ClientId with -ClientSecret / -CertificateThumbprint / -Certificate / -CertificatePath.' } $mode = $modes[0] @@ -1075,31 +1028,45 @@ if ($mode -in @('ClientSecret', 'Certificate')) { if (-not $TenantId) { throw "-TenantId is required for $mode authentication." } } -if ($mode -ne 'ExistingConnection') { - $connect = @{ NoWelcome = $true; ErrorAction = 'Stop' } +$connect = @{ NoWelcome = $true; ErrorAction = 'Stop' } switch ($mode) { 'Interactive' { if ($TenantId) { $connect.TenantId = $TenantId } if ($ClientId) { $connect.ClientId = $ClientId } } - 'DeviceCode' { - $connect.UseDeviceCode = $true - if ($TenantId) { $connect.TenantId = $TenantId } - if ($ClientId) { $connect.ClientId = $ClientId } - } - 'AccessToken' { $connect.AccessToken = $AccessToken } 'ClientSecret' { $connect.TenantId = $TenantId $connect.ClientSecretCredential = [pscredential]::new($ClientId, $ClientSecret) } - 'Certificate' { - $connect.TenantId = $TenantId - $connect.ClientId = $ClientId - $connect.CertificateThumbprint = $CertificateThumbprint + 'Certificate' { + $connect.TenantId = $TenantId + $connect.ClientId = $ClientId + if ($Certificate) { + $connect.Certificate = $Certificate + } + elseif ($CertificatePath) { + if (-not (Test-Path -LiteralPath $CertificatePath)) { + throw "Certificate file not found: $CertificatePath" + } + $pfx = (Resolve-Path -LiteralPath $CertificatePath).ProviderPath + $connect.Certificate = if ($CertificatePassword) { + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword) + } + else { + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx) + } + } + else { + $connect.CertificateThumbprint = $CertificateThumbprint + } + } + 'ManagedIdentity' { + if ($ClientId) { throw '-ClientId cannot be used with -UseManagedIdentity. Only system-assigned managed identity is supported.' } + $connect.Identity = $true } } - if ($mode -in @('Interactive', 'DeviceCode')) { + if ($mode -eq 'Interactive') { $connected = $false for ($setIndex = 0; $setIndex -lt $delegatedScopeSets.Count; $setIndex++) { $connect.Scopes = $delegatedScopeSets[$setIndex] @@ -1119,11 +1086,10 @@ if ($mode -ne 'ExistingConnection') { else { Connect-MgGraph @connect } -} $mg = Get-MgContext if (-not $mg) { - throw 'Connect-MgGraph did not establish a Graph context. If you used -Interactive from a non-interactive host, the browser flow cannot complete - use -UseDeviceCode instead.' + throw 'Connect-MgGraph did not establish a Graph context. If you used -Interactive from a non-interactive host, the browser flow cannot complete - use an interactive host or run with app-only authentication.' } $authType = [string](Get-Value $mg 'AuthType' '') @@ -1132,29 +1098,6 @@ $ctxTenant = [string](Get-Value $mg 'TenantId' $TenantId) $callerId = [string](Get-Value $mg 'ClientId' '') $script:CallerAppId = $callerId -# A raw -AccessToken is always reported as 'Delegated' by Get-MgContext, even for a -# client-credentials token with no user. Ask the token instead. -if ($mode -eq 'AccessToken') { - $tokenPlain = '' - try { - $tokenPlain = [Net.NetworkCredential]::new('', $AccessToken).Password - } - catch { - $tokenPlain = '' - } - $claims = Get-JwtPayload -Token $tokenPlain - $fromToken = Test-JwtIsAppOnly -Claims $claims - if ($null -ne $fromToken) { $isAppOnly = [bool]$fromToken } - if ([string]::IsNullOrWhiteSpace($callerId) -and $null -ne $claims) { - $callerId = [string](Get-Value $claims 'appid' ([string](Get-Value $claims 'azp' ''))) - $script:CallerAppId = $callerId - } - if ($null -ne $claims) { - $tokenTenant = [string](Get-Value $claims 'tid' '') - if (-not [string]::IsNullOrWhiteSpace($tokenTenant)) { $ctxTenant = $tokenTenant } - } -} - if ($isAppOnly) { $who = if ([string]::IsNullOrWhiteSpace($callerId)) { '(unknown application)' } else { $callerId } Write-Host " Connected app-only as $who in tenant $ctxTenant [$mode]" -ForegroundColor Green @@ -1540,4 +1483,4 @@ else { if ($script:Failures.Count -gt 0) { exit 1 } -Complete-A365Log -Outcome 'Succeeded' \ No newline at end of file +Complete-A365Log -Outcome 'Succeeded' diff --git a/scripts/bulk-agent-registration/Remove-A365AgentUser.ps1 b/scripts/bulk-agent-registration/Remove-A365AgentUser.ps1 index 93890434..92f8c660 100644 --- a/scripts/bulk-agent-registration/Remove-A365AgentUser.ps1 +++ b/scripts/bulk-agent-registration/Remove-A365AgentUser.ps1 @@ -51,24 +51,17 @@ writes console output only. .PARAMETER TenantId - Directory (tenant) id. Optional for -Interactive / -UseDeviceCode; required for application - authentication. + Directory (tenant) id. Required for interactive, client secret, and certificate + authentication; optional for managed identity. .PARAMETER Interactive Sign in as a user (delegated auth). Mutually exclusive with other authentication modes; - -ClientId may select the app registration used for sign-in. - -.PARAMETER UseDeviceCode - Sign in as a user via the device code flow, for hosts that cannot complete a browser - redirect. Falls back to a reduced delegated scope set if the Agent 365 preview scopes are - not enabled in the tenant. - -.PARAMETER UseExistingConnection - Reuse an already-established Connect-MgGraph session instead of connecting again. + requires -ClientId for a caller-controlled public client authorized for AgentUser preview + scopes. .PARAMETER ClientId - Application (client) ID to authenticate as. Required with -ClientSecret or - -CertificateThumbprint. + Application (client) ID to authenticate as. Required with -Interactive, -ClientSecret, or + certificate authentication. .PARAMETER ClientSecret Client secret, as a SecureString or a plain string. Requires -ClientId and -TenantId. @@ -77,8 +70,17 @@ Thumbprint of a certificate to authenticate -ClientId with. Requires -ClientId and -TenantId. -.PARAMETER AccessToken - A pre-acquired Graph access token, as a SecureString or a plain string. +.PARAMETER Certificate + An X509Certificate2 to authenticate -ClientId with. Requires -ClientId and -TenantId. + +.PARAMETER CertificatePath + Path to a .pfx file to authenticate -ClientId with. Requires -ClientId and -TenantId. + +.PARAMETER CertificatePassword + Password for -CertificatePath, as a SecureString or plain string. + +.PARAMETER UseManagedIdentity + Authenticate with the host's system-assigned managed identity. .PARAMETER LogPath Write a timestamped log of this run. A path that names an existing directory (or ends in @@ -95,12 +97,13 @@ .EXAMPLE .\Remove-A365AgentUser.ps1 -AgentUserId a2a52f41-a150-4cce-98ad-462ac636c478 ` - -Interactive -InspectOnly + -TenantId $tenantId -ClientId $interactiveClientId -Interactive -InspectOnly Report what would be deleted, and delete nothing. .EXAMPLE - .\Remove-A365AgentUser.ps1 -AgentUserId $id -Permanent -Force -Interactive + .\Remove-A365AgentUser.ps1 -AgentUserId $id -TenantId $tenantId ` + -ClientId $interactiveClientId -Interactive -Permanent -Force Delete the agent user and purge it from deleted items. IRREVERSIBLE, and frees the UPN. @@ -130,13 +133,13 @@ param( [string] $TenantId, [switch] $Interactive, - [switch] $UseDeviceCode, - [switch] $UseExistingConnection, [string] $ClientId, [object] $ClientSecret, [string] $CertificateThumbprint, - [object] $AccessToken, - + [System.Security.Cryptography.X509Certificates.X509Certificate2] $Certificate, + [string] $CertificatePath, + [object] $CertificatePassword, + [switch] $UseManagedIdentity, # ===================================================================== # LOGGING # ===================================================================== @@ -685,58 +688,88 @@ $delegatedScopeSets = @( ) $ClientSecret = ConvertTo-SecureStringValue -Value $ClientSecret -Name 'ClientSecret' -$AccessToken = ConvertTo-SecureStringValue -Value $AccessToken -Name 'AccessToken' +$CertificatePassword = ConvertTo-SecureStringValue -Value $CertificatePassword -Name 'CertificatePassword' + +$certificateSourceCount = @( + [bool]$CertificateThumbprint, + ($null -ne $Certificate), + [bool]$CertificatePath +).Where({ $_ }).Count +if ($certificateSourceCount -gt 1) { + throw 'Supply exactly one certificate source: -CertificateThumbprint, -Certificate, or -CertificatePath.' +} +if ($CertificatePassword -and (-not $CertificatePath)) { + throw '-CertificatePassword can be used only with -CertificatePath.' +} $modes = @() -if ($UseExistingConnection) { $modes += 'ExistingConnection' } if ($Interactive) { $modes += 'Interactive' } -if ($UseDeviceCode) { $modes += 'DeviceCode' } -if ($AccessToken) { $modes += 'AccessToken' } -if ($CertificateThumbprint) { $modes += 'Certificate' } +if ($UseManagedIdentity) { $modes += 'ManagedIdentity' } +if ($CertificateThumbprint -or $Certificate -or $CertificatePath) { $modes += 'Certificate' } if ($ClientSecret) { $modes += 'ClientSecret' } if ($modes.Count -gt 1) { throw "Conflicting authentication options ($($modes -join ', ')). Supply exactly one." } if ($modes.Count -eq 0) { - throw 'No authentication method specified. Pass -Interactive (recommended), -UseDeviceCode, -UseExistingConnection, or -ClientId with -ClientSecret / -CertificateThumbprint.' + throw 'No authentication method specified. Pass -Interactive (recommended), -UseManagedIdentity, or -ClientId with -ClientSecret / -CertificateThumbprint / -Certificate / -CertificatePath.' } $mode = $modes[0] +if ($mode -eq 'Interactive' -and [string]::IsNullOrWhiteSpace($ClientId)) { + throw '-ClientId is required for interactive AgentUser removal because the caller-controlled public client must be authorized for the AgentUser preview scopes.' +} +if ($mode -eq 'Interactive' -and [string]::IsNullOrWhiteSpace($TenantId)) { + throw '-TenantId is required for interactive AgentUser removal.' +} if ($mode -in @('ClientSecret', 'Certificate')) { if (-not $ClientId) { throw "-ClientId is required for $mode authentication." } if (-not $TenantId) { throw "-TenantId is required for $mode authentication." } } -if ($mode -ne 'ExistingConnection') { - $connect = @{ NoWelcome = $true; ErrorAction = 'Stop' } +$connect = @{ NoWelcome = $true; ErrorAction = 'Stop' } switch ($mode) { 'Interactive' { if ($TenantId) { $connect.TenantId = $TenantId } if ($ClientId) { $connect.ClientId = $ClientId } } - 'DeviceCode' { - $connect.UseDeviceCode = $true - if ($TenantId) { $connect.TenantId = $TenantId } - if ($ClientId) { $connect.ClientId = $ClientId } - } - 'AccessToken' { $connect.AccessToken = $AccessToken } 'ClientSecret' { $connect.TenantId = $TenantId $connect.ClientSecretCredential = [pscredential]::new($ClientId, $ClientSecret) } - 'Certificate' { - $connect.TenantId = $TenantId - $connect.ClientId = $ClientId - $connect.CertificateThumbprint = $CertificateThumbprint + 'Certificate' { + $connect.TenantId = $TenantId + $connect.ClientId = $ClientId + if ($Certificate) { + $connect.Certificate = $Certificate + } + elseif ($CertificatePath) { + if (-not (Test-Path -LiteralPath $CertificatePath)) { + throw "Certificate file not found: $CertificatePath" + } + $pfx = (Resolve-Path -LiteralPath $CertificatePath).ProviderPath + $connect.Certificate = if ($CertificatePassword) { + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword) + } + else { + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx) + } + } + else { + $connect.CertificateThumbprint = $CertificateThumbprint + } + } + 'ManagedIdentity' { + if ($ClientId) { throw '-ClientId cannot be used with -UseManagedIdentity. Only system-assigned managed identity is supported.' } + $connect.Identity = $true } } - if ($mode -in @('Interactive', 'DeviceCode')) { + if ($mode -eq 'Interactive') { # The Agent 365 delegated scopes are preview and do not exist in every tenant. Entra rejects # the WHOLE scope string with AADSTS70011 if any single scope is unknown, so fall back to a # universally valid set rather than failing outright. Scope validation happens before a - # device code is issued, so a failed attempt does not burn a code. + # interactive prompt is shown, so a failed attempt does not burn a code. $connected = $false for ($setIndex = 0; $setIndex -lt $delegatedScopeSets.Count; $setIndex++) { $connect.Scopes = $delegatedScopeSets[$setIndex] @@ -756,11 +789,10 @@ if ($mode -ne 'ExistingConnection') { else { Connect-MgGraph @connect } -} $mg = Get-MgContext if (-not $mg) { - throw 'Connect-MgGraph did not establish a Graph context. If you used -Interactive from a non-interactive host, the browser flow cannot complete - use -UseDeviceCode instead.' + throw 'Connect-MgGraph did not establish a Graph context. If you used -Interactive from a non-interactive host, the browser flow cannot complete - use an interactive host or run with app-only authentication.' } $authType = [string](Get-Value $mg 'AuthType' '') @@ -921,4 +953,4 @@ Write-Host 'Done.' -ForegroundColor Green if ($PassThru) { $plan } if ($failures.Count -gt 0) { Complete-A365Log -Outcome 'Failed'; exit 1 } -Complete-A365Log -Outcome 'Succeeded' \ No newline at end of file +Complete-A365Log -Outcome 'Succeeded' diff --git a/scripts/bulk-agent-registration/Remove-A365Blueprint.ps1 b/scripts/bulk-agent-registration/Remove-A365Blueprint.ps1 index 09dd3697..54a11cef 100644 --- a/scripts/bulk-agent-registration/Remove-A365Blueprint.ps1 +++ b/scripts/bulk-agent-registration/Remove-A365Blueprint.ps1 @@ -44,21 +44,15 @@ default, so pointing this script at an ordinary app registration by mistake is refused. .PARAMETER TenantId - Directory (tenant) id. Optional for -Interactive / -UseDeviceCode; required for application - authentication. + Directory (tenant) id. Optional for -Interactive / -UseManagedIdentity; required for client secret + and certificate authentication. .PARAMETER Interactive Sign in as a user in the browser. -.PARAMETER UseDeviceCode - Sign in as a user using the device code flow, for hosts with no browser. - -.PARAMETER UseExistingConnection - Reuse the Connect-MgGraph session already established in this PowerShell process. - .PARAMETER ClientId - Application (client) ID to authenticate as. Required with -ClientSecret or - -CertificateThumbprint. + Application (client) ID to authenticate as. Required with -ClientSecret or certificate + authentication. .PARAMETER ClientSecret Client secret, as a SecureString or a plain string. Requires -ClientId and -TenantId. @@ -67,8 +61,17 @@ Thumbprint of a certificate to authenticate -ClientId with. Requires -ClientId and -TenantId. -.PARAMETER AccessToken - A pre-acquired Graph access token, as a SecureString or a plain string. +.PARAMETER Certificate + An X509Certificate2 to authenticate -ClientId with. Requires -ClientId and -TenantId. + +.PARAMETER CertificatePath + Path to a .pfx file to authenticate -ClientId with. Requires -ClientId and -TenantId. + +.PARAMETER CertificatePassword + Password for -CertificatePath, as a SecureString or plain string. + +.PARAMETER UseManagedIdentity + Authenticate with the host's system-assigned managed identity. .PARAMETER ScriptRoot Directory containing Remove-A365AgentUser.ps1 and Remove-A365AgentIdentity.ps1, used by the @@ -142,16 +145,16 @@ param( [string] $TenantId, [switch] $Interactive, - [switch] $UseDeviceCode, - [switch] $UseExistingConnection, [string] $ClientId, # [object], not [string]: an omitted [string] binds to '' , which ConvertTo-SecureStringValue # reports as "supplied but empty". [object] also lets a SecureString or PSCredential through # unconverted, which a [string] would silently stringify to its type name. [object] $ClientSecret, [string] $CertificateThumbprint, - [object] $AccessToken, - + [System.Security.Cryptography.X509Certificates.X509Certificate2] $Certificate, + [string] $CertificatePath, + [object] $CertificatePassword, + [switch] $UseManagedIdentity, [string] $ScriptRoot, # ===================================================================== @@ -702,21 +705,31 @@ $delegatedScopeSets = @( ) $ClientSecret = ConvertTo-SecureStringValue -Value $ClientSecret -Name 'ClientSecret' -$AccessToken = ConvertTo-SecureStringValue -Value $AccessToken -Name 'AccessToken' +$CertificatePassword = ConvertTo-SecureStringValue -Value $CertificatePassword -Name 'CertificatePassword' + +$certificateSourceCount = @( + [bool]$CertificateThumbprint, + ($null -ne $Certificate), + [bool]$CertificatePath +).Where({ $_ }).Count +if ($certificateSourceCount -gt 1) { + throw 'Supply exactly one certificate source: -CertificateThumbprint, -Certificate, or -CertificatePath.' +} +if ($CertificatePassword -and (-not $CertificatePath)) { + throw '-CertificatePassword can be used only with -CertificatePath.' +} $modes = @() -if ($UseExistingConnection) { $modes += 'ExistingConnection' } if ($Interactive) { $modes += 'Interactive' } -if ($UseDeviceCode) { $modes += 'DeviceCode' } -if ($AccessToken) { $modes += 'AccessToken' } -if ($CertificateThumbprint) { $modes += 'Certificate' } +if ($UseManagedIdentity) { $modes += 'ManagedIdentity' } +if ($CertificateThumbprint -or $Certificate -or $CertificatePath) { $modes += 'Certificate' } if ($ClientSecret) { $modes += 'ClientSecret' } if ($modes.Count -gt 1) { throw "Conflicting authentication options ($($modes -join ', ')). Supply exactly one." } if ($modes.Count -eq 0) { - throw 'No authentication method specified. Pass -Interactive (recommended), -UseDeviceCode, -UseExistingConnection, or -ClientId with -ClientSecret / -CertificateThumbprint.' + throw 'No authentication method specified. Pass -Interactive (recommended), -UseManagedIdentity, or -ClientId with -ClientSecret / -CertificateThumbprint / -Certificate / -CertificatePath.' } $mode = $modes[0] @@ -725,35 +738,49 @@ if ($mode -in @('ClientSecret', 'Certificate')) { if (-not $TenantId) { throw "-TenantId is required for $mode authentication." } } -if ($mode -ne 'ExistingConnection') { - $connect = @{ NoWelcome = $true; ErrorAction = 'Stop' } +$connect = @{ NoWelcome = $true; ErrorAction = 'Stop' } switch ($mode) { 'Interactive' { if ($TenantId) { $connect.TenantId = $TenantId } if ($ClientId) { $connect.ClientId = $ClientId } } - 'DeviceCode' { - $connect.UseDeviceCode = $true - if ($TenantId) { $connect.TenantId = $TenantId } - if ($ClientId) { $connect.ClientId = $ClientId } - } - 'AccessToken' { $connect.AccessToken = $AccessToken } 'ClientSecret' { $connect.TenantId = $TenantId $connect.ClientSecretCredential = [pscredential]::new($ClientId, $ClientSecret) } - 'Certificate' { - $connect.TenantId = $TenantId - $connect.ClientId = $ClientId - $connect.CertificateThumbprint = $CertificateThumbprint + 'Certificate' { + $connect.TenantId = $TenantId + $connect.ClientId = $ClientId + if ($Certificate) { + $connect.Certificate = $Certificate + } + elseif ($CertificatePath) { + if (-not (Test-Path -LiteralPath $CertificatePath)) { + throw "Certificate file not found: $CertificatePath" + } + $pfx = (Resolve-Path -LiteralPath $CertificatePath).ProviderPath + $connect.Certificate = if ($CertificatePassword) { + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword) + } + else { + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx) + } + } + else { + $connect.CertificateThumbprint = $CertificateThumbprint + } + } + 'ManagedIdentity' { + if ($ClientId) { throw '-ClientId cannot be used with -UseManagedIdentity. Only system-assigned managed identity is supported.' } + $connect.Identity = $true } } - if ($mode -in @('Interactive', 'DeviceCode')) { + if ($mode -eq 'Interactive') { # The Agent 365 delegated scopes are preview and do not exist in every tenant. Entra rejects # the WHOLE scope string with AADSTS70011 if any single scope is unknown, so fall back to a # universally valid set rather than failing outright. Scope validation happens before a - # device code is issued, so a failed attempt does not burn a code. + # interactive prompt is shown, so a failed attempt does not burn a code. $connected = $false for ($setIndex = 0; $setIndex -lt $delegatedScopeSets.Count; $setIndex++) { $connect.Scopes = $delegatedScopeSets[$setIndex] @@ -773,11 +800,10 @@ if ($mode -ne 'ExistingConnection') { else { Connect-MgGraph @connect } -} $mg = Get-MgContext if (-not $mg) { - throw 'Connect-MgGraph did not establish a Graph context. If you used -Interactive from a non-interactive host, the browser flow cannot complete - use -UseDeviceCode instead.' + throw 'Connect-MgGraph did not establish a Graph context. If you used -Interactive from a non-interactive host, the browser flow cannot complete - use an interactive host or run with app-only authentication.' } $authType = [string](Get-Value $mg 'AuthType' '') @@ -951,8 +977,14 @@ if ($dependents -gt 0) { $commonArgs = @{ Force = $true } if ($Permanent) { $commonArgs.Permanent = $true } if ($TenantId) { $commonArgs.TenantId = $TenantId } - # The cascade runs inside this script's Graph session, so it must not try to sign in again. - $commonArgs.UseExistingConnection = $true + if ($ClientId) { $commonArgs.ClientId = $ClientId } + if ($Interactive) { $commonArgs.Interactive = $true } + if ($UseManagedIdentity){ $commonArgs.UseManagedIdentity = $true } + if ($ClientSecret) { $commonArgs.ClientSecret = $ClientSecret } + if ($CertificateThumbprint) { $commonArgs.CertificateThumbprint = $CertificateThumbprint } + if ($Certificate) { $commonArgs.Certificate = $Certificate } + if ($CertificatePath) { $commonArgs.CertificatePath = $CertificatePath } + if ($CertificatePassword) { $commonArgs.CertificatePassword = $CertificatePassword } # Each cascaded script writes its own log file beside this one, under the same correlation id. if ($LogPath) { $commonArgs.LogPath = $LogPath } if ($LogIncludeSecrets) { $commonArgs.LogIncludeSecrets = $true } @@ -1051,4 +1083,4 @@ if ($PassThru) { } } -Complete-A365Log -Outcome 'Succeeded' \ No newline at end of file +Complete-A365Log -Outcome 'Succeeded' diff --git a/scripts/bulk-agent-registration/Update-A365AgentIdentity.ps1 b/scripts/bulk-agent-registration/Update-A365AgentIdentity.ps1 index 962eb897..261f9dc5 100644 --- a/scripts/bulk-agent-registration/Update-A365AgentIdentity.ps1 +++ b/scripts/bulk-agent-registration/Update-A365AgentIdentity.ps1 @@ -97,9 +97,6 @@ .PARAMETER UseManagedIdentity Authenticate with the host's managed identity, forwarded unchanged. -.PARAMETER AccessToken - A pre-acquired Graph access token. Accepts a string or a SecureString and is forwarded - unchanged, for the same reason as -ClientSecret above. .PARAMETER Interactive Sign in as a user instead of running as an application, forwarded unchanged. @@ -175,7 +172,6 @@ param( [string] $CertificatePath, [object] $CertificatePassword, [switch] $UseManagedIdentity, - [object] $AccessToken, [switch] $Interactive, [switch] $SkipPermissionCheck, @@ -202,7 +198,7 @@ $forwardable = @( 'Sponsor', 'Owner', 'Disabled', 'RequiredPermission', 'RequireOwnerAssignment' 'GrantAdminConsent', 'OutputJsonPath' 'ClientId', 'ClientSecret', 'CertificateThumbprint', 'Certificate', 'CertificatePath' - 'CertificatePassword', 'UseManagedIdentity', 'AccessToken', 'Interactive', 'SkipPermissionCheck' + 'CertificatePassword', 'UseManagedIdentity', 'Interactive', 'SkipPermissionCheck' ) $forward = @{ @@ -221,4 +217,4 @@ foreach ($k in $StepParameter.Keys) { $forward[$k] = $StepParameter[$k] } -& $step @forward -WhatIf:$WhatIfPreference \ No newline at end of file +& $step @forward -WhatIf:$WhatIfPreference diff --git a/scripts/bulk-agent-registration/Update-A365AgentRegistration.ps1 b/scripts/bulk-agent-registration/Update-A365AgentRegistration.ps1 index 67e95d98..80fa2fbc 100644 --- a/scripts/bulk-agent-registration/Update-A365AgentRegistration.ps1 +++ b/scripts/bulk-agent-registration/Update-A365AgentRegistration.ps1 @@ -81,9 +81,6 @@ .PARAMETER UseManagedIdentity Authenticate with the host's managed identity, forwarded unchanged. -.PARAMETER AccessToken - A pre-acquired Graph access token. Accepts a string or a SecureString and is forwarded - unchanged, for the same reason as -ClientSecret above. .PARAMETER Interactive Sign in as a user instead of running as an application, forwarded unchanged. Often @@ -152,7 +149,6 @@ param( [string] $CertificatePath, [object] $CertificatePassword, [switch] $UseManagedIdentity, - [object] $AccessToken, [switch] $Interactive, [switch] $SkipPermissionCheck, @@ -176,7 +172,7 @@ $forwardable = @( 'DisplayName', 'Description', 'Owner', 'OwnerId', 'AgentIdentityId', 'BlueprintAppId' 'SkipDisplayNameNormalization' 'ClientId', 'ClientSecret', 'CertificateThumbprint', 'Certificate', 'CertificatePath' - 'CertificatePassword', 'UseManagedIdentity', 'AccessToken', 'Interactive', 'SkipPermissionCheck' + 'CertificatePassword', 'UseManagedIdentity', 'Interactive', 'SkipPermissionCheck' ) $forward = @{ @@ -198,4 +194,4 @@ foreach ($k in $StepParameter.Keys) { $forward[$k] = $StepParameter[$k] } -& $step @forward -WhatIf:$WhatIfPreference \ No newline at end of file +& $step @forward -WhatIf:$WhatIfPreference diff --git a/scripts/bulk-agent-registration/Update-A365AgentUser.ps1 b/scripts/bulk-agent-registration/Update-A365AgentUser.ps1 index 2815fe83..460b9a4e 100644 --- a/scripts/bulk-agent-registration/Update-A365AgentUser.ps1 +++ b/scripts/bulk-agent-registration/Update-A365AgentUser.ps1 @@ -18,9 +18,8 @@ The user principal name cannot be changed after creation and is therefore not accepted here; the account is identified by -AgentUserId instead. - APP-ONLY AUTHENTICATION ONLY. New-A365AgentUser.ps1 has no -Interactive and no - -SkipPermissionCheck, so those parameters are deliberately absent from this script too - rather than being accepted and then failing downstream. + The same app-only and interactive delegated authentication methods as + New-A365AgentUser.ps1 are supported. The Graph work itself is done by New-A365AgentUser.ps1, which this script invokes in its update mode. That script reports failure by exiting with a non-zero code instead of @@ -63,7 +62,7 @@ .PARAMETER ClientId Application (client) ID to authenticate as. Forwarded unchanged to - New-A365AgentUser.ps1, which is app-only - see its help for the full authentication + New-A365AgentUser.ps1. See its help for the full app-only and delegated authentication reference. .PARAMETER ClientSecret @@ -91,12 +90,8 @@ .PARAMETER UseManagedIdentity Authenticate with the host's managed identity, forwarded unchanged. -.PARAMETER ManagedIdentityClientId - Client id of a user-assigned managed identity, forwarded unchanged. - -.PARAMETER AccessToken - A pre-acquired Graph access token. Accepts a string or a SecureString and is forwarded - unchanged, for the same reason as -ClientSecret above. +.PARAMETER Interactive + Sign in as a user instead of running as an application, forwarded unchanged. .PARAMETER StepParameter Escape hatch: a hashtable splatted into New-A365AgentUser.ps1 for parameters this wrapper @@ -122,7 +117,7 @@ Assign a licence to an agent user that already has a usage location. .NOTES - Requires New-A365AgentUser.ps1 beside this script, and app-only authentication. + Requires New-A365AgentUser.ps1 beside this script. #> #requires -Version 7 @@ -148,7 +143,6 @@ param( [string] $LicenseSkuPartNumber, [string[]] $DisabledPlans, - # No -Interactive or -SkipPermissionCheck: the step script has neither. # Forward credential objects unchanged so SecureString values are not stringified. [string] $ClientId, [object] $ClientSecret, @@ -158,8 +152,7 @@ param( [string] $CertificatePath, [object] $CertificatePassword, [switch] $UseManagedIdentity, - [string] $ManagedIdentityClientId, - [object] $AccessToken, + [switch] $Interactive, # Escape hatch for anything this wrapper does not surface. [hashtable] $StepParameter = @{}, @@ -193,7 +186,7 @@ $forwardable = @( 'AssignLicense', 'LicenseSkuId', 'LicenseSkuPartNumber', 'DisabledPlans' 'ClientId', 'ClientSecret', 'CertificateThumbprint', 'CertificateStoreLocation' 'Certificate', 'CertificatePath', 'CertificatePassword', 'UseManagedIdentity' - 'ManagedIdentityClientId', 'AccessToken' + 'Interactive' ) $forward = @{ @@ -221,4 +214,4 @@ $global:LASTEXITCODE = 0 # a caller cannot mistake a failed update for a successful one. if ($LASTEXITCODE -ne 0) { throw "New-A365AgentUser.ps1 exited with code $LASTEXITCODE. Its own error output above has the detail." -} \ No newline at end of file +} diff --git a/scripts/bulk-agent-registration/Update-A365Blueprint.ps1 b/scripts/bulk-agent-registration/Update-A365Blueprint.ps1 index 16da125a..e37a7b2f 100644 --- a/scripts/bulk-agent-registration/Update-A365Blueprint.ps1 +++ b/scripts/bulk-agent-registration/Update-A365Blueprint.ps1 @@ -81,9 +81,6 @@ .PARAMETER UseManagedIdentity Authenticate with the host's managed identity, forwarded unchanged. -.PARAMETER AccessToken - A pre-acquired Graph access token. Accepts a string or a SecureString and is forwarded - unchanged, for the same reason as -ClientSecret above. .PARAMETER Interactive Sign in as a user instead of running as an application, forwarded unchanged. @@ -157,7 +154,6 @@ param( [string] $CertificatePath, [object] $CertificatePassword, [switch] $UseManagedIdentity, - [object] $AccessToken, [switch] $Interactive, [switch] $SkipPermissionCheck, @@ -184,7 +180,7 @@ $forwardable = @( 'RequireOwnerAssignment', 'NewClientSecret', 'GrantAdminConsent' 'SkipInheritablePermissions', 'ManagedIdentityPrincipalId' 'ClientId', 'ClientSecret', 'CertificateThumbprint', 'Certificate', 'CertificatePath' - 'CertificatePassword', 'UseManagedIdentity', 'AccessToken', 'Interactive', 'SkipPermissionCheck' + 'CertificatePassword', 'UseManagedIdentity', 'Interactive', 'SkipPermissionCheck' ) $forward = @{ @@ -203,4 +199,4 @@ foreach ($k in $StepParameter.Keys) { $forward[$k] = $StepParameter[$k] } -& $step @forward -WhatIf:$WhatIfPreference \ No newline at end of file +& $step @forward -WhatIf:$WhatIfPreference From d3393f667729a9e4acb1084cf9d557ddcfcc01fb Mon Sep 17 00:00:00 2001 From: Walter Luna Date: Thu, 1 Oct 2026 14:22:23 +0100 Subject: [PATCH 06/10] Update bulk authentication coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 744dc687-79b1-4181-a626-57d46d06260f --- .../tests/Execution.Tests.ps1 | 73 +++++++++++++++++-- .../tests/PermissionDeclaration.Tests.ps1 | 67 ++++++++++++++--- .../tests/SecureAuthenticationInput.Tests.ps1 | 40 ++++------ .../fixtures/A365-AutomationOrchestrator.ps1 | 2 +- .../New-A365AgentBlueprint.ps1 | 8 +- .../update-wrappers/New-A365AgentIdentity.ps1 | 8 +- .../New-A365AgentRegistration.ps1 | 8 +- .../update-wrappers/New-A365AgentUser.ps1 | 8 +- 8 files changed, 148 insertions(+), 66 deletions(-) diff --git a/scripts/bulk-agent-registration/tests/Execution.Tests.ps1 b/scripts/bulk-agent-registration/tests/Execution.Tests.ps1 index a3af94a7..d62e6153 100644 --- a/scripts/bulk-agent-registration/tests/Execution.Tests.ps1 +++ b/scripts/bulk-agent-registration/tests/Execution.Tests.ps1 @@ -157,8 +157,6 @@ Test-Case '-WhatIf plans the run without calling the orchestrator' { Write-Host 'Execution: auth preflight checks run before any row' -ForegroundColor Cyan function New-A365AuthPreflightCsv { - # A single AgentUser row is enough to exercise the AgentUser-vs-app-only-auth rule; - # it also must never validation-fail on its own, so every other requirement is met. $rows = @( New-A365TestCsvRow @{ ObjectType = 'Blueprint'; Key = 'BP-Auth'; DisplayName = 'Auth Blueprint'; Sponsor = 'sponsor@contoso.com' } New-A365TestCsvRow @{ ObjectType = 'AgentIdentity'; Key = 'AI-Auth'; ParentKey = 'BP-Auth'; DisplayName = 'Auth Identity'; Sponsor = 'sponsor@contoso.com' } @@ -206,16 +204,79 @@ Test-Case 'Conflicting authentication options are refused before any row runs' { } } -Test-Case 'An AgentUser row with non-app-only auth (-Interactive) is refused before any row runs' { +Test-Case 'Client secret authentication without a client id is refused before any row runs' { $csv = New-A365AuthPreflightCsv $global:A365BulkExecFixtureCalls = [System.Collections.Generic.List[object]]::new() try { & $script:WrapperPath -CsvPath $csv -TenantId 'tenant-id' -ScriptRoot $script:FixturesDir ` - -Interactive -Confirm:$false ` + -ClientSecret 'a-secret' -Confirm:$false *> $null + Assert-Equal 1 $LASTEXITCODE + Assert-Equal 0 $global:A365BulkExecFixtureCalls.Count 'Nothing may run when app authentication has no client id.' + } + finally { + Remove-Variable -Name A365BulkExecFixtureCalls -Scope Global -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $csv -ErrorAction SilentlyContinue + } +} + +Test-Case 'Multiple certificate sources are refused before any row runs' { + $csv = New-A365AuthPreflightCsv + $global:A365BulkExecFixtureCalls = [System.Collections.Generic.List[object]]::new() + try { + & $script:WrapperPath -CsvPath $csv -TenantId 'tenant-id' -ScriptRoot $script:FixturesDir ` + -ClientId 'test-client' -CertificateThumbprint 'thumbprint' ` + -Certificate ([System.Security.Cryptography.X509Certificates.X509Certificate2]::new()) ` + -Confirm:$false *> $null + Assert-Equal 1 $LASTEXITCODE + Assert-Equal 0 $global:A365BulkExecFixtureCalls.Count 'Nothing may run when certificate sources conflict.' + } + finally { + Remove-Variable -Name A365BulkExecFixtureCalls -Scope Global -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $csv -ErrorAction SilentlyContinue + } +} + +Test-Case 'Managed identity with a client id is refused before any row runs' { + $csv = New-A365AuthPreflightCsv + $global:A365BulkExecFixtureCalls = [System.Collections.Generic.List[object]]::new() + try { + & $script:WrapperPath -CsvPath $csv -TenantId 'tenant-id' -ScriptRoot $script:FixturesDir ` + -UseManagedIdentity -ClientId 'user-assigned-client-id' -Confirm:$false *> $null + Assert-Equal 1 $LASTEXITCODE + Assert-Equal 0 $global:A365BulkExecFixtureCalls.Count 'Nothing may run when user-assigned managed identity selection is attempted.' + } + finally { + Remove-Variable -Name A365BulkExecFixtureCalls -Scope Global -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $csv -ErrorAction SilentlyContinue + } +} + +Test-Case 'Interactive authentication is forwarded to AgentUser rows' { + $csv = New-A365AuthPreflightCsv + $global:A365BulkExecFixtureCalls = [System.Collections.Generic.List[object]]::new() + try { + & $script:WrapperPath -CsvPath $csv -TenantId 'tenant-id' -ScriptRoot $script:FixturesDir ` + -ClientId 'interactive-client' -Interactive -Confirm:$false ` *> $null $exitCode = $LASTEXITCODE - Assert-Equal 1 $exitCode 'The AgentUser/app-only-auth mismatch must be refused, not attempted.' - Assert-Equal 0 $global:A365BulkExecFixtureCalls.Count 'This must be a true preflight: not even the Blueprint/AgentIdentity rows may run first.' + Assert-Equal 0 $exitCode 'Interactive authentication is supported for every bulk entity type.' + Assert-Equal 3 $global:A365BulkExecFixtureCalls.Count 'Every row should reach the orchestrator fixture.' + Assert-True (@($global:A365BulkExecFixtureCalls | Where-Object { -not $_.Interactive }).Count -eq 0) 'Interactive must be forwarded to every row.' + } + finally { + Remove-Variable -Name A365BulkExecFixtureCalls -Scope Global -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $csv -ErrorAction SilentlyContinue + } +} + +Test-Case 'Interactive AgentUser onboarding without a client id is refused before any row runs' { + $csv = New-A365AuthPreflightCsv + $global:A365BulkExecFixtureCalls = [System.Collections.Generic.List[object]]::new() + try { + & $script:WrapperPath -CsvPath $csv -TenantId 'tenant-id' -ScriptRoot $script:FixturesDir ` + -Interactive -Confirm:$false *> $null + Assert-Equal 1 $LASTEXITCODE + Assert-Equal 0 $global:A365BulkExecFixtureCalls.Count 'AgentUser preview scopes require a caller-controlled interactive client.' } finally { Remove-Variable -Name A365BulkExecFixtureCalls -Scope Global -ErrorAction SilentlyContinue diff --git a/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 b/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 index 1c35fd2f..dc4dd228 100644 --- a/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 +++ b/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 @@ -87,9 +87,28 @@ function Assert-A365Access { function New-A365DeclarationFixture { $roleNames = @('CustomSecAttributeAssignment.ReadWrite.All', 'CustomSecAttributeDefinition.Read.All') + $agentUserScopeNames = @( + 'User.Read', + 'User.Read.All', + 'User.ReadWrite.All', + 'Directory.Read.All', + 'Organization.Read.All', + 'Application.Read.All', + 'AgentIdentity.Read.All', + 'AgentIdentity.ReadWrite.All', + 'AgentIdUser.ReadWrite.All', + 'LicenseAssignment.ReadWrite.All', + 'DelegatedPermissionGrant.ReadWrite.All', + 'AppRoleAssignment.ReadWrite.All' + ) + $agentUserScopes = @() + for ($i = 0; $i -lt $agentUserScopeNames.Count; $i++) { + $agentUserScopes += @{ id = "scope-agent-user-$($i + 1)"; value = $agentUserScopeNames[$i] } + } return [pscustomobject]@{ RoleNames = $roleNames ScopeNames = $roleNames + AgentUserScopeNames = $agentUserScopeNames Graph = [pscustomobject]@{ id = 'graph-sp' appRoles = @( @@ -99,7 +118,7 @@ function New-A365DeclarationFixture { oauth2PermissionScopes = @( @{ id = 'scope-a'; value = $roleNames[0] } @{ id = 'scope-b'; value = $roleNames[1] } - ) + ) + $agentUserScopes } ExistingAccess = @( @{ resourceAppId = $graphAppId; resourceAccess = @( @@ -123,6 +142,7 @@ function New-A365DeclarationFixture { PatchFails = $false MissingApplication = $false MissingPrincipal = $false + PublicClientEnabled = $true } } @@ -131,7 +151,9 @@ function Invoke-A365DeclarationScenario { [Parameter(Mandatory)] $State, [switch] $DryRun, [bool] $SkipConsent = $true, - [switch] $WriteReport + [switch] $WriteReport, + [string] $Scenario = 'AgentIdentity', + [string[]] $AdditionalAppRole = @() ) function Connect-GraphSession { @@ -160,8 +182,11 @@ function Invoke-A365DeclarationScenario { "/applications(appId='automation-app')?`$select=id,appId,displayName" { return [pscustomobject]@{ id = 'app-object'; appId = 'automation-app'; displayName = 'Test automation' } } - "/applications/app-object?`$select=requiredResourceAccess" { - return [pscustomobject]@{ requiredResourceAccess = $State.ExistingAccess } + "/applications/app-object?`$select=requiredResourceAccess,isFallbackPublicClient" { + return [pscustomobject]@{ + requiredResourceAccess = $State.ExistingAccess + isFallbackPublicClient = $State.PublicClientEnabled + } } "/servicePrincipals(appId='automation-app')?`$select=id,appId,displayName" { if ($State.MissingPrincipal) { return $null } @@ -222,7 +247,8 @@ function Invoke-A365DeclarationScenario { TenantId = 'test-tenant' AppId = 'automation-app' DisplayName = 'Test automation' - Scenario = 'AgentIdentity' + Scenario = $Scenario + AdditionalAppRole = @($AdditionalAppRole) SkipAppRole = @( 'AgentIdentity.Create.All', 'AgentIdentity.Read.All', 'AgentIdentity.ReadWrite.All', 'AgentIdentityBlueprint.Read.All', 'Application.Read.All', 'User.Read.All', @@ -564,6 +590,26 @@ Test-Case 'Accepted duplicate-only cleanup is Applied and a cleaned second run p Assert-A365PermissionNames $next.ScopeNames $rerunJson.delegatedScopesDeclared } +Test-Case 'AgentUser scenario requests delegated scopes even when registration delegated scopes are disabled by scenario' { + $state = New-A365DeclarationFixture + $state.PublicClientEnabled = $false + $run = Invoke-A365DeclarationScenario -State $state -Scenario 'AgentUser' -AdditionalAppRole $state.RoleNames[0] + $json = $run.Json | ConvertFrom-Json -AsHashtable + Assert-True ($json['delegatedScopesRequested'] -is [array]) 'delegatedScopesRequested must serialize as an array.' + + foreach ($scope in $state.AgentUserScopeNames) { + Assert-True (@($json.delegatedScopesRequested | Where-Object { $_ -eq $scope }).Count -gt 0) ` + "AgentUser scenario must request delegated scope '$scope'." + } + + $publicClientPatch = @($state.Calls | Where-Object { + $_.Method -eq 'PATCH' -and + $_.Body.ContainsKey('isFallbackPublicClient') + }) + Assert-Count $publicClientPatch 1 'The automation app must enable public-client flows for device-code authentication.' + Assert-True $publicClientPatch[0].Body.isFallbackPublicClient 'Public-client flows must be enabled, not disabled.' +} + Test-Case 'Native WhatIf keeps existing declarations and planned additions without PATCH or report-file write' { $state = New-A365DeclarationFixture $run = Invoke-A365DeclarationScenario -State $state -DryRun -WriteReport @@ -714,11 +760,12 @@ Test-Case 'Declining declarations does not change independent grant semantics or Test-Case 'New application declaration reads and writes retry directory replication 404 responses' { $state = New-A365DeclarationFixture $state.MissingApplication = $true + $state.PublicClientEnabled = $false Invoke-A365DeclarationScenario -State $state | Out-Null $declarationRead = @($state.Calls | Where-Object { $_.Method -eq 'GET' -and - $_.Uri -eq '/applications/app-object?$select=requiredResourceAccess' + $_.Uri -eq '/applications/app-object?$select=requiredResourceAccess,isFallbackPublicClient' }) Assert-Count $declarationRead 1 Assert-True $declarationRead[0].RetryOnNotFound 'The immediate post-create declaration read must tolerate replication lag.' @@ -726,8 +773,10 @@ Test-Case 'New application declaration reads and writes retry directory replicat $declarationWrites = @($state.Calls | Where-Object { $_.Method -eq 'PATCH' -and $_.Uri -eq '/applications/app-object' }) - Assert-Count $declarationWrites 1 - Assert-True $declarationWrites[0].RetryOnNotFound 'The immediate post-create declaration write must tolerate replication lag.' + Assert-Count $declarationWrites 2 'Public-client and permission declaration writes must both be exercised.' + foreach ($write in $declarationWrites) { + Assert-True $write.RetryOnNotFound 'Every immediate post-create declaration write must tolerate replication lag.' + } } Test-Case 'Existing application declaration reconciliation does not use creation-only retries' { @@ -735,7 +784,7 @@ Test-Case 'Existing application declaration reconciliation does not use creation Invoke-A365DeclarationScenario -State $state | Out-Null $declarationCalls = @($state.Calls | Where-Object { - $_.Uri -eq '/applications/app-object?$select=requiredResourceAccess' -or + $_.Uri -eq '/applications/app-object?$select=requiredResourceAccess,isFallbackPublicClient' -or ($_.Method -eq 'PATCH' -and $_.Uri -eq '/applications/app-object') }) Assert-Count $declarationCalls 2 diff --git a/scripts/bulk-agent-registration/tests/SecureAuthenticationInput.Tests.ps1 b/scripts/bulk-agent-registration/tests/SecureAuthenticationInput.Tests.ps1 index c7356175..4c87d991 100644 --- a/scripts/bulk-agent-registration/tests/SecureAuthenticationInput.Tests.ps1 +++ b/scripts/bulk-agent-registration/tests/SecureAuthenticationInput.Tests.ps1 @@ -40,23 +40,20 @@ $wrapperCases = @( foreach ($case in $wrapperCases) { - Test-Case "$($case.Name): a SecureString -ClientSecret/-CertificatePassword/-AccessToken all reach the step script as the exact live objects bound on the command line" { + Test-Case "$($case.Name): a SecureString -ClientSecret/-CertificatePassword both reach the step script as the exact live objects bound on the command line" { $global:A365UpdateWrapperFixtureCalls = [System.Collections.Generic.List[object]]::new() $clientSecret = $null $certificatePassword = $null - $accessToken = $null try { $clientSecret = New-A365SecureStringMarker -Value 'client-secret-marker' $certificatePassword = New-A365SecureStringMarker -Value 'cert-password-marker' - $accessToken = New-A365SecureStringMarker -Value 'access-token-marker' $splat = @{ - TenantId = 'tenant-id' - ScriptRoot = $script:UpdateWrapperFixturesDir - ClientId = 'test-client' - ClientSecret = $clientSecret - CertificatePassword = $certificatePassword - AccessToken = $accessToken - Confirm = $false + TenantId = 'tenant-id' + ScriptRoot = $script:UpdateWrapperFixturesDir + ClientId = 'test-client' + ClientSecret = $clientSecret + CertificatePassword = $certificatePassword + Confirm = $false } $splat[$case.IdParam] = $case.IdValue @@ -65,35 +62,28 @@ foreach ($case in $wrapperCases) { Assert-Equal 1 $global:A365UpdateWrapperFixtureCalls.Count "$($case.Name): exactly one call must reach the fixture step script per invocation." $call = $global:A365UpdateWrapperFixtureCalls[0] - # SecureString inputs must remain SecureString values at the step boundary. Assert-Equal 'System.Security.SecureString' $call.ClientSecretType "$($case.Name): -ClientSecret must still be a SecureString when it reaches the step script." Assert-Equal 'System.Security.SecureString' $call.CertificatePasswordType "$($case.Name): -CertificatePassword must still be a SecureString when it reaches the step script." - Assert-Equal 'System.Security.SecureString' $call.AccessTokenType "$($case.Name): -AccessToken must still be a SecureString when it reaches the step script." - - # Wrappers must forward the exact live credential objects. Assert-Equal ([System.Runtime.CompilerServices.RuntimeHelpers]::GetHashCode($clientSecret)) $call.ClientSecretIdentity "$($case.Name): -ClientSecret must reach the step script as the identical object instance, not a copy." Assert-Equal ([System.Runtime.CompilerServices.RuntimeHelpers]::GetHashCode($certificatePassword)) $call.CertificatePasswordIdentity "$($case.Name): -CertificatePassword must reach the step script as the identical object instance, not a copy." - Assert-Equal ([System.Runtime.CompilerServices.RuntimeHelpers]::GetHashCode($accessToken)) $call.AccessTokenIdentity "$($case.Name): -AccessToken must reach the step script as the identical object instance, not a copy." } finally { Remove-Variable -Name A365UpdateWrapperFixtureCalls -Scope Global -ErrorAction SilentlyContinue if ($clientSecret) { $clientSecret.Dispose() } if ($certificatePassword) { $certificatePassword.Dispose() } - if ($accessToken) { $accessToken.Dispose() } } } - Test-Case "$($case.Name): a plain string -ClientSecret/-CertificatePassword/-AccessToken remain plain strings at the step script (backward compatibility)" { + Test-Case "$($case.Name): a plain string -ClientSecret/-CertificatePassword both remain plain strings at the step script (backward compatibility)" { $global:A365UpdateWrapperFixtureCalls = [System.Collections.Generic.List[object]]::new() try { $splat = @{ - TenantId = 'tenant-id' - ScriptRoot = $script:UpdateWrapperFixturesDir - ClientId = 'test-client' - ClientSecret = 'plain-client-secret' - CertificatePassword = 'plain-cert-password' - AccessToken = 'plain-access-token' - Confirm = $false + TenantId = 'tenant-id' + ScriptRoot = $script:UpdateWrapperFixturesDir + ClientId = 'test-client' + ClientSecret = 'plain-client-secret' + CertificatePassword = 'plain-cert-password' + Confirm = $false } $splat[$case.IdParam] = $case.IdValue @@ -101,11 +91,9 @@ foreach ($case in $wrapperCases) { Assert-Equal 1 $global:A365UpdateWrapperFixtureCalls.Count "$($case.Name): exactly one call must reach the fixture step script per invocation." $call = $global:A365UpdateWrapperFixtureCalls[0] - # Plain-string authentication remains backward compatible. Assert-Equal 'System.String' $call.ClientSecretType "$($case.Name): a plain string -ClientSecret must still be forwarded as a plain string." Assert-Equal 'System.String' $call.CertificatePasswordType "$($case.Name): a plain string -CertificatePassword must still be forwarded as a plain string." - Assert-Equal 'System.String' $call.AccessTokenType "$($case.Name): a plain string -AccessToken must still be forwarded as a plain string." } finally { Remove-Variable -Name A365UpdateWrapperFixtureCalls -Scope Global -ErrorAction SilentlyContinue diff --git a/scripts/bulk-agent-registration/tests/fixtures/A365-AutomationOrchestrator.ps1 b/scripts/bulk-agent-registration/tests/fixtures/A365-AutomationOrchestrator.ps1 index 5820d88b..e19d0a28 100644 --- a/scripts/bulk-agent-registration/tests/fixtures/A365-AutomationOrchestrator.ps1 +++ b/scripts/bulk-agent-registration/tests/fixtures/A365-AutomationOrchestrator.ps1 @@ -91,7 +91,6 @@ param( [string] $CertificatePath, [object] $CertificatePassword, [switch] $UseManagedIdentity, - [object] $AccessToken, [switch] $Interactive, [switch] $SkipPermissionCheck, @@ -124,6 +123,7 @@ if ($callCapture -and $null -ne $callCapture.Value) { PrincipalName = $AgentUserPrincipalName LogCorrelationId = $LogCorrelationId ClientSecretIdentity = $secretIdentity + Interactive = [bool]$Interactive }) } diff --git a/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentBlueprint.ps1 b/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentBlueprint.ps1 index 67c5d425..bd073700 100644 --- a/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentBlueprint.ps1 +++ b/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentBlueprint.ps1 @@ -4,7 +4,7 @@ <# Deterministic stand-in for New-A365AgentBlueprint.ps1's update mode, used only by SecureAuthenticationInput.Tests.ps1 (via Update-A365Blueprint.ps1 -ScriptRoot pointing - here). Records enough about -ClientSecret, -CertificatePassword and -AccessToken to prove + here). Records enough about -ClientSecret and -CertificatePassword to prove that Update-A365Blueprint.ps1 forwards the exact object it was given - never a copy, a re-typed value, or a stringified one - without making any Graph call. #> @@ -32,9 +32,7 @@ param( [object] $Certificate, [string] $CertificatePath, [object] $CertificatePassword, - [switch] $UseManagedIdentity, - [object] $AccessToken, - [switch] $Interactive, + [switch] $UseManagedIdentity, [switch] $Interactive, [switch] $SkipPermissionCheck ) @@ -53,8 +51,6 @@ if ($callCapture -and $null -ne $callCapture.Value) { ClientSecretType = if ($null -ne $ClientSecret) { $ClientSecret.GetType().FullName } else { $null } CertificatePasswordIdentity = if ($null -ne $CertificatePassword) { [System.Runtime.CompilerServices.RuntimeHelpers]::GetHashCode($CertificatePassword) } else { $null } CertificatePasswordType = if ($null -ne $CertificatePassword) { $CertificatePassword.GetType().FullName } else { $null } - AccessTokenIdentity = if ($null -ne $AccessToken) { [System.Runtime.CompilerServices.RuntimeHelpers]::GetHashCode($AccessToken) } else { $null } - AccessTokenType = if ($null -ne $AccessToken) { $AccessToken.GetType().FullName } else { $null } }) } diff --git a/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentIdentity.ps1 b/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentIdentity.ps1 index b57160bc..8893c35c 100644 --- a/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentIdentity.ps1 +++ b/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentIdentity.ps1 @@ -4,7 +4,7 @@ <# Deterministic stand-in for New-A365AgentIdentity.ps1's update mode, used only by SecureAuthenticationInput.Tests.ps1 (via Update-A365AgentIdentity.ps1 -ScriptRoot - pointing here). Records enough about -ClientSecret, -CertificatePassword and -AccessToken + pointing here). Records enough about -ClientSecret and -CertificatePassword to prove that Update-A365AgentIdentity.ps1 forwards the exact object it was given - never a copy, a re-typed value, or a stringified one - without making any Graph call. #> @@ -33,9 +33,7 @@ param( [object] $Certificate, [string] $CertificatePath, [object] $CertificatePassword, - [switch] $UseManagedIdentity, - [object] $AccessToken, - [switch] $Interactive, + [switch] $UseManagedIdentity, [switch] $Interactive, [switch] $SkipPermissionCheck ) @@ -54,8 +52,6 @@ if ($callCapture -and $null -ne $callCapture.Value) { ClientSecretType = if ($null -ne $ClientSecret) { $ClientSecret.GetType().FullName } else { $null } CertificatePasswordIdentity = if ($null -ne $CertificatePassword) { [System.Runtime.CompilerServices.RuntimeHelpers]::GetHashCode($CertificatePassword) } else { $null } CertificatePasswordType = if ($null -ne $CertificatePassword) { $CertificatePassword.GetType().FullName } else { $null } - AccessTokenIdentity = if ($null -ne $AccessToken) { [System.Runtime.CompilerServices.RuntimeHelpers]::GetHashCode($AccessToken) } else { $null } - AccessTokenType = if ($null -ne $AccessToken) { $AccessToken.GetType().FullName } else { $null } }) } diff --git a/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentRegistration.ps1 b/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentRegistration.ps1 index 54d011a4..397df141 100644 --- a/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentRegistration.ps1 +++ b/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentRegistration.ps1 @@ -4,7 +4,7 @@ <# Deterministic stand-in for New-A365AgentRegistration.ps1's update mode, used only by SecureAuthenticationInput.Tests.ps1 (via Update-A365AgentRegistration.ps1 -ScriptRoot - pointing here). Records enough about -ClientSecret, -CertificatePassword and -AccessToken + pointing here). Records enough about -ClientSecret and -CertificatePassword to prove that Update-A365AgentRegistration.ps1 forwards the exact object it was given - never a copy, a re-typed value, or a stringified one - without making any Graph call. #> @@ -29,9 +29,7 @@ param( [object] $Certificate, [string] $CertificatePath, [object] $CertificatePassword, - [switch] $UseManagedIdentity, - [object] $AccessToken, - [switch] $Interactive, + [switch] $UseManagedIdentity, [switch] $Interactive, [switch] $SkipPermissionCheck ) @@ -50,8 +48,6 @@ if ($callCapture -and $null -ne $callCapture.Value) { ClientSecretType = if ($null -ne $ClientSecret) { $ClientSecret.GetType().FullName } else { $null } CertificatePasswordIdentity = if ($null -ne $CertificatePassword) { [System.Runtime.CompilerServices.RuntimeHelpers]::GetHashCode($CertificatePassword) } else { $null } CertificatePasswordType = if ($null -ne $CertificatePassword) { $CertificatePassword.GetType().FullName } else { $null } - AccessTokenIdentity = if ($null -ne $AccessToken) { [System.Runtime.CompilerServices.RuntimeHelpers]::GetHashCode($AccessToken) } else { $null } - AccessTokenType = if ($null -ne $AccessToken) { $AccessToken.GetType().FullName } else { $null } }) } diff --git a/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentUser.ps1 b/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentUser.ps1 index 687cea7e..c4b8801e 100644 --- a/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentUser.ps1 +++ b/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentUser.ps1 @@ -4,7 +4,7 @@ <# Deterministic stand-in for New-A365AgentUser.ps1's update mode, used only by SecureAuthenticationInput.Tests.ps1 (via Update-A365AgentUser.ps1 -ScriptRoot pointing - here). Records enough about -ClientSecret, -CertificatePassword and -AccessToken to prove + here). Records enough about -ClientSecret and -CertificatePassword to prove that Update-A365AgentUser.ps1 forwards the exact object it was given - never a copy, a re-typed value, or a stringified one - without making any Graph call. @@ -37,9 +37,7 @@ param( [object] $Certificate, [string] $CertificatePath, [object] $CertificatePassword, - [switch] $UseManagedIdentity, - [string] $ManagedIdentityClientId, - [object] $AccessToken + [switch] $UseManagedIdentity ) Set-StrictMode -Version Latest @@ -58,8 +56,6 @@ if ($callCapture -and $null -ne $callCapture.Value) { ClientSecretType = if ($null -ne $ClientSecret) { $ClientSecret.GetType().FullName } else { $null } CertificatePasswordIdentity = if ($null -ne $CertificatePassword) { [System.Runtime.CompilerServices.RuntimeHelpers]::GetHashCode($CertificatePassword) } else { $null } CertificatePasswordType = if ($null -ne $CertificatePassword) { $CertificatePassword.GetType().FullName } else { $null } - AccessTokenIdentity = if ($null -ne $AccessToken) { [System.Runtime.CompilerServices.RuntimeHelpers]::GetHashCode($AccessToken) } else { $null } - AccessTokenType = if ($null -ne $AccessToken) { $AccessToken.GetType().FullName } else { $null } }) } From 764176799b0a1aecc2b3e79cd0d0f9e4ff0dfeab Mon Sep 17 00:00:00 2001 From: Walter Luna Date: Thu, 1 Oct 2026 14:22:45 +0100 Subject: [PATCH 07/10] Document supported bulk authentication methods Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 744dc687-79b1-4181-a626-57d46d06260f --- CHANGELOG.md | 1 + scripts/bulk-agent-registration/readme.md | 29 ++++++++++++----------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59aa369b..9b5f9497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g - `a365 develop get-token --device-code` — forces device code auth for Microsoft Graph scopes the Windows WAM broker rejects (e.g. Exchange `MailboxSettings.ReadWrite`, `ExchangeMessageTrace.Read.All`). ### Fixed +- Bulk onboarding and update/remove wrappers now use a unified auth contract that supports client secret, certificate, system-assigned managed identity, and interactive delegated sign-in while removing deprecated token and tool-based auth paths. - Setup no longer fails to detect the Agent 365 CLI application in tenants where it is not yet provisioned, and reports lookup errors instead of silently switching your configured client app (#489). - The first-party Agent 365 CLI app now uses device code authentication when Windows Account Manager is unavailable, avoiding unsupported browser-response errors in WSL, macOS, and Linux (#489). - `setup all --authmode s2s` no longer prints spurious "Action Required" PowerShell steps when the agent identity already inherits its app roles from the blueprint, and now retries the grant automatically before falling back to manual steps (#460). diff --git a/scripts/bulk-agent-registration/readme.md b/scripts/bulk-agent-registration/readme.md index 7a049d5b..cecabf26 100644 --- a/scripts/bulk-agent-registration/readme.md +++ b/scripts/bulk-agent-registration/readme.md @@ -251,11 +251,12 @@ Exactly one authentication method must be supplied. Supplying two is refused up | --- | --- | --- | --- | | Client secret | `-ClientId -ClientSecret` | Yes | Prefer `$env:A365_CLIENT_SECRET` over passing the value on the command line. | | Certificate | `-ClientId -CertificateThumbprint`
`-Certificate \| -CertificatePath` | Yes | Recommended for production. `-CertificatePath` also accepts `-CertificatePassword`. | -| Managed identity | `-UseManagedIdentity` | Yes | For Azure-hosted automation. `-ClientId` selects a user-assigned identity. | -| Access token | `-AccessToken` | Depends | A pre-obtained Microsoft Graph token. | -| Interactive | `-Interactive` | No | Signs in as a user. Cannot be used with `-NewAgentUser`. | +| Managed identity | `-UseManagedIdentity` | Yes | For Azure-hosted automation. System-assigned identity only. | +| Interactive | `-Interactive` | No | Signs in as a user and uses delegated scopes. AgentUser operations also require `-ClientId` for a caller-controlled public client. | -> **Important: `-NewAgentUser` requires app-only authentication.** `New-A365AgentUser.ps1` supports client secret, certificate and managed identity only — it has no interactive mode. The orchestrator refuses the combination before any phase runs, rather than failing after the blueprint and identity have already been created. +> **Important:** Interactive runs are delegated, so authorization is the combination of consented scopes and the signed-in user's directory roles. App-only runs are authorized by application roles (`roles` claim) instead. +> +> `New-A365AutomationApp.ps1` uses `-AuthCertificateThumbprint`, `-AuthCertificate`, or `-AuthCertificatePath` plus optional `-AuthCertificatePassword` for the credential that authenticates the bootstrap run. Its unprefixed `-CertificateThumbprint` and `-CertificatePath` parameters identify the certificate credential to add to the automation application. When delegated scopes are selected, the script also enables public-client flows so the generated application can be used for interactive device-code authentication. ### 5.1 Mixed authentication for the registration phase @@ -328,7 +329,7 @@ If the vault still uses access policies rather than RBAC, grant secret set and g | `-Certificate / -CertificateThumbprint` | A signed RS256 client assertion — the Graph SDK will not mint a token for another audience | | `-UseManagedIdentity` | IMDS, or IDENTITY_ENDPOINT on App Service and Functions | | `-Interactive` | A signed-in Azure session (az login or Connect-AzAccount) | -| `-AccessToken` | Not possible — supply `-KeyVaultAccessToken` | +| `-Interactive` without an Azure session (`az login` or `Connect-AzAccount`) | Not possible — supply `-KeyVaultAccessToken` | ### 6.4 What gets stored @@ -345,7 +346,7 @@ The write is confirmed by reading the version back. If the vault write fails for ## 7. Parameter reference -75 parameters, grouped by the phase they configure. Every setting for a phase carries that phase's prefix, so a parameter that belongs to a phase you did not select is reported as ignored rather than silently doing nothing. +Parameters are grouped by the phase they configure. Every setting for a phase carries that phase's prefix, so a parameter that belongs to a phase you did not select is reported as ignored rather than silently doing nothing. ### 7.1 Authentication and connection @@ -359,7 +360,6 @@ The write is confirmed by reading the version back. If the vault write fails for | `-CertificatePath` | String | Path to a .pfx file. | | `-CertificatePassword` | Object | Password for the .pfx. | | `-UseManagedIdentity` | Switch | Authenticate as an Azure managed identity. | -| `-AccessToken` | Object | A pre-obtained Microsoft Graph bearer token. | | `-Interactive` | Switch | Sign in as a user. | | `-SkipPermissionCheck` | Switch | Skip the app-role pre-flight check. | | `-ScriptRoot` | String | Directory holding the step scripts, if not alongside the orchestrator. | @@ -407,7 +407,7 @@ The write is confirmed by reading the version back. If the vault write fails for | Parameter | Purpose | | --- | --- | -| `-NewAgentUser` | Create the agent user. App-only authentication required. | +| `-NewAgentUser` | Create the agent user using app-only or interactive delegated authentication. | | `-UpdateAgentUser ` | Update an existing agent user. | | `-AgentUserPrincipalName` | UPN. Mandatory with `-NewAgentUser`; there is no fallback. | | `-AgentUserDisplayName` | Display name. | @@ -531,7 +531,7 @@ One `-UseExistingAgentIdentity` serves both phases: ```powershell .\A365-AutomationOrchestrator.ps1 ` - -TenantId -Interactive ` + -TenantId -ClientId -Interactive ` -RemoveAgentRegistration ` -RemoveAgentUser ` -RemoveAgentIdentity ` @@ -635,7 +635,7 @@ Each takes `-TenantId` and its own object id as the only mandatory parameters, p Merge behavior differs by attribute, and it is not uniform. Custom security attributes merge per attribute, though a multi-valued attribute that is written is replaced wholesale. Agent identity tags merge — the script reads the current set and writes the union, so adding one tag does not drop the others. Agent identity owners are additive. Registration ownerIds REPLACE the whole collection, so list every owner you want to keep. -Like the step scripts they wrap (section 7.1), `-ClientSecret`, `-CertificatePassword` and `-AccessToken` on all four scripts accept either a plain string or a `SecureString` and are forwarded to the step script unchanged - the exact object bound on the command line is the exact object the step script receives, never re-typed, copied or stringified in between. Plain strings remain supported for compatibility but still produce the step script's own command-line-exposure warning. +Like the step scripts they wrap (section 7.1), `-ClientSecret` and `-CertificatePassword` on all four scripts accept either a plain string or a `SecureString` and are forwarded to the step script unchanged - the exact object bound on the command line is the exact object the step script receives, never re-typed, copied or stringified in between. Plain strings remain supported for compatibility but still produce the step script's own command-line-exposure warning. ## 12. Quick start checklist @@ -648,7 +648,8 @@ Like the step scripts they wrap (section 7.1), `-ClientSecret`, `-CertificatePas 2. **Create the automation application** ```powershell - .\New-A365AutomationApp.ps1 -TenantId -DisplayName 'A365 Provisioning Automation' -Scenario All -NewClientSecret + .\New-A365AutomationApp.ps1 -TenantId -Interactive ` + -DisplayName 'A365 Provisioning Automation' -Scenario All -NewClientSecret ``` 3. **Have an administrator consent the Graph app roles** @@ -685,7 +686,7 @@ Like the step scripts they wrap (section 7.1), `-ClientSecret`, `-CertificatePas `summary.consentActionRequired` lists anything an administrator still has to finish. It is empty when the run is complete. -> **Security reminders.** Prefer a certificate or a federated managed identity over a client secret in production. Client secrets created by these scripts are intended for development. If a secret is ever exposed — in a transcript, a chat, a ticket or a screenshot — rotate it immediately: possession of the secret is possession of every permission the application holds. +> **Security reminders.** Prefer a certificate or a system-assigned managed identity over a client secret in production. Client secrets created by these scripts are intended for development. If a secret is ever exposed — in a transcript, a chat, a ticket or a screenshot — rotate it immediately: possession of the secret is possession of every permission the application holds. ## 13. Bulk CSV onboarding (A365-BulkOnboarding.ps1) @@ -704,10 +705,10 @@ The orchestrator is invoked in-process with the PowerShell call operator, in the | `-ScriptRoot` | Directory containing `A365-AutomationOrchestrator.ps1`. Defaults to this script's own directory - keep the two files together, or pass this explicitly. | | `-OutputJsonPath` | Write one aggregate JSON report (`A365BulkProvisioningRunReport`) covering every row (see 13.6). | | `-IncludeBlueprintSecretsInOutput` | Include any blueprint client secret in the aggregate report. Off by default, like the orchestrator's own switch of the same name. | -| `-ClientId`, `-ClientSecret`, `-CertificateThumbprint`, `-Certificate`, `-CertificatePath`, `-CertificatePassword`, `-UseManagedIdentity`, `-AccessToken`, `-Interactive`, `-SkipPermissionCheck` | Authentication, forwarded verbatim to every row's orchestrator call. Exactly one method must be supplied - see section 5 and section 7.1. | +| `-ClientId`, `-ClientSecret`, `-CertificateThumbprint`, `-Certificate`, `-CertificatePath`, `-CertificatePassword`, `-UseManagedIdentity`, `-Interactive`, `-SkipPermissionCheck` | Authentication, forwarded to every row's orchestrator call. Exactly one method must be supplied; interactive CSVs containing AgentUser rows also require `-ClientId`. See section 5 and section 7.1. | | `-LogPath`, `-LogIncludeSecrets`, `-LogCorrelationId` | Logging, forwarded to every row's orchestrator call so every log file produced by the run - the orchestrator's and every step script's - shares one correlation id. A correlation id is generated when omitted, exactly like the orchestrator. | -`A365-BulkOnboarding.ps1` supports `-WhatIf` and `-Confirm` (`ConfirmImpact = 'Medium'`). The CSV is always read and validated first, before the `-WhatIf`/`-Confirm` prompt; under `-WhatIf` (or a declined `-Confirm`), the full dependency plan is printed and no orchestrator call is made. For a real run, authentication is checked once before any row is attempted: exactly one authentication method must be supplied, and a CSV with AgentUser rows is refused unless that method is app-only (client secret, certificate, or managed identity) - the orchestrator would otherwise reject every AgentUser row identically. +`A365-BulkOnboarding.ps1` supports `-WhatIf` and `-Confirm` (`ConfirmImpact = 'Medium'`). The CSV is always read and validated first, before the `-WhatIf`/`-Confirm` prompt; under `-WhatIf` (or a declined `-Confirm`), the full dependency plan is printed and no orchestrator call is made. For a real run, authentication is checked once before any row is attempted and exactly one supported method must be supplied. ### 13.3 CSV schema From 22d51fd646ce7d447622d46b82b5c8fc24f54340 Mon Sep 17 00:00:00 2001 From: Walter Luna Date: Fri, 2 Oct 2026 14:56:37 +0100 Subject: [PATCH 08/10] Fix interactive automation app authentication Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 744dc687-79b1-4181-a626-57d46d06260f --- .../New-A365AutomationApp.ps1 | 10 ++-- .../tests/SecureAuthenticationInput.Tests.ps1 | 55 +++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 b/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 index 91740037..03f64337 100644 --- a/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 +++ b/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 @@ -884,10 +884,12 @@ finally { } $certificateSources = @( - [bool]$CertificateThumbprint, - ($null -ne $Certificate), - [bool]$CertificatePath - ) | Where-Object { $_ } + @( + [bool]$CertificateThumbprint, + ($null -ne $Certificate), + [bool]$CertificatePath + ) | Where-Object { $_ } + ) if ($certificateSources.Count -gt 1) { throw 'Supply exactly one certificate source: -AuthCertificateThumbprint, -AuthCertificate, or -AuthCertificatePath.' } diff --git a/scripts/bulk-agent-registration/tests/SecureAuthenticationInput.Tests.ps1 b/scripts/bulk-agent-registration/tests/SecureAuthenticationInput.Tests.ps1 index 4c87d991..3759698c 100644 --- a/scripts/bulk-agent-registration/tests/SecureAuthenticationInput.Tests.ps1 +++ b/scripts/bulk-agent-registration/tests/SecureAuthenticationInput.Tests.ps1 @@ -224,4 +224,59 @@ Test-Case 'Get-AppOnlyGraphToken rejects an explicit null -ClientSecret before a } } +$script:AutomationAppScriptPath = (Resolve-Path (Join-Path $PSScriptRoot '..' 'New-A365AutomationApp.ps1')).ProviderPath +$automationAuthSource = Get-A365ExtractedFunctionSource -Path $script:AutomationAppScriptPath ` + -FunctionName @('Connect-GraphSession', 'ConvertTo-SecureStringValue', 'Test-HasProperty') +. ([scriptblock]::Create($automationAuthSource)) + +Test-Case 'Connect-GraphSession accepts interactive authentication without a certificate source' { + $previousEnvironmentSecret = $env:A365_CLIENT_SECRET + $global:A365ConnectMgGraphCalled = $false + try { + $env:A365_CLIENT_SECRET = $null + + function Get-Module { + [CmdletBinding()] + param([switch] $ListAvailable, [string] $Name) + [pscustomobject]@{ Name = $Name } + } + function Import-Module { + [CmdletBinding()] + param([Parameter(Position = 0)] $Name) + } + function Connect-MgGraph { + [CmdletBinding()] + param( + [switch] $NoWelcome, + [string] $TenantId, + [string] $ClientId, + [string[]] $Scopes, + [pscredential] $ClientSecretCredential, + [object] $Certificate, + [string] $CertificateThumbprint, + [switch] $Identity + ) + $global:A365ConnectMgGraphCalled = $true + } + function Get-MgContext { + [pscustomobject]@{ + TenantId = 'tenant-id' + Account = 'operator@contoso.com' + AuthType = 'Delegated' + } + } + + $context = Connect-GraphSession -TenantId 'tenant-id' -Interactive + + Assert-True $global:A365ConnectMgGraphCalled 'Interactive authentication without a certificate must reach Connect-MgGraph.' + Assert-Equal 'Interactive' $context.Mode 'Interactive authentication must remain selected when no certificate source is supplied.' + Assert-False $context.IsAppOnly 'Interactive authentication must produce a delegated context.' + } + finally { + $env:A365_CLIENT_SECRET = $previousEnvironmentSecret + Remove-Item -Path Function:\Get-Module, Function:\Import-Module, Function:\Connect-MgGraph, Function:\Get-MgContext -ErrorAction SilentlyContinue + Remove-Variable -Name A365ConnectMgGraphCalled -Scope Global -ErrorAction SilentlyContinue + } +} + Get-A365TestResults From 9e757d3cd47a75fabc904dd1892daa477a6f5545 Mon Sep 17 00:00:00 2001 From: Walter Luna Date: Tue, 6 Oct 2026 14:58:17 +0100 Subject: [PATCH 09/10] Load bulk authentication PFX keys ephemerally Prevent certificate authentication from persisting imported private keys to user or machine profile stores. Add coverage for password-protected and passwordless PFX loaders and include the registration fixture formatting update. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 744dc687-79b1-4181-a626-57d46d06260f --- .../New-A365AgentBlueprint.ps1 | 5 +-- .../New-A365AgentIdentity.ps1 | 5 +-- .../New-A365AgentRegistration.ps1 | 5 +-- .../Remove-A365AgentIdentity.ps1 | 5 +-- .../Remove-A365AgentRegistration.ps1 | 5 +-- .../Remove-A365AgentUser.ps1 | 5 +-- .../Remove-A365Blueprint.ps1 | 5 +-- .../tests/SecureAuthenticationInput.Tests.ps1 | 33 +++++++++++++++++++ .../New-A365AgentRegistration.ps1 | 3 +- 9 files changed, 56 insertions(+), 15 deletions(-) diff --git a/scripts/bulk-agent-registration/New-A365AgentBlueprint.ps1 b/scripts/bulk-agent-registration/New-A365AgentBlueprint.ps1 index abc52d19..3e04bb01 100644 --- a/scripts/bulk-agent-registration/New-A365AgentBlueprint.ps1 +++ b/scripts/bulk-agent-registration/New-A365AgentBlueprint.ps1 @@ -1902,11 +1902,12 @@ finally { throw "Certificate file not found: $CertificatePath" } $pfx = (Resolve-Path -LiteralPath $CertificatePath).ProviderPath + $keyStorageFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet $connect.Certificate = if ($CertificatePassword) { - [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword) + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword, $keyStorageFlags) } else { - [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx) + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, [string]::Empty, $keyStorageFlags) } } else { diff --git a/scripts/bulk-agent-registration/New-A365AgentIdentity.ps1 b/scripts/bulk-agent-registration/New-A365AgentIdentity.ps1 index bf11a75d..9260fa43 100644 --- a/scripts/bulk-agent-registration/New-A365AgentIdentity.ps1 +++ b/scripts/bulk-agent-registration/New-A365AgentIdentity.ps1 @@ -2046,11 +2046,12 @@ finally { throw "Certificate file not found: $CertificatePath" } $pfx = (Resolve-Path -LiteralPath $CertificatePath).ProviderPath + $keyStorageFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet $connect.Certificate = if ($CertificatePassword) { - [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword) + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword, $keyStorageFlags) } else { - [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx) + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, [string]::Empty, $keyStorageFlags) } } else { diff --git a/scripts/bulk-agent-registration/New-A365AgentRegistration.ps1 b/scripts/bulk-agent-registration/New-A365AgentRegistration.ps1 index 8af168b9..9b31e0ed 100644 --- a/scripts/bulk-agent-registration/New-A365AgentRegistration.ps1 +++ b/scripts/bulk-agent-registration/New-A365AgentRegistration.ps1 @@ -1186,11 +1186,12 @@ finally { throw "Certificate file not found: $CertificatePath" } $pfx = (Resolve-Path -LiteralPath $CertificatePath).ProviderPath + $keyStorageFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet $connect.Certificate = if ($CertificatePassword) { - [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword) + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword, $keyStorageFlags) } else { - [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx) + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, [string]::Empty, $keyStorageFlags) } } else { diff --git a/scripts/bulk-agent-registration/Remove-A365AgentIdentity.ps1 b/scripts/bulk-agent-registration/Remove-A365AgentIdentity.ps1 index 8358afda..e74e5fdf 100644 --- a/scripts/bulk-agent-registration/Remove-A365AgentIdentity.ps1 +++ b/scripts/bulk-agent-registration/Remove-A365AgentIdentity.ps1 @@ -740,11 +740,12 @@ $connect = @{ NoWelcome = $true; ErrorAction = 'Stop' } throw "Certificate file not found: $CertificatePath" } $pfx = (Resolve-Path -LiteralPath $CertificatePath).ProviderPath + $keyStorageFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet $connect.Certificate = if ($CertificatePassword) { - [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword) + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword, $keyStorageFlags) } else { - [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx) + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, [string]::Empty, $keyStorageFlags) } } else { diff --git a/scripts/bulk-agent-registration/Remove-A365AgentRegistration.ps1 b/scripts/bulk-agent-registration/Remove-A365AgentRegistration.ps1 index fae1be70..113bfc2b 100644 --- a/scripts/bulk-agent-registration/Remove-A365AgentRegistration.ps1 +++ b/scripts/bulk-agent-registration/Remove-A365AgentRegistration.ps1 @@ -1049,11 +1049,12 @@ $connect = @{ NoWelcome = $true; ErrorAction = 'Stop' } throw "Certificate file not found: $CertificatePath" } $pfx = (Resolve-Path -LiteralPath $CertificatePath).ProviderPath + $keyStorageFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet $connect.Certificate = if ($CertificatePassword) { - [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword) + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword, $keyStorageFlags) } else { - [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx) + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, [string]::Empty, $keyStorageFlags) } } else { diff --git a/scripts/bulk-agent-registration/Remove-A365AgentUser.ps1 b/scripts/bulk-agent-registration/Remove-A365AgentUser.ps1 index 92f8c660..d3505986 100644 --- a/scripts/bulk-agent-registration/Remove-A365AgentUser.ps1 +++ b/scripts/bulk-agent-registration/Remove-A365AgentUser.ps1 @@ -748,11 +748,12 @@ $connect = @{ NoWelcome = $true; ErrorAction = 'Stop' } throw "Certificate file not found: $CertificatePath" } $pfx = (Resolve-Path -LiteralPath $CertificatePath).ProviderPath + $keyStorageFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet $connect.Certificate = if ($CertificatePassword) { - [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword) + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword, $keyStorageFlags) } else { - [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx) + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, [string]::Empty, $keyStorageFlags) } } else { diff --git a/scripts/bulk-agent-registration/Remove-A365Blueprint.ps1 b/scripts/bulk-agent-registration/Remove-A365Blueprint.ps1 index 54a11cef..97b681b8 100644 --- a/scripts/bulk-agent-registration/Remove-A365Blueprint.ps1 +++ b/scripts/bulk-agent-registration/Remove-A365Blueprint.ps1 @@ -759,11 +759,12 @@ $connect = @{ NoWelcome = $true; ErrorAction = 'Stop' } throw "Certificate file not found: $CertificatePath" } $pfx = (Resolve-Path -LiteralPath $CertificatePath).ProviderPath + $keyStorageFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet $connect.Certificate = if ($CertificatePassword) { - [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword) + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword, $keyStorageFlags) } else { - [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx) + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, [string]::Empty, $keyStorageFlags) } } else { diff --git a/scripts/bulk-agent-registration/tests/SecureAuthenticationInput.Tests.ps1 b/scripts/bulk-agent-registration/tests/SecureAuthenticationInput.Tests.ps1 index 3759698c..3edf72dc 100644 --- a/scripts/bulk-agent-registration/tests/SecureAuthenticationInput.Tests.ps1 +++ b/scripts/bulk-agent-registration/tests/SecureAuthenticationInput.Tests.ps1 @@ -279,4 +279,37 @@ Test-Case 'Connect-GraphSession accepts interactive authentication without a cer } } +$ephemeralPfxScripts = @( + 'New-A365AgentBlueprint.ps1' + 'New-A365AgentIdentity.ps1' + 'New-A365AgentRegistration.ps1' + 'Remove-A365Blueprint.ps1' + 'Remove-A365AgentIdentity.ps1' + 'Remove-A365AgentUser.ps1' + 'Remove-A365AgentRegistration.ps1' +) + +foreach ($scriptName in $ephemeralPfxScripts) { + Test-Case "$scriptName loads authentication PFX private keys ephemerally" { + $scriptPath = (Resolve-Path (Join-Path $PSScriptRoot '..' $scriptName)).ProviderPath + $source = Get-Content -LiteralPath $scriptPath -Raw + + Assert-True ($source.Contains( + '[System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet' + )) "$scriptName must keep imported authentication private keys in memory instead of persisting them to a user or machine profile." + Assert-True ($source.Contains( + '[System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword, $keyStorageFlags)' + )) "$scriptName must apply ephemeral key storage when the PFX is password protected." + Assert-True ($source.Contains( + '[System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, [string]::Empty, $keyStorageFlags)' + )) "$scriptName must apply ephemeral key storage when the PFX has no password." + Assert-False ($source.Contains( + '[System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword)' + )) "$scriptName must not use the default key store for a password-protected PFX." + Assert-False ($source.Contains( + '[System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx)' + )) "$scriptName must not use the default key store for a passwordless PFX." + } +} + Get-A365TestResults diff --git a/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentRegistration.ps1 b/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentRegistration.ps1 index 397df141..5d16ab2b 100644 --- a/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentRegistration.ps1 +++ b/scripts/bulk-agent-registration/tests/fixtures/update-wrappers/New-A365AgentRegistration.ps1 @@ -29,7 +29,8 @@ param( [object] $Certificate, [string] $CertificatePath, [object] $CertificatePassword, - [switch] $UseManagedIdentity, [switch] $Interactive, + [switch] $UseManagedIdentity, + [switch] $Interactive, [switch] $SkipPermissionCheck ) From 8dc03d3ec6876998c76939c7ec9636c7edeb20f7 Mon Sep 17 00:00:00 2001 From: Walter Luna Date: Tue, 6 Oct 2026 18:01:09 +0100 Subject: [PATCH 10/10] implement suggestions from copilot Forward the resolved tenant through blueprint removal cascades and require a caller-controlled client ID before deleting AgentUsers interactively. Limit routine AgentUser interactive tokens to provisioning scopes, request app-role assignment permissions only for permission configuration, and remove the unused delegated-grant scope from automation app declarations. Add regression coverage for cascade authentication and least-privilege interactive scope selection, and document the supported bulk authentication contract in the release notes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 744dc687-79b1-4181-a626-57d46d06260f --- CHANGELOG.md | 1 + .../New-A365AgentUser.ps1 | 5 +- .../New-A365AutomationApp.ps1 | 3 +- .../Remove-A365Blueprint.ps1 | 16 +- .../AgentUserInteractiveScopes.Tests.ps1 | 74 ++++++ .../tests/PermissionDeclaration.Tests.ps1 | 5 +- .../RemovalCascadeAuthentication.Tests.ps1 | 231 ++++++++++++++++++ 7 files changed, 324 insertions(+), 11 deletions(-) create mode 100644 scripts/bulk-agent-registration/tests/AgentUserInteractiveScopes.Tests.ps1 create mode 100644 scripts/bulk-agent-registration/tests/RemovalCascadeAuthentication.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9367e48d..6a45f3d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ Blueprint agents that export telemetry through the app-only S2S endpoint don't n - `a365 develop get-token --device-code` — forces device code auth for Microsoft Graph scopes the Windows WAM broker rejects (e.g. Exchange `MailboxSettings.ReadWrite`, `ExchangeMessageTrace.Read.All`). ### Fixed +- Bulk onboarding and update/remove wrappers now use a unified authentication contract that supports client secret, certificate, system-assigned managed identity, and interactive delegated sign-in while removing deprecated token and tool-based authentication paths (#505). - `a365 create-instance` now reports an ambiguous government-cloud environment as a configuration error with guidance to select a specific cloud (#478). - Setup now warns before replacing a stale stored blueprint ID with the sole application matching the configured display name (#478). - Messaging endpoint create and delete overrides now reject non-HTTPS URLs and URLs containing user information, query strings, or fragments (#478). diff --git a/scripts/bulk-agent-registration/New-A365AgentUser.ps1 b/scripts/bulk-agent-registration/New-A365AgentUser.ps1 index 3dd9e19d..a3449e8c 100644 --- a/scripts/bulk-agent-registration/New-A365AgentUser.ps1 +++ b/scripts/bulk-agent-registration/New-A365AgentUser.ps1 @@ -1160,14 +1160,13 @@ function Get-InteractiveDelegatedScopes { 'AgentIdentity.Read.All', 'AgentIdentity.ReadWrite.All', 'AgentIdUser.ReadWrite.All', - 'LicenseAssignment.ReadWrite.All', - 'DelegatedPermissionGrant.ReadWrite.All', - 'AppRoleAssignment.ReadWrite.All')) { + 'LicenseAssignment.ReadWrite.All')) { $null = $scopes.Add($scope) } if ($ConfigurePermissions) { $null = $scopes.Add('Application.ReadWrite.All') + $null = $scopes.Add('AppRoleAssignment.ReadWrite.All') } return @($scopes) diff --git a/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 b/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 index 03f64337..93d8f8cf 100644 --- a/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 +++ b/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 @@ -1066,7 +1066,7 @@ $script:RegistrationDelegatedScopes = @( ) # New-A365AgentUser.ps1 can run interactively (delegated), so these scopes are declared on the -# automation app for the user-create/update/manager/license and per-identity consent operations. +# automation app for routine provisioning and optional permission bootstrap operations. $script:AgentUserDelegatedScopes = @( 'User.Read' 'User.Read.All' @@ -1078,7 +1078,6 @@ $script:AgentUserDelegatedScopes = @( 'AgentIdentity.ReadWrite.All' 'AgentIdUser.ReadWrite.All' 'LicenseAssignment.ReadWrite.All' - 'DelegatedPermissionGrant.ReadWrite.All' 'AppRoleAssignment.ReadWrite.All' ) diff --git a/scripts/bulk-agent-registration/Remove-A365Blueprint.ps1 b/scripts/bulk-agent-registration/Remove-A365Blueprint.ps1 index 97b681b8..ab4810d9 100644 --- a/scripts/bulk-agent-registration/Remove-A365Blueprint.ps1 +++ b/scripts/bulk-agent-registration/Remove-A365Blueprint.ps1 @@ -975,9 +975,18 @@ if ($dependents -gt 0) { } } - $commonArgs = @{ Force = $true } + $identityIds = @($identities | ForEach-Object { [string](Get-Value $_ 'id' '') } | Where-Object { $_ }) + $userIds = @($agentUsers | ForEach-Object { [string](Get-Value $_ 'id' '') } | Where-Object { $_ }) + + if ([string]::IsNullOrWhiteSpace($ctxTenant)) { + throw 'The connected Graph context did not provide a tenant ID, so the dependent removal scripts cannot be invoked safely.' + } + if ($mode -eq 'Interactive' -and $userIds.Count -gt 0 -and [string]::IsNullOrWhiteSpace($ClientId)) { + throw '-ClientId is required for an interactive blueprint cascade that removes AgentUsers because the caller-controlled public client must be authorized for the AgentUser preview scopes.' + } + + $commonArgs = @{ Force = $true; TenantId = $ctxTenant } if ($Permanent) { $commonArgs.Permanent = $true } - if ($TenantId) { $commonArgs.TenantId = $TenantId } if ($ClientId) { $commonArgs.ClientId = $ClientId } if ($Interactive) { $commonArgs.Interactive = $true } if ($UseManagedIdentity){ $commonArgs.UseManagedIdentity = $true } @@ -991,9 +1000,6 @@ if ($dependents -gt 0) { if ($LogIncludeSecrets) { $commonArgs.LogIncludeSecrets = $true } if ($LogCorrelationId) { $commonArgs.LogCorrelationId = $LogCorrelationId } - $identityIds = @($identities | ForEach-Object { [string](Get-Value $_ 'id' '') } | Where-Object { $_ }) - $userIds = @($agentUsers | ForEach-Object { [string](Get-Value $_ 'id' '') } | Where-Object { $_ }) - if ($PSCmdlet.ShouldProcess("$($identities.Count) agent identities and $($agentUsers.Count) agent users under '$appName'", 'Delete')) { if ($userIds.Count -gt 0) { & $userScript @commonArgs -AgentUserId $userIds diff --git a/scripts/bulk-agent-registration/tests/AgentUserInteractiveScopes.Tests.ps1 b/scripts/bulk-agent-registration/tests/AgentUserInteractiveScopes.Tests.ps1 new file mode 100644 index 00000000..6884a6ee --- /dev/null +++ b/scripts/bulk-agent-registration/tests/AgentUserInteractiveScopes.Tests.ps1 @@ -0,0 +1,74 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# + Verifies that routine interactive AgentUser provisioning does not request tenant-wide + permission-management scopes that are needed only for permission bootstrap. +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force + +function Get-A365FunctionSource { + param( + [Parameter(Mandatory)][string] $Path, + [Parameter(Mandatory)][string] $FunctionName + ) + + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $Path, + [ref]$tokens, + [ref]$errors + ) + if ($errors.Count -gt 0) { + throw "Could not parse '$Path': $($errors[0].Message)" + } + + $definition = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq $FunctionName + }, $true) + if (-not $definition) { + throw "Function '$FunctionName' was not found in '$Path'." + } + + return $definition.Extent.Text +} + +$agentUserScript = (Resolve-Path (Join-Path $PSScriptRoot '..' 'New-A365AgentUser.ps1')).ProviderPath +. ([scriptblock]::Create((Get-A365FunctionSource -Path $agentUserScript -FunctionName 'Get-InteractiveDelegatedScopes'))) + +Test-Case 'Routine interactive AgentUser scopes exclude privileged permission-management scopes' { + $script:ConfigurePermissions = $false + $scopes = @(Get-InteractiveDelegatedScopes) + + Assert-False ($scopes -contains 'DelegatedPermissionGrant.ReadWrite.All') ` + 'Routine provisioning never writes delegated grants and must not request tenant-wide delegated-grant management.' + Assert-False ($scopes -contains 'AppRoleAssignment.ReadWrite.All') ` + 'Routine provisioning does not create app-role assignments and must not require permission-bootstrap consent.' + Assert-False ($scopes -contains 'Application.ReadWrite.All') ` + 'Routine provisioning must not request application-write access used only by permission bootstrap.' + Assert-True ($scopes -contains 'AgentIdUser.ReadWrite.All') ` + 'The least-privilege change must preserve the delegated scope required to create and update AgentUsers.' +} + +Test-Case 'ConfigurePermissions requests only the permission-management scopes it uses' { + $script:ConfigurePermissions = $true + $scopes = @(Get-InteractiveDelegatedScopes) + + Assert-True ($scopes -contains 'Application.ReadWrite.All') ` + 'Permission bootstrap writes requiredResourceAccess on the target application.' + Assert-True ($scopes -contains 'AppRoleAssignment.ReadWrite.All') ` + 'Permission bootstrap reads and creates appRoleAssignments for the target service principal.' + Assert-False ($scopes -contains 'DelegatedPermissionGrant.ReadWrite.All') ` + 'Permission bootstrap creates app-role assignments, not delegated permission grants.' +} + +Remove-Variable -Name ConfigurePermissions -Scope Script -ErrorAction SilentlyContinue + +Get-A365TestResults diff --git a/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 b/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 index dc4dd228..7d22d630 100644 --- a/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 +++ b/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 @@ -98,7 +98,6 @@ function New-A365DeclarationFixture { 'AgentIdentity.ReadWrite.All', 'AgentIdUser.ReadWrite.All', 'LicenseAssignment.ReadWrite.All', - 'DelegatedPermissionGrant.ReadWrite.All', 'AppRoleAssignment.ReadWrite.All' ) $agentUserScopes = @() @@ -601,6 +600,10 @@ Test-Case 'AgentUser scenario requests delegated scopes even when registration d Assert-True (@($json.delegatedScopesRequested | Where-Object { $_ -eq $scope }).Count -gt 0) ` "AgentUser scenario must request delegated scope '$scope'." } + Assert-False (@($json.delegatedScopesRequested | Where-Object { + $_ -eq 'DelegatedPermissionGrant.ReadWrite.All' + }).Count -gt 0) ` + 'AgentUser declarations must not include a delegated-grant scope that provisioning never uses.' $publicClientPatch = @($state.Calls | Where-Object { $_.Method -eq 'PATCH' -and diff --git a/scripts/bulk-agent-registration/tests/RemovalCascadeAuthentication.Tests.ps1 b/scripts/bulk-agent-registration/tests/RemovalCascadeAuthentication.Tests.ps1 new file mode 100644 index 00000000..47acb937 --- /dev/null +++ b/scripts/bulk-agent-registration/tests/RemovalCascadeAuthentication.Tests.ps1 @@ -0,0 +1,231 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# + Regression tests for authentication values forwarded by Remove-A365Blueprint.ps1 when + -Force delegates dependent cleanup to the AgentUser and AgentIdentity removal scripts. +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force + +$script:BlueprintRemovalPath = (Resolve-Path (Join-Path $PSScriptRoot '..' 'Remove-A365Blueprint.ps1')).ProviderPath +$script:ResolvedTenantId = 'resolved-tenant-id' + +function New-A365CascadeFixture { + param( + [switch] $IncludeAgentUser, + [switch] $IncludeAgentIdentity + ) + + $root = Join-Path ([IO.Path]::GetTempPath()) "a365-cascade-$([guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $root -Force | Out-Null + $capturePath = Join-Path $root 'calls.jsonl' + + $childScript = @' +param( + [string[]] $AgentUserId, + [string[]] $AgentIdentityId, + [switch] $Force, + [string] $TenantId, + [string] $ClientId, + [switch] $Interactive +) + +[pscustomobject]@{ + Script = [IO.Path]::GetFileName($PSCommandPath) + TenantId = $TenantId + ClientId = $ClientId + Interactive = [bool]$Interactive + AgentUserId = @($AgentUserId) + AgentIdentityId = @($AgentIdentityId) +} | ConvertTo-Json -Compress | Add-Content -LiteralPath $env:A365_CASCADE_CAPTURE +'@ + + Set-Content -LiteralPath (Join-Path $root 'Remove-A365AgentUser.ps1') -Value $childScript + Set-Content -LiteralPath (Join-Path $root 'Remove-A365AgentIdentity.ps1') -Value $childScript + + $global:A365CascadeFixture = [pscustomobject]@{ + IncludeAgentUser = [bool]$IncludeAgentUser + IncludeAgentIdentity = [bool]$IncludeAgentIdentity + } + $env:A365_CASCADE_CAPTURE = $capturePath + + [pscustomobject]@{ + Root = $root + CapturePath = $capturePath + } +} + +function Remove-A365CascadeFixture { + param($Fixture) + + Remove-Item Env:A365_CASCADE_CAPTURE -ErrorAction SilentlyContinue + Remove-Variable -Name A365CascadeFixture -Scope Global -ErrorAction SilentlyContinue + if ($Fixture -and (Test-Path -LiteralPath $Fixture.Root)) { + Remove-Item -LiteralPath $Fixture.Root -Recurse -Force + } +} + +function Get-A365CascadeCalls { + param([Parameter(Mandatory)] $Fixture) + + if (-not (Test-Path -LiteralPath $Fixture.CapturePath)) { return , @() } + return , @(Get-Content -LiteralPath $Fixture.CapturePath | ForEach-Object { $_ | ConvertFrom-Json }) +} + +function global:Connect-MgGraph { + param( + [string] $TenantId, + [string] $ClientId, + [string[]] $Scopes, + [switch] $NoWelcome, + [string] $ErrorAction + ) +} + +function global:Get-MgContext { + [pscustomobject]@{ + AuthType = 'Delegated' + Account = 'operator@contoso.com' + ClientId = 'graph-sdk-default-client' + TenantId = 'resolved-tenant-id' + } +} + +function global:Invoke-MgGraphRequest { + param( + [Parameter(Mandatory)][string] $Method, + [Parameter(Mandatory)][string] $Uri, + $Headers, + [string] $OutputType, + $Body, + [string] $ContentType + ) + + if ($Method -eq 'GET' -and $Uri -match "/applications\(appId='blueprint-app'\)$") { + return [pscustomobject]@{ + id = 'blueprint-object' + appId = 'blueprint-app' + displayName = 'Test blueprint' + '@odata.type' = '#microsoft.graph.agentIdentityBlueprint' + } + } + if ($Method -eq 'GET' -and $Uri -match 'servicePrincipals/microsoft\.graph\.agentIdentity') { + $value = if ($global:A365CascadeFixture.IncludeAgentIdentity) { + @([pscustomobject]@{ id = 'identity-1'; displayName = 'Identity one' }) + } + else { + @() + } + return [pscustomobject]@{ value = $value } + } + if ($Method -eq 'GET' -and $Uri -match 'users/microsoft\.graph\.agentUser') { + $value = if ($global:A365CascadeFixture.IncludeAgentUser) { + @([pscustomobject]@{ + id = 'user-1' + displayName = 'User one' + userPrincipalName = 'user-one@contoso.com' + }) + } + else { + @() + } + return [pscustomobject]@{ value = $value } + } + if ($Method -eq 'GET' -and $Uri -match '/servicePrincipals\?') { + return [pscustomobject]@{ value = @() } + } + if ($Method -eq 'DELETE' -and $Uri -match '/applications/blueprint-object$') { + return $null + } + if ($Method -eq 'GET' -and $Uri -match '/applications/blueprint-object$') { + throw 'HTTP 404 Not Found' + } + + throw "Unexpected Graph request: $Method $Uri" +} + +function Invoke-A365BlueprintCascade { + param( + [Parameter(Mandatory)] $Fixture, + [string] $ClientId + ) + + $arguments = @{ + BlueprintId = 'blueprint-app' + Interactive = $true + Force = $true + ScriptRoot = $Fixture.Root + Confirm = $false + } + if ($ClientId) { $arguments.ClientId = $ClientId } + + & $script:BlueprintRemovalPath @arguments +} + +try { + Test-Case 'Interactive AgentUser cascade rejects a missing caller-controlled ClientId before child deletion' { + $fixture = New-A365CascadeFixture -IncludeAgentUser -IncludeAgentIdentity + try { + Assert-Throws { + Invoke-A365BlueprintCascade -Fixture $fixture + } 'ClientId.*interactive blueprint cascade.*AgentUsers' + Assert-Count (Get-A365CascadeCalls -Fixture $fixture) 0 ` + 'No child deletion may start before the interactive AgentUser ClientId requirement is satisfied.' + } + finally { + Remove-A365CascadeFixture -Fixture $fixture + } + } + + Test-Case 'Interactive AgentUser cascade forwards resolved tenant and caller-controlled ClientId' { + $fixture = New-A365CascadeFixture -IncludeAgentUser -IncludeAgentIdentity + try { + Invoke-A365BlueprintCascade -Fixture $fixture -ClientId 'caller-public-client' + $calls = Get-A365CascadeCalls -Fixture $fixture + + Assert-Count $calls 2 'Both dependent object types must be delegated to their removal scripts.' + foreach ($call in $calls) { + Assert-Equal $script:ResolvedTenantId $call.TenantId ` + 'Child removers must receive the tenant resolved from the active Graph context.' + Assert-Equal 'caller-public-client' $call.ClientId ` + 'Interactive child removers must receive the caller-controlled public-client ID.' + Assert-True $call.Interactive 'The cascade must preserve interactive authentication mode.' + } + } + finally { + Remove-A365CascadeFixture -Fixture $fixture + } + } + + Test-Case 'Identity-only interactive cascade remains valid without ClientId and receives resolved tenant' { + $fixture = New-A365CascadeFixture -IncludeAgentIdentity + try { + Invoke-A365BlueprintCascade -Fixture $fixture + $calls = Get-A365CascadeCalls -Fixture $fixture + + Assert-Count $calls 1 'An identity-only cascade must invoke only the AgentIdentity remover.' + Assert-Equal 'Remove-A365AgentIdentity.ps1' $calls[0].Script + Assert-Equal $script:ResolvedTenantId $calls[0].TenantId ` + 'The identity remover must receive the tenant resolved from the active Graph context.' + Assert-Equal '' $calls[0].ClientId ` + 'Identity-only interactive removal does not require a caller-controlled public client.' + Assert-True $calls[0].Interactive 'The cascade must preserve interactive authentication mode.' + } + finally { + Remove-A365CascadeFixture -Fixture $fixture + } + } +} +finally { + Remove-Item Function:\global:Connect-MgGraph -ErrorAction SilentlyContinue + Remove-Item Function:\global:Get-MgContext -ErrorAction SilentlyContinue + Remove-Item Function:\global:Invoke-MgGraphRequest -ErrorAction SilentlyContinue + Remove-Item Env:A365_CASCADE_CAPTURE -ErrorAction SilentlyContinue + Remove-Variable -Name A365CascadeFixture -Scope Global -ErrorAction SilentlyContinue +} + +Get-A365TestResults