diff --git a/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 b/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 index c09fa6c5..f7aca5fe 100644 --- a/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 +++ b/scripts/bulk-agent-registration/New-A365AutomationApp.ps1 @@ -13,13 +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. - 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. 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). - 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. 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,10 +96,14 @@ the Registration and All scenarios. .PARAMETER SkipGrant - 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. + 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. @@ -221,6 +227,7 @@ $script:MicrosoftGraphAppId = '00000003-0000-0000-c000-000000000000' function Test-HasProperty { param($Object, [Parameter(Mandatory)][string] $Name) if ($null -eq $Object) { return $false } + if ($Object -is [System.Collections.IDictionary]) { return $Object.Contains($Name) } $properties = $Object.PSObject.Properties if ($null -eq $properties) { return $false } foreach ($property in $properties) { @@ -229,6 +236,92 @@ function Test-HasProperty { return $false } +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() + $existingKeys = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $duplicatesRemoved = 0 + + 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) { + if ($existingKeys.Add(('{0}|{1}' -f $access.type, $access.id))) { + $graphAccess.Add($access) + } + else { + $duplicatesRemoved++ + } + } + } + else { + $otherResources.Add(@{ + resourceAppId = [string]$entry.resourceAppId + resourceAccess = $resourceAccess + }) + } + } + + $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 = ($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) @@ -1115,43 +1208,62 @@ else { $applicationObjectId = [string]$application.id $applicationAppId = [string]$application.appId -# 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 } - } +# 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 + +$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), 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 + $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 } - 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 + if ($scopesAddedToRequest.Count -gt 0) { + Write-Host " Requested delegated scope(s): $(($scopesAddedToRequest | ForEach-Object { $_.Name }) -join ', ')" -ForegroundColor Green + } + if ($permissionMerge.DuplicatesRemoved -gt 0) { + Write-Host " Removed $($permissionMerge.DuplicatesRemoved) duplicate Graph permission declaration(s)." -ForegroundColor Green } } else { - Write-Host ' Delegated scopes already requested on the application.' -ForegroundColor Gray + $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 @@ -1294,7 +1406,8 @@ $alreadyHeld = @() $failedGrant = @() if ($SkipGrant) { - Write-Host ' Skipped by -SkipGrant.' -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 ` @@ -1375,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 } @@ -1421,22 +1535,62 @@ if ($plainSecret) { } } -if ($delegatedToRequest.Count -gt 0) { +$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 '' - 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 + 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 + } + } + 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 ' 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 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 + Write-Host " Link : https://login.microsoftonline.com/$($ctx.TenantId)/adminconsent?client_id=$applicationAppId" -ForegroundColor Cyan } # 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') { - $csaGranted = @($resolved | Where-Object { $_.Name -like 'CustomSecAttribute*' }) - if ($csaGranted.Count -gt 0) { + $csaRoles = @($resolved | Where-Object { $_.Name -like 'CustomSecAttribute*' }) + if ($csaRoles.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 + $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 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 +1620,22 @@ $summary = [ordered]@{ applicationObjectId = $applicationObjectId servicePrincipalId = $servicePrincipalId scenario = $Scenario + grantSkipped = [bool]$SkipGrant + 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 = @($declaredScopes | ForEach-Object { $_.Name }) delegatedScopesRequested = @($delegatedToRequest | 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 keyVault = $script:KeyVaultResult generatedUtc = [DateTimeOffset]::UtcNow.ToString('o') } @@ -1480,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 new file mode 100644 index 00000000..611f5e93 --- /dev/null +++ b/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 @@ -0,0 +1,712 @@ +# 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', '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.' + 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 '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') +} + +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