Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ release notes.
`-Credential`), are unchanged.
- A backtick line continuation in `Get-IslSetting`, the one left in the module since 0.6.0 said
there were none.
- **`-Credential` did not find a Microsoft Entra account's session.** The launcher looked for
the credential's user name, taken apart as text, in `query user`. Windows calls an Entra
account `AzureAD\<display name without spaces, cut at 20 characters>`, which is neither the
sign-in name nor a part of it, so a credential naming `user@domain` was refused ("No mapping
between account names and security IDs") or fell back to the stored-password task. The
launcher now asks Windows which account the credential means, finds the session by the name
Windows gives it, registers the interactive task for that name and grants the run folder by
SID. Verified on the joined lab device with the sign-in name, `AzureAD\<sign-in name>` and
the Windows name (Findings, "The harness as another account").
- Under `-ErrorAction Stop` on Windows PowerShell 5.1, a refused grant on the run folder ended
with `icacls`' bare line ("No mapping between account names and security IDs was done")
instead of the message naming the account and the folder.
- **`Repair-IntuneScript` hid the mistake it was run on.** `return 1; exit 1` became
`1; exit 0; exit 1`: the same behaviour, the exit the author wrote unreachable, and no finding
left. A script-scope `return` with an exit other than 0 after it in the same block now carries
Expand Down
10 changes: 9 additions & 1 deletion Private/Grant-IslFolderAccess.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,15 @@ function Grant-IslFolderAccess {
[string]$Account
)

$output = & icacls.exe $Path /grant "${Account}:(OI)(CI)M" 2>&1
# By SID where Windows can resolve the name: a sign-in name of an Entra account is not a name
# icacls looks up
$resolved = Resolve-IslAccount -Name $Account
$trustee = if ($resolved) { "*$($resolved.Sid)" } else { $Account }
# Windows PowerShell turns a native command's redirected stderr into error records, and under
# a caller's -ErrorAction Stop the first one ends the function with icacls' bare line instead
# of the message below
$ErrorActionPreference = 'Continue'
$output = & icacls.exe $Path /grant "${trustee}:(OI)(CI)M" 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Could not grant $Account access to ${Path}: $($output -join ' ')"
}
Expand Down
10 changes: 8 additions & 2 deletions Private/Invoke-IslProcess.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,11 @@
}
else {
$userName = $Credential.UserName
$account = if ($userName -match '\\') { ($userName -split '\\')[-1] }
# "query user" lists the name Windows gives the account, which for an Entra account is
# neither the sign-in name nor a part of it; ask Windows before taking the name apart
$resolved = Resolve-IslAccount -Name $userName
$account = if ($resolved) { ($resolved.Name -split '\\')[-1] }
elseif ($userName -match '\\') { ($userName -split '\\')[-1] }
elseif ($userName -match '@') { ($userName -split '@')[0] }
else { $userName }
$sessions = @(Get-IslLogonSession | Where-Object { $_.UserName -eq $account })
Expand All @@ -149,7 +153,9 @@
else { 'Password' }
Write-Verbose "Task for ${userName}: logon type $logon, $($sessions.Count) session(s) found"
if ($logon -eq 'Interactive') {
$principalSplat = @{ UserId = $userName; LogonType = 'Interactive'; RunLevel = 'Limited' }
# The scheduler takes an Entra account by its Windows name only
$principalName = if ($resolved) { $resolved.Name } else { $userName }
$principalSplat = @{ UserId = $principalName; LogonType = 'Interactive'; RunLevel = 'Limited' }
$registerTaskSplat.Principal = New-ScheduledTaskPrincipal @principalSplat
}
else {
Expand Down
84 changes: 84 additions & 0 deletions Private/Resolve-IslAccount.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
function ConvertTo-IslAccount {
<#
.SYNOPSIS
Asks Windows for the SID behind one account name and for the name it gives that SID.

.DESCRIPTION
The two translations Resolve-IslAccount is built on, kept apart so the unit tests can stand
in for accounts a build machine does not have. Returns nothing for a name Windows cannot
resolve, and nothing off Windows.

.PARAMETER Name
One account name, exactly as it is to be looked up.

.EXAMPLE
ConvertTo-IslAccount -Name 'NT AUTHORITY\SYSTEM'

Name NT AUTHORITY\SYSTEM, Sid S-1-5-18.
#>
[CmdletBinding()]
[OutputType('IntuneScriptLab.Account')]
param(
[Parameter(Mandatory)]
[string]$Name
)

try {
$sidType = [System.Security.Principal.SecurityIdentifier]
$sid = ([System.Security.Principal.NTAccount]$Name).Translate($sidType)
[pscustomobject]@{
PSTypeName = 'IntuneScriptLab.Account'
Name = $sid.Translate([System.Security.Principal.NTAccount]).Value
Sid = $sid.Value
}
}
catch { Write-Verbose "Account name '$Name' not resolved: $($_.Exception.Message)" }
}

function Resolve-IslAccount {
<#
.SYNOPSIS
Finds what Windows itself calls the account a credential names, and its SID.

.DESCRIPTION
A credential can name an account several ways and Windows shows only one of them. For a
Microsoft Entra account on a joined device (lab device, 2026-10-05; Findings, "The harness
as another account"):

signed in as isl-verylongusername-test01@4nlnm3.onmicrosoft.com
display name Isl Verylongdisplayname Testaccount
Windows name AzureAD\IslVerylongdisplayna

The Windows name is the display name without its spaces, cut at 20 characters, and it is
what "query user" lists, what owns the session's processes and the only name a scheduled
task accepted for an interactive principal: the sign-in name and the SID were both refused
there. The sign-in name resolves to the account's SID only with the AzureAD\ prefix.

So the name is looked up as given and, when it is a sign-in name without a domain part
that does not resolve, again as AzureAD\<name>. The SID is then turned back into the name
Windows uses. Returns nothing when neither lookup resolves; the caller then works with
the name as it was given.

.PARAMETER Name
The account as the credential names it: isl-user, MACHINE\isl-user, DOMAIN\user,
user@domain or AzureAD\user@domain.

.EXAMPLE
Resolve-IslAccount -Name 'someone@contoso.com'

Name AzureAD\SomeOne and the account's SID, on a device where that Entra user has signed in.
#>
[CmdletBinding()]
[OutputType('IntuneScriptLab.Account')]
param(
[Parameter(Mandatory)]
[string]$Name
)

$candidates = @($Name)
if ($Name -match '@' -and $Name -notmatch '\\') { $candidates += "AzureAD\$Name" }
foreach ($candidate in $candidates) {
$account = ConvertTo-IslAccount -Name $candidate
if ($account) { return $account }
}
}
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,11 @@ profile loaded, and `RunAs` says `(Password)` so you know the session differs; t
the "Log on as a batch job" right, which a standard user does not have by default, and the launcher
reports the refusal with that hint within seconds, whether the scheduler answers `0x80070569` or
simply never starts the task (`0x00041303`, "has not run yet", which is what the lab device does
today). Needs an elevated session. On
today). Needs an elevated session. A Microsoft Entra account can be named by its sign-in name
(`user@domain`) or by the name Windows gives it: Windows calls such an account
`AzureAD\<display name without spaces, cut at 20 characters>`, which is neither the sign-in name nor
a part of it, so the launcher asks Windows which account the credential means and looks for its
session under that name. On
the lab device the interactive path reproduced the agent's launch point for point (console session,
`UserInteractive` true, the account's profile paths, system32; `Validation/Findings.md`, "The
harness as another account"). The script copy and its output live under `ProgramData\IntuneScriptLab\Runs` with the
Expand Down
14 changes: 14 additions & 0 deletions Tests/Unit/Private/Get-IslLogonSession.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ Describe 'Get-IslLogonSession' -Tag 'Unit', 'Private' {
$sessions[1].State | Should-Be 'Disc'
}

It 'reads a 20-character name, the longest Windows gives an account, as printed on VM 125' {
# An Entra user named "Isl Verylongdisplayname Testaccount" who signs in as
# isl-verylongusername-test01@...: Windows calls it AzureAD\IslVerylongdisplayna, the display
# name without spaces cut at 20 characters, and the column still ends in two spaces
$sessions = @(Get-SessionFromText -Lines @(
' USERNAME SESSIONNAME ID STATE IDLE TIME LOGON TIME'
' islverylongdisplayna console 2 Active none 10/5/2026 11:05 AM'
))
$sessions.Count | Should-Be 1
$sessions[0].UserName | Should-Be 'islverylongdisplayna'
$sessions[0].SessionName | Should-Be 'console'
$sessions[0].Id | Should-Be 2
}

It 'returns nothing when nobody is logged on, the tool is missing, or only the header prints' {
@(Get-SessionFromText -Lines @()).Count | Should-Be 0
@(Get-SessionFromText -Lines @(' USERNAME SESSIONNAME ID STATE IDLE TIME LOGON TIME')).Count |
Expand Down
72 changes: 72 additions & 0 deletions Tests/Unit/Private/Grant-IslFolderAccess.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.2.0' }

<#
The grant on the run folder another account's script is copied to. By SID where Windows can
resolve the account, because the sign-in name of an Entra account is not a name icacls looks up
(VM 125, 2026-10-05); by the name as given otherwise.
#>

BeforeAll {
$script:ModuleRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
Import-Module (Join-Path $script:ModuleRoot 'IntuneScriptLab.psd1') -Force
$script:Me = [System.Security.Principal.WindowsIdentity]::GetCurrent()

function Grant-Access {
param([string]$Path, [string]$Account, [string]$Preference = 'Continue')
$parameters = @{ Path = $Path; Account = $Account; Preference = $Preference }
InModuleScope IntuneScriptLab -Parameters $parameters {
# What a caller's -ErrorAction leaves in force inside the module
$ErrorActionPreference = $Preference
Grant-IslFolderAccess -Path $Path -Account $Account
}
}
}

AfterAll {
Remove-Module IntuneScriptLab -Force -ErrorAction SilentlyContinue
}

Describe 'Grant-IslFolderAccess' -Tag 'Unit', 'Private' {

BeforeEach {
$script:Folder = Join-Path $TestDrive "run-$([guid]::NewGuid().ToString('N'))"
$null = New-Item -ItemType Directory -Path $script:Folder
}

It 'grants Modify, inherited by files and folders, by the SID of an account Windows resolves' {
Mock Resolve-IslAccount -ModuleName IntuneScriptLab {
[pscustomobject]@{ Name = 'AzureAD\IslVerylongdisplayna'; Sid = 'S-1-5-32-545' }
}
# S-1-5-32-545 is BUILTIN\Users, standing in for the account: the grant has to land on the
# SID it was given, whatever the name passed in
Grant-Access $script:Folder 'isl-verylongusername-test01@4nlnm3.onmicrosoft.com'
$rule = (Get-Acl -Path $script:Folder).Access | Where-Object {
-not $_.IsInherited -and
$_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq 'S-1-5-32-545'
}
@($rule).Count | Should-Be 1
"$($rule.FileSystemRights)" | Should-BeLikeString '*Modify*'
"$($rule.InheritanceFlags)" | Should-Be 'ContainerInherit, ObjectInherit'
}

It 'grants by the name as given when Windows does not resolve it' {
Mock Resolve-IslAccount -ModuleName IntuneScriptLab { }
Grant-Access $script:Folder $script:Me.Name
$rule = (Get-Acl -Path $script:Folder).Access | Where-Object {
-not $_.IsInherited -and "$($_.IdentityReference)" -eq $script:Me.Name
}
@($rule).Count | Should-Be 1
}

It 'names the account and the folder when icacls refuses, under error action <Preference>' -ForEach @(
@{ Preference = 'Continue' }
@{ Preference = 'Stop' }
) {
# Under Stop, Windows PowerShell 5.1 ended the function at icacls' first stderr line with
# that line alone as the message (the CI runner and the lab device both showed it)
Mock Resolve-IslAccount -ModuleName IntuneScriptLab { }
$missing = "$env:COMPUTERNAME\no-such-account-for-isl"
$failure = { Grant-Access $script:Folder $missing $Preference } | Should-Throw
$failure.Exception.Message | Should-BeLikeString "Could not grant $missing access to *No mapping*"
}
}
44 changes: 44 additions & 0 deletions Tests/Unit/Private/Invoke-IslProcess.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ Describe 'Invoke-IslProcess' -Tag 'Unit', 'Private' {
Mock Unregister-ScheduledTask -ModuleName IntuneScriptLab { }
Mock Stop-ScheduledTask -ModuleName IntuneScriptLab { }
Mock Get-ScheduledTask -ModuleName IntuneScriptLab { [pscustomobject]@{ State = 'Ready' } }
# The accounts in these tests do not exist here: Windows resolves none of them
Mock Resolve-IslAccount -ModuleName IntuneScriptLab { }
Mock Start-ScheduledTask -ModuleName IntuneScriptLab {
$folder = Join-Path ([IO.Path]::GetTempPath()) $TaskName.Substring('IntuneScriptLab-'.Length)
[IO.File]::WriteAllText((Join-Path $folder 'stdout.txt'), "user-out`r`n")
Expand Down Expand Up @@ -237,6 +239,48 @@ Describe 'Invoke-IslProcess' -Tag 'Unit', 'Private' {
$result.UserName | Should-Be 'isl-user@lab.local'
}

It 'finds an Entra account''s session by the name Windows gives it, not by its sign-in name (VM 125)' {
# Signed in as isl-verylongusername-test01@..., listed by "query user" as
# islverylongdisplayna; the scheduler takes the Windows name and refuses the sign-in name
Mock Resolve-IslAccount -ModuleName IntuneScriptLab {
[pscustomobject]@{
Name = 'AzureAD\IslVerylongdisplayna'
Sid = 'S-1-12-1-1497552185-1263987200-3276725654-805488699'
}
}
Mock Get-IslLogonSession -ModuleName IntuneScriptLab {
[pscustomobject]@{
UserName = 'islverylongdisplayna'; SessionName = 'console'; Id = 2; State = 'Active'
}
}
$launchSplat = $script:LaunchSplat.Clone()
$signInName = 'isl-verylongusername-test01@4nlnm3.onmicrosoft.com'
$launchSplat.Credential = [pscredential]::new($signInName, $script:Credential.Password)
$result = Invoke-Process $launchSplat
$result.LogonType | Should-Be 'Interactive'
$result.UserName | Should-Be $signInName
Should-Invoke Resolve-IslAccount -ModuleName IntuneScriptLab -Exactly -Times 1 -ParameterFilter {
$Name -eq 'isl-verylongusername-test01@4nlnm3.onmicrosoft.com'
}
Should-Invoke Register-ScheduledTask -ModuleName IntuneScriptLab -Exactly -Times 1 -ParameterFilter {
$Principal.UserId -eq 'AzureAD\IslVerylongdisplayna' -and
"$($Principal.LogonType)" -eq 'Interactive' -and $null -eq $Password
}
}

It 'does not take another account''s session for a sign-in name that only looks like it' {
# The part before the @ of one account can be the Windows name of another
Mock Resolve-IslAccount -ModuleName IntuneScriptLab {
[pscustomobject]@{ Name = 'AzureAD\SomeoneElse'; Sid = 'S-1-12-1-1-2-3-4' }
}
Mock Get-IslLogonSession -ModuleName IntuneScriptLab {
[pscustomobject]@{ UserName = 'isl-user'; SessionName = 'console'; Id = 2; State = 'Active' }
}
$launchSplat = $script:LaunchSplat.Clone()
$launchSplat.Credential = [pscredential]::new('isl-user@lab.local', $script:Credential.Password)
(Invoke-Process $launchSplat).LogonType | Should-Be 'Password'
}

It 'reports a logon the scheduler refuses instead of waiting for the timeout (VM 125, isl-user)' {
Mock Get-IslLogonSession -ModuleName IntuneScriptLab { @() }
Mock Start-ScheduledTask -ModuleName IntuneScriptLab { }
Expand Down
Loading
Loading