Skip to content

Commit 57866f9

Browse files
committed
feat: user agent / task / standard contexts
introduce new user agent for better tracing on customer tenant side fix issues with standards/scheduled task logging
1 parent 024c3da commit 57866f9

11 files changed

Lines changed: 188 additions & 28 deletions

File tree

Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ function Push-ExecScheduledCommand {
1111
$OrchestratorBasedCommands = @('Invoke-CIPPOffboardingJob')
1212

1313
# Initialize AsyncLocal storage for thread-safe per-invocation context
14-
if (-not $global:CippScheduledTaskIdStorage) {
15-
$global:CippScheduledTaskIdStorage = [System.Threading.AsyncLocal[string]]::new()
16-
}
17-
$global:CippScheduledTaskIdStorage.Value = $Item.TaskInfo.RowKey
14+
# Store task id in CIPPCore module-scoped AsyncLocal storage so Write-LogMessage can read it -
15+
# global vars are unreliable in Azure Functions
16+
Set-CippScheduledTaskContext -TaskId $Item.TaskInfo.RowKey
17+
18+
# Store action source + creating user identity (from stored headers) for outbound User-Agent attribution
19+
Set-CippUserAgentContext -Headers $Item.Parameters.Headers -Source 'scheduled-task' -TaskId $Item.TaskInfo.RowKey
1820

1921
$Table = Get-CippTable -tablename 'ScheduledTasks'
2022
$task = $Item.TaskInfo

Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,12 @@ function Push-CIPPStandard {
4646
return
4747
}
4848

49-
# Initialize AsyncLocal storage for thread-safe per-invocation context
50-
# Uses $global: so Write-LogMessage (CIPPCore module) can read it across module boundaries
51-
if (-not $global:CippStandardInfoStorage) {
52-
$global:CippStandardInfoStorage = [System.Threading.AsyncLocal[object]]::new()
53-
}
54-
$global:CippStandardInfoStorage.Value = $StandardInfo
49+
# Store standard info in CIPPCore module-scoped AsyncLocal storage so Write-LogMessage and
50+
# Set-CIPPStandardsCompareField can read it - global vars are unreliable in Azure Functions
51+
Set-CippStandardInfoContext -StandardInfo $StandardInfo
52+
53+
# Store action source + template id for outbound User-Agent attribution
54+
Set-CippUserAgentContext -Source 'standard' -TemplateId $Item.TemplateId
5555

5656
# ---- Standard execution telemetry ----
5757
$runId = [guid]::NewGuid().ToString()
@@ -131,8 +131,6 @@ function Push-CIPPStandard {
131131
Error = $err
132132
} | ConvertTo-Json -Compress)
133133

134-
if ($global:CippStandardInfoStorage) {
135-
$global:CippStandardInfoStorage.Value = $null
136-
}
134+
Set-CippStandardInfoContext -StandardInfo $null
137135
}
138136
}

Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ function New-CippCoreRequest {
6161
}
6262
}
6363

64+
# Store action source + acting identity for outbound User-Agent attribution
65+
Set-CippUserAgentContext -Headers $Request.Headers
66+
6467
# Check if endpoint is disabled via feature flags
6568
$FeatureFlags = Get-CIPPFeatureFlag
6669

Modules/CIPPCore/Public/Get-CIPPStatsUniqueStandardsApplied.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ function Get-CIPPStatsUniqueStandardsApplied {
1919
$Id = if ([string]::IsNullOrWhiteSpace($TemplateValue)) {
2020
[string]$Standard.Standard
2121
} else {
22-
"{0}|{1}" -f $Standard.Standard, $TemplateValue
22+
'{0}|{1}' -f $Standard.Standard, $TemplateValue
2323
}
2424

2525
if ([string]::IsNullOrWhiteSpace($Id)) { continue }
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
function Get-CippUserAgent {
2+
<#
3+
.SYNOPSIS
4+
Builds the User-Agent string for outbound M365 API requests.
5+
.DESCRIPTION
6+
Returns 'CIPP/<version>' optionally suffixed with semicolon-delimited 'key:value' context segments
7+
set via Set-CippUserAgentContext, e.g. 'CIPP/8.2.0 (user:john@msp.com)' or
8+
'CIPP/8.2.0 (scheduled-task:<taskid>; user:john@msp.com)'.
9+
This allows MDR/security teams to attribute CIPP activity in M365 audit logs.
10+
.FUNCTIONALITY
11+
Internal
12+
#>
13+
[CmdletBinding()]
14+
param()
15+
16+
$Version = $env:CippVersion ?? $env:APP_VERSION ?? '1.0'
17+
$Context = $script:CippUserAgentContextStorage.Value
18+
19+
if ($Context.Source) {
20+
$Segments = [System.Collections.Generic.List[string]]::new()
21+
if ($Context.Source -in @('user', 'api')) {
22+
# Identity belongs to the source itself, e.g. user:john@msp.com or api:<appid>
23+
$Segments.Add($Context.Identity ? ('{0}:{1}' -f $Context.Source, $Context.Identity) : $Context.Source)
24+
} else {
25+
$Id = @($Context.TaskId, $Context.TemplateId) | Where-Object { $_ } | Select-Object -First 1
26+
$Segments.Add($Id ? ('{0}:{1}' -f $Context.Source, $Id) : $Context.Source)
27+
if ($Context.Identity) {
28+
$Segments.Add('user:{0}' -f $Context.Identity)
29+
}
30+
}
31+
return ('CIPP/{0} ({1})' -f $Version, ($Segments -join '; '))
32+
}
33+
return ('CIPP/{0}' -f $Version)
34+
}

Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ function New-GraphGetRequest {
5454
}
5555

5656
if (!$headers['User-Agent']) {
57-
$headers['User-Agent'] = "CIPP/$($global:CippVersion ?? '1.0')"
57+
$headers['User-Agent'] = Get-CippUserAgent
5858
}
5959

6060

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
function Set-CippUserAgentContext {
2+
<#
3+
.SYNOPSIS
4+
Stores the execution source and user identity for the current invocation, for inclusion in outbound User-Agent strings.
5+
.DESCRIPTION
6+
Resolves the acting identity from the client principal headers (UPN when available, falling back to
7+
the Entra object id claim, SWA userId, or the API client AppId) and stores it with the action source in
8+
AsyncLocal storage so Get-CippUserAgent can build an attributable User-Agent for Graph requests.
9+
.PARAMETER Headers
10+
The request headers (live or stored snapshot) containing x-ms-client-principal* values.
11+
.PARAMETER Source
12+
The action source label, e.g. 'scheduled-task'. When omitted, 'api' is inferred for AAD API clients and 'user' otherwise.
13+
.PARAMETER TaskId
14+
Optional task identifier (e.g. the scheduled task RowKey) included in the User-Agent for cross-referencing.
15+
.PARAMETER TemplateId
16+
Optional standard template identifier included in the User-Agent for cross-referencing.
17+
.FUNCTIONALITY
18+
Internal
19+
#>
20+
[CmdletBinding()]
21+
param(
22+
$Headers,
23+
[string]$Source,
24+
[string]$TaskId,
25+
[string]$TemplateId
26+
)
27+
28+
if (-not $script:CippUserAgentContextStorage) {
29+
$script:CippUserAgentContextStorage = [System.Threading.AsyncLocal[hashtable]]::new()
30+
}
31+
32+
$Identity = $null
33+
$GuidRegex = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
34+
35+
if ($Headers.'x-ms-client-principal-idp' -eq 'aad' -and $Headers.'x-ms-client-principal-name' -match $GuidRegex) {
36+
# Direct API client - principal name is the AppId
37+
$Identity = $Headers.'x-ms-client-principal-name'
38+
if (-not $Source) { $Source = 'api' }
39+
} elseif ($Headers.'x-ms-client-principal') {
40+
try {
41+
$Principal = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json
42+
# Prefer the UPN - human readable and meaningful to MDR/security teams reviewing M365 audit logs
43+
$Upn = $Principal.userDetails
44+
if ([string]::IsNullOrWhiteSpace($Upn)) {
45+
$Upn = ($Principal.claims | Where-Object { $_.typ -in @('preferred_username', 'upn', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn', 'email', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress') } | Select-Object -First 1).val
46+
}
47+
if ($Upn) {
48+
$Identity = $Upn
49+
if (-not $Source) { $Source = 'user' }
50+
} else {
51+
$AppId = ($Principal.claims | Where-Object { $_.typ -in @('azp', 'appid') } | Select-Object -First 1).val
52+
if ($AppId) {
53+
# App-only token - identify by AppId
54+
$Identity = $AppId
55+
if (-not $Source) { $Source = 'api' }
56+
} else {
57+
# Fall back to the Entra object id claim or the SWA userId
58+
$Oid = ($Principal.claims | Where-Object { $_.typ -in @('http://schemas.microsoft.com/identity/claims/objectidentifier', 'oid') } | Select-Object -First 1).val
59+
$Identity = $Oid ?? $Principal.userId
60+
if (-not $Source) { $Source = 'user' }
61+
}
62+
}
63+
} catch {
64+
Write-Verbose "Failed to resolve identity from client principal: $($_.Exception.Message)"
65+
}
66+
}
67+
68+
if ($Source) {
69+
$script:CippUserAgentContextStorage.Value = @{
70+
Source = $Source
71+
Identity = $Identity
72+
TaskId = $TaskId
73+
TemplateId = $TemplateId
74+
}
75+
}
76+
}

Modules/CIPPCore/Public/GraphHelper/Write-LogMessage.ps1

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,18 +68,19 @@ function Write-LogMessage {
6868
if ($tenantId) {
6969
$TableRow.Add('TenantID', [string]$tenantId)
7070
}
71-
if ($global:CippStandardInfoStorage -and $global:CippStandardInfoStorage.Value) {
72-
$TableRow.Standard = [string]$global:CippStandardInfoStorage.Value.Standard
73-
$TableRow.StandardTemplateId = [string]$global:CippStandardInfoStorage.Value.StandardTemplateId
74-
if ($global:CippStandardInfoStorage.Value.IntuneTemplateId) {
75-
$TableRow.IntuneTemplateId = [string]$global:CippStandardInfoStorage.Value.IntuneTemplateId
71+
$StandardInfo = $script:CippStandardInfoStorage.Value
72+
if ($StandardInfo) {
73+
$TableRow.Standard = [string]$StandardInfo.Standard
74+
$TableRow.StandardTemplateId = [string]$StandardInfo.StandardTemplateId
75+
if ($StandardInfo.IntuneTemplateId) {
76+
$TableRow.IntuneTemplateId = [string]$StandardInfo.IntuneTemplateId
7677
}
77-
if ($global:CippStandardInfoStorage.Value.ConditionalAccessTemplateId) {
78-
$TableRow.ConditionalAccessTemplateId = [string]$global:CippStandardInfoStorage.Value.ConditionalAccessTemplateId
78+
if ($StandardInfo.ConditionalAccessTemplateId) {
79+
$TableRow.ConditionalAccessTemplateId = [string]$StandardInfo.ConditionalAccessTemplateId
7980
}
8081
}
81-
if ($global:CippScheduledTaskIdStorage -and $global:CippScheduledTaskIdStorage.Value) {
82-
$TableRow.ScheduledTaskId = [string]$global:CippScheduledTaskIdStorage.Value
82+
if ($script:CippScheduledTaskIdStorage.Value) {
83+
$TableRow.ScheduledTaskId = [string]$script:CippScheduledTaskIdStorage.Value
8384
}
8485

8586
$Table.Entity = $TableRow

Modules/CIPPCore/Public/Set-CIPPStandardsCompareField.ps1

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ function Set-CIPPStandardsCompareField {
7070
if ($ExistingHash.ContainsKey($Field.FieldName)) {
7171
$Entity = $ExistingHash[$Field.FieldName]
7272
$Entity.Value = $NormalizedValue
73-
$Entity | Add-Member -NotePropertyName TemplateId -NotePropertyValue ([string]$global:CippStandardInfoStorage.Value.StandardTemplateId) -Force
73+
$Entity | Add-Member -NotePropertyName TemplateId -NotePropertyValue ([string]$script:CippStandardInfoStorage.Value.StandardTemplateId) -Force
7474
$Entity | Add-Member -NotePropertyName LicenseAvailable -NotePropertyValue ([bool]$Field.LicenseAvailable) -Force
7575
$Entity | Add-Member -NotePropertyName CurrentValue -NotePropertyValue ([string]$Field.CurrentValue) -Force
7676
$Entity | Add-Member -NotePropertyName ExpectedValue -NotePropertyValue ([string]$Field.ExpectedValue) -Force
@@ -79,7 +79,7 @@ function Set-CIPPStandardsCompareField {
7979
PartitionKey = [string]$TenantName.defaultDomainName
8080
RowKey = [string]$Field.FieldName
8181
Value = $NormalizedValue
82-
TemplateId = [string]$global:CippStandardInfoStorage.Value.StandardTemplateId
82+
TemplateId = [string]$script:CippStandardInfoStorage.Value.StandardTemplateId
8383
LicenseAvailable = [bool]$Field.LicenseAvailable
8484
CurrentValue = [string]$Field.CurrentValue
8585
ExpectedValue = [string]$Field.ExpectedValue
@@ -106,7 +106,7 @@ function Set-CIPPStandardsCompareField {
106106
try {
107107
if ($Existing) {
108108
$Existing.Value = $NormalizedValue
109-
$Existing | Add-Member -NotePropertyName TemplateId -NotePropertyValue ([string]$global:CippStandardInfoStorage.Value.StandardTemplateId) -Force
109+
$Existing | Add-Member -NotePropertyName TemplateId -NotePropertyValue ([string]$script:CippStandardInfoStorage.Value.StandardTemplateId) -Force
110110
$Existing | Add-Member -NotePropertyName LicenseAvailable -NotePropertyValue ([bool]$LicenseAvailable) -Force
111111
$Existing | Add-Member -NotePropertyName CurrentValue -NotePropertyValue ([string]$CurrentValue) -Force
112112
$Existing | Add-Member -NotePropertyName ExpectedValue -NotePropertyValue ([string]$ExpectedValue) -Force
@@ -116,7 +116,7 @@ function Set-CIPPStandardsCompareField {
116116
PartitionKey = [string]$TenantName.defaultDomainName
117117
RowKey = [string]$FieldName
118118
Value = $NormalizedValue
119-
TemplateId = [string]$global:CippStandardInfoStorage.Value.StandardTemplateId
119+
TemplateId = [string]$script:CippStandardInfoStorage.Value.StandardTemplateId
120120
LicenseAvailable = [bool]$LicenseAvailable
121121
CurrentValue = [string]$CurrentValue
122122
ExpectedValue = [string]$ExpectedValue
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
function Set-CippScheduledTaskContext {
2+
<#
3+
.SYNOPSIS
4+
Stores the scheduled task id in CIPPCore module-scoped AsyncLocal storage for the current invocation.
5+
.DESCRIPTION
6+
Used by the scheduler engine (Push-ExecScheduledCommand in CIPPActivityTriggers) so that CIPPCore functions
7+
like Write-LogMessage can attribute log entries to the running scheduled task. Module script scope
8+
is used instead of global scope, which is not reliable in Azure Functions.
9+
.PARAMETER TaskId
10+
The scheduled task RowKey. Pass $null or empty to clear.
11+
.FUNCTIONALITY
12+
Internal
13+
#>
14+
[CmdletBinding()]
15+
param(
16+
[string]$TaskId
17+
)
18+
19+
if (-not $script:CippScheduledTaskIdStorage) {
20+
$script:CippScheduledTaskIdStorage = [System.Threading.AsyncLocal[string]]::new()
21+
}
22+
$script:CippScheduledTaskIdStorage.Value = $TaskId
23+
}

0 commit comments

Comments
 (0)