Skip to content
Open
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
74 changes: 34 additions & 40 deletions scripts/bulk-agent-registration/A365-AutomationOrchestrator.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -993,7 +986,6 @@ param(
[string] $CertificatePath,
[object] $CertificatePassword,
[switch] $UseManagedIdentity,
[object] $AccessToken,
[switch] $Interactive,
[switch] $SkipPermissionCheck,

Expand Down Expand Up @@ -2104,64 +2096,66 @@ 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 }
if ($ClientId) { $registrationAuthSplat.ClientId = $ClientId }
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
Expand Down
43 changes: 25 additions & 18 deletions scripts/bulk-agent-registration/A365-BulkOnboarding.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 -
Comment thread
walterluna marked this conversation as resolved.
see A365-AutomationOrchestrator.ps1's own help for the full description of each.

.PARAMETER LogPath
Expand Down Expand Up @@ -208,7 +205,6 @@ param(
[string] $CertificatePath,
[object] $CertificatePassword,
[switch] $UseManagedIdentity,
[object] $AccessToken,
[switch] $Interactive,
[switch] $SkipPermissionCheck,

Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
Loading
Loading