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/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..3e04bb01 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] @@ -1901,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 { @@ -1914,10 +1916,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 +2059,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..9260fa43 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] @@ -2042,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 { @@ -2055,10 +2060,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 +2223,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..9b31e0ed 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] @@ -1222,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 { @@ -1235,10 +1200,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 +1229,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 +1529,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..a3449e8c 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,74 @@ 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')) { + $null = $scopes.Add($scope) + } + + if ($ConfigurePermissions) { + $null = $scopes.Add('Application.ReadWrite.All') + $null = $scopes.Add('AppRoleAssignment.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 +1229,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 +1254,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 +1295,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 +1312,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 +1322,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 +1489,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 +2468,8 @@ $script:CachedToken = $null $script:CachedTokenExpiry = [DateTimeOffset]::MinValue $script:TokenRoles = @() $script:TokenIsDelegated = $false +$script:TenantId = $TenantId +$script:ClientId = $ClientId try { Resolve-CloudEndpoints @@ -2556,7 +2525,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..93d8f8cf 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,44 @@ 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 +931,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 +970,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 +1065,22 @@ $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 routine provisioning and optional permission bootstrap 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' + '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 +1102,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 +1188,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 +1251,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 +1273,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 +1431,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..e74e5fdf 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,50 @@ 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 + $keyStorageFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet + $connect.Certificate = if ($CertificatePassword) { + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword, $keyStorageFlags) + } + else { + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, [string]::Empty, $keyStorageFlags) + } + } + 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 +782,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 +954,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..113bfc2b 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,46 @@ 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 + $keyStorageFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet + $connect.Certificate = if ($CertificatePassword) { + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword, $keyStorageFlags) + } + else { + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, [string]::Empty, $keyStorageFlags) + } + } + 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 +1087,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 +1099,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 +1484,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..d3505986 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,89 @@ $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 + $keyStorageFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet + $connect.Certificate = if ($CertificatePassword) { + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword, $keyStorageFlags) + } + else { + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, [string]::Empty, $keyStorageFlags) + } + } + 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 +790,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 +954,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..ab4810d9 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,50 @@ 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 + $keyStorageFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet + $connect.Certificate = if ($CertificatePassword) { + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, $CertificatePassword, $keyStorageFlags) + } + else { + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfx, [string]::Empty, $keyStorageFlags) + } + } + 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 +801,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' '') @@ -948,19 +975,31 @@ 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 } - # 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 } 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 @@ -1051,4 +1090,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 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 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/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..7d22d630 100644 --- a/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 +++ b/scripts/bulk-agent-registration/tests/PermissionDeclaration.Tests.ps1 @@ -87,9 +87,27 @@ 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', + '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 +117,7 @@ function New-A365DeclarationFixture { oauth2PermissionScopes = @( @{ id = 'scope-a'; value = $roleNames[0] } @{ id = 'scope-b'; value = $roleNames[1] } - ) + ) + $agentUserScopes } ExistingAccess = @( @{ resourceAppId = $graphAppId; resourceAccess = @( @@ -123,6 +141,7 @@ function New-A365DeclarationFixture { PatchFails = $false MissingApplication = $false MissingPrincipal = $false + PublicClientEnabled = $true } } @@ -131,7 +150,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 +181,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 +246,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 +589,30 @@ 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'." + } + 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 + $_.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 +763,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 +776,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 +787,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/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 diff --git a/scripts/bulk-agent-registration/tests/SecureAuthenticationInput.Tests.ps1 b/scripts/bulk-agent-registration/tests/SecureAuthenticationInput.Tests.ps1 index c7356175..3edf72dc 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 @@ -236,4 +224,92 @@ 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 + } +} + +$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/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..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 @@ -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. #> @@ -30,7 +30,6 @@ param( [string] $CertificatePath, [object] $CertificatePassword, [switch] $UseManagedIdentity, - [object] $AccessToken, [switch] $Interactive, [switch] $SkipPermissionCheck ) @@ -50,8 +49,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 } }) }