Skip to content

Commit deadcee

Browse files
authored
Merge pull request #170 from KelvinTegelaar/master
[pull] master from KelvinTegelaar:master
2 parents ca673a9 + c04bde0 commit deadcee

30 files changed

Lines changed: 3216 additions & 2305 deletions

Config/PermissionsTranslator.json

Lines changed: 2030 additions & 1995 deletions
Large diffs are not rendered by default.

Config/openapi.json

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -38886,6 +38886,58 @@
3888638886
"x-cipp-role": "Endpoint.Autopilot.Read"
3888738887
}
3888838888
},
38889+
"/api/ListApiEgress": {
38890+
"get": {
38891+
"summary": "ListApiEgress",
38892+
"operationId": "ListApiEgress",
38893+
"tags": [
38894+
"CIPP > Settings"
38895+
],
38896+
"description": "Per-API-client egress usage for this instance against the daily cap. Reads the\nCraftEgressAccounting table that the Craft runtime mirrors its egress accounting into (the same\nstorage account, so this reads it directly): a per-day audit row per client plus an instance\ntotal, and 15-minute buckets for the trend. Returns today's instance summary (used-of-cap,\nenforcing, when the cap was first hit, how many requests were shed), the per-client breakdown,\nand the last-24h instance trend. When the table has no data for today - accounting off, a\nnon-hosted instance, or simply no app-only traffic yet - Enabled is false and the UI hides the\ncard. SuperAdmin only.",
38897+
"parameters": [
38898+
{
38899+
"name": "Hours",
38900+
"in": "query",
38901+
"required": false,
38902+
"schema": {
38903+
"type": "integer"
38904+
}
38905+
}
38906+
],
38907+
"responses": {
38908+
"200": {
38909+
"description": "Success",
38910+
"content": {
38911+
"application/json": {
38912+
"schema": {
38913+
"type": "array",
38914+
"items": {
38915+
"type": "object",
38916+
"description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides."
38917+
}
38918+
}
38919+
}
38920+
}
38921+
},
38922+
"401": {
38923+
"description": "Unauthorized - invalid or missing bearer token"
38924+
},
38925+
"403": {
38926+
"description": "Forbidden - caller lacks the required RBAC role"
38927+
},
38928+
"500": {
38929+
"description": "Internal server error"
38930+
}
38931+
},
38932+
"security": [
38933+
{
38934+
"bearerAuth": []
38935+
}
38936+
],
38937+
"x-cipp-role": "CIPP.SuperAdmin.Read",
38938+
"x-cipp-any-tenant": true
38939+
}
38940+
},
3888938941
"/api/ListApiTest": {
3889038942
"get": {
3889138943
"summary": "ListApiTest",
@@ -49090,24 +49142,6 @@
4909049142
"Bucket": {
4909149143
"x-cipp-field-source": "storage"
4909249144
},
49093-
"EgressBytes": {
49094-
"x-cipp-field-source": "storage"
49095-
},
49096-
"EgressBytesToday": {
49097-
"type": "integer",
49098-
"x-cipp-field-source": "storage"
49099-
},
49100-
"EgressCapBytes": {
49101-
"x-cipp-field-source": "storage"
49102-
},
49103-
"EgressRejectClients": {
49104-
"type": "string",
49105-
"x-cipp-field-source": "storage"
49106-
},
49107-
"EgressRejectCount": {
49108-
"type": "integer",
49109-
"x-cipp-field-source": "storage"
49110-
},
4911149145
"ErrCount": {
4911249146
"type": "integer",
4911349147
"x-cipp-field-source": "storage"

Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserDomain.ps1

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,18 @@ function Push-DomainAnalyserDomain {
242242
$Result.DKIMEnabled = $false
243243
$ScoreExplanation.Add('DKIM Not Configured') | Out-Null
244244
}
245+
246+
# Persist the selectors the analyser just discovered so GetDkimSelectors reads them from the
247+
# Domains table instead of returning nothing (mirrors how Invoke-ListDomainHealth/ExecDnsConfig store them).
248+
$DiscoveredSelectors = @($DkimRecord.Selectors | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) | Sort-Object -Unique
249+
if ($DiscoveredSelectors.Count -gt 0) {
250+
$DkimSelectorsJson = [string]($DiscoveredSelectors | ConvertTo-Json -Compress)
251+
if ($DomainObject.PSObject.Properties.Name -notcontains 'DkimSelectors') {
252+
$DomainObject | Add-Member -MemberType NoteProperty -Name DkimSelectors -Value $DkimSelectorsJson -Force
253+
} else {
254+
$DomainObject.DkimSelectors = $DkimSelectorsJson
255+
}
256+
}
245257
} catch {
246258
$Message = 'DKIM Exception'
247259
Write-LogMessage -API 'DomainAnalyser' -tenant $DomainObject.TenantId -message $Message -LogData (Get-CippException -Exception $_) -sev Error
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
function Grant-CippAppGraphConsent {
2+
<#
3+
.SYNOPSIS
4+
Ensures a tenant-wide (AllPrincipals) oauth2PermissionGrant exists from an app's service
5+
principal to Microsoft Graph, covering the given delegated scopes.
6+
.DESCRIPTION
7+
Admin-consents delegated Microsoft Graph scopes for an app registration in the partner
8+
tenant, so users signing in through it are not prompted to consent. Used to pre-consent
9+
the OIDC + offline_access scopes an MCP client (Copilot Studio, Claude, ChatGPT, VS Code)
10+
requests: without offline_access consent, Entra will not issue a refresh token and the
11+
client re-authenticates every time the access token expires (~hourly).
12+
13+
This matters most in tenants that disable user consent to applications (which CIPP's own
14+
OauthConsentLowSec standard recommends): there, an un-consented offline_access request
15+
silently yields no refresh token. Admin consent is the only reliable path.
16+
17+
Additive — existing consented scopes are preserved and only the missing ones are added.
18+
The service principal of a freshly created app is not always queryable immediately, so
19+
the client SP lookup retries briefly before giving up.
20+
.PARAMETER AppId
21+
Application (client) ID of the app whose service principal should receive the grant.
22+
.PARAMETER Scopes
23+
Delegated Microsoft Graph scope names to ensure are consented (e.g. openid, profile,
24+
offline_access).
25+
.FUNCTIONALITY
26+
Internal
27+
#>
28+
[CmdletBinding()]
29+
param(
30+
[Parameter(Mandatory)]
31+
[string]$AppId,
32+
33+
[Parameter(Mandatory)]
34+
[string[]]$Scopes
35+
)
36+
37+
$GraphAppId = '00000003-0000-0000-c000-000000000000'
38+
39+
# The app's own service principal may still be replicating right after creation.
40+
$ClientSp = $null
41+
for ($Attempt = 1; $Attempt -le 3 -and -not $ClientSp.id; $Attempt++) {
42+
try {
43+
$ClientSp = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/servicePrincipals(appId='$AppId')?`$select=id" -NoAuthCheck $true -asapp $true
44+
} catch {
45+
Write-Information "[App-Consent] Service principal for $AppId not queryable yet (attempt $Attempt): $($_.Exception.Message)"
46+
}
47+
if (-not $ClientSp.id -and $Attempt -lt 3) { Start-Sleep -Seconds 2 }
48+
}
49+
if (-not $ClientSp.id) {
50+
throw "Service principal for app '$AppId' was not found; cannot write consent grant yet."
51+
}
52+
53+
$GraphSp = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/servicePrincipals(appId='$GraphAppId')?`$select=id" -NoAuthCheck $true -asapp $true
54+
if (-not $GraphSp.id) {
55+
throw 'Microsoft Graph service principal was not found in this tenant.'
56+
}
57+
58+
$Grants = @(New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/servicePrincipals/$($ClientSp.id)/oauth2PermissionGrants" -NoAuthCheck $true -asapp $true)
59+
$TenantGrant = $Grants | Where-Object { $_.resourceId -eq $GraphSp.id -and $_.consentType -eq 'AllPrincipals' } | Select-Object -First 1
60+
61+
if ($TenantGrant) {
62+
$CurrentScopes = @($TenantGrant.scope -split ' ' | Where-Object { $_ })
63+
$MissingScopes = @($Scopes | Where-Object { $_ -notin $CurrentScopes })
64+
if ($MissingScopes.Count -eq 0) {
65+
return [PSCustomObject]@{ AppId = $AppId; Action = 'nochange'; Scopes = $CurrentScopes }
66+
}
67+
$MergedScopes = (@($CurrentScopes + $MissingScopes) | Sort-Object -Unique) -join ' '
68+
$PatchBody = @{ scope = $MergedScopes } | ConvertTo-Json -Compress
69+
$null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$($TenantGrant.id)" -body $PatchBody -type PATCH -NoAuthCheck $true -asapp $true
70+
return [PSCustomObject]@{ AppId = $AppId; Action = 'updated'; Scopes = @($MergedScopes -split ' ') }
71+
}
72+
73+
$CreateBody = @{
74+
clientId = $ClientSp.id
75+
consentType = 'AllPrincipals'
76+
resourceId = $GraphSp.id
77+
scope = ($Scopes -join ' ')
78+
} | ConvertTo-Json -Compress
79+
$null = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants' -body $CreateBody -type POST -NoAuthCheck $true -asapp $true
80+
return [PSCustomObject]@{ AppId = $AppId; Action = 'created'; Scopes = @($Scopes) }
81+
}

Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,59 @@ function Initialize-CIPPAuth {
243243
} else {
244244
Write-Information "[Auth-Init] EasyAuth already matches $($EnabledClients.Count) enabled API client(s) — no update needed"
245245
}
246+
247+
# Ensure offline_access is admin-consented on every MCP-enabled client so Entra
248+
# issues a refresh token — without it, MCP clients (Copilot Studio especially)
249+
# re-authenticate roughly every hour when the access token expires. Set at
250+
# client-creation time by Set-CIPPMCPClientApp, but re-checked here so a client
251+
# created before this existed, or whose service principal had not replicated at
252+
# creation, self-heals on the next warmup. Idempotent and cheap: the grant helper
253+
# no-ops once the scopes are present. Best-effort per client.
254+
foreach ($McpId in $McpClientIds) {
255+
if ([string]::IsNullOrEmpty($McpId)) { continue }
256+
try {
257+
$McpConsent = Grant-CippAppGraphConsent -AppId $McpId -Scopes @('openid', 'profile', 'offline_access')
258+
if ($McpConsent.Action -ne 'nochange') {
259+
Write-Information "[Auth-Init] MCP client $McpId offline_access consent: $($McpConsent.Action)"
260+
}
261+
} catch {
262+
Write-Information "[Auth-Init] MCP client $McpId offline_access consent reconcile failed (non-fatal): $_"
263+
}
264+
}
265+
266+
# Ensure the MCP OAuth scope advertisement (challenge header + discovery docs)
267+
# includes offline_access. These app settings are written by "Save to Azure",
268+
# but a code deploy does NOT regenerate them — an instance that never re-saved
269+
# after offline_access was added would still hand strict discovery clients
270+
# (e.g. Copilot CLI) a scope with no offline_access, so they re-authenticate
271+
# ~hourly. Reconcile on drift only: once offline_access is present this never
272+
# writes (or restarts) again. Values come from the same helper Save to Azure
273+
# uses, so a write here is byte-identical and self-terminating.
274+
if ($McpClientIds.Count -gt 0 -and $env:WEBSITE_HOSTNAME) {
275+
try {
276+
$McpScope = "https://$($env:WEBSITE_HOSTNAME)/user_impersonation"
277+
$HeaderTokens = @("$($env:WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES)" -split ' ' | Where-Object { $_ })
278+
$ScopeDrift = ('offline_access' -notin $HeaderTokens) -or ($McpScope -notin $HeaderTokens)
279+
if (-not $ScopeDrift -and $env:CIPPNG) {
280+
foreach ($DocJson in @($env:CRAFT_PRM, $env:CRAFT_PRM_AS)) {
281+
$Supported = $null
282+
try { $Supported = @(($DocJson | ConvertFrom-Json -ErrorAction Stop).scopes_supported) } catch { $ScopeDrift = $true; break }
283+
if ('offline_access' -notin $Supported -or $McpScope -notin $Supported) { $ScopeDrift = $true; break }
284+
}
285+
}
286+
if ($ScopeDrift) {
287+
$McpRg = Get-CIPPFunctionAppResourceGroup -SiteName $env:WEBSITE_SITE_NAME
288+
$McpAppSettings = Get-CippMcpScopeAppSettings -Hostname $env:WEBSITE_HOSTNAME -TenantId $env:TenantID -IsCippNg:([bool]$env:CIPPNG)
289+
$null = Update-CIPPAzFunctionAppSetting -Name $env:WEBSITE_SITE_NAME -ResourceGroupName $McpRg -AppSetting $McpAppSettings
290+
Write-Information '[Auth-Init] MCP OAuth scope advertisement was missing offline_access — reconciled app settings and requesting restart'
291+
Request-CIPPRestart -Reason 'MCP OAuth scope settings reconciled (offline_access) during warmup'
292+
} else {
293+
Write-Information '[Auth-Init] MCP OAuth scope advertisement already includes offline_access — no update needed'
294+
}
295+
} catch {
296+
Write-Information "[Auth-Init] MCP OAuth scope reconcile failed (non-fatal): $_"
297+
}
298+
}
246299
}
247300
} catch {
248301
Write-Information "[Auth-Init] API client reconcile failed (non-fatal): $_"

Modules/CIPPCore/Public/Authentication/Set-CIPPMCPClientApp.ps1

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,36 @@ function Set-CIPPMCPClientApp {
125125
$SpaRedirectUris.Add($Uri)
126126
}
127127

128+
# Declare offline_access (Microsoft Graph, delegated) so Entra will issue a refresh token to
129+
# MCP clients. Without it, Copilot Studio (Manual OAuth) and stricter discovery clients
130+
# re-prompt for sign-in roughly every hour when the access token expires. Additive — every
131+
# permission already on the app is preserved; only offline_access is added if missing.
132+
$GraphResourceId = '00000003-0000-0000-c000-000000000000'
133+
$OfflineAccessId = '7427e0e9-2fba-42fe-b0c0-848c9e6a8182'
134+
$RequiredResourceAccess = [System.Collections.Generic.List[object]]::new()
135+
$GraphEntrySeen = $false
136+
foreach ($Resource in @($App.requiredResourceAccess)) {
137+
$ResourceAccess = [System.Collections.Generic.List[object]]::new()
138+
foreach ($Access in @($Resource.resourceAccess)) { $ResourceAccess.Add(@{ id = $Access.id; type = $Access.type }) }
139+
if ($Resource.resourceAppId -eq $GraphResourceId) {
140+
$GraphEntrySeen = $true
141+
if (-not ($ResourceAccess | Where-Object { $_.id -eq $OfflineAccessId })) {
142+
$ResourceAccess.Add(@{ id = $OfflineAccessId; type = 'Scope' })
143+
}
144+
}
145+
$RequiredResourceAccess.Add(@{ resourceAppId = $Resource.resourceAppId; resourceAccess = @($ResourceAccess) })
146+
}
147+
if (-not $GraphEntrySeen) {
148+
$RequiredResourceAccess.Add(@{ resourceAppId = $GraphResourceId; resourceAccess = @(@{ id = $OfflineAccessId; type = 'Scope' }) })
149+
}
150+
128151
$PatchBody = @{
129152
identifierUris = @($IdentifierUris)
130153
api = $Api
131154
web = @{ redirectUris = @($WebRedirectUris) }
132155
spa = @{ redirectUris = @($SpaRedirectUris) }
133156
publicClient = @{ redirectUris = @($PublicRedirectUris) }
157+
requiredResourceAccess = @($RequiredResourceAccess)
134158
# "Allow public client flows" — required for the secret-less PKCE redemption every MCP
135159
# client above performs.
136160
isFallbackPublicClient = $true
@@ -140,6 +164,20 @@ function Set-CIPPMCPClientApp {
140164
try {
141165
$null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$($App.id)" -type PATCH -body $PatchBody -NoAuthCheck $true -asapp $true
142166
Write-LogMessage -headers $Headers -API 'ExecApiClient' -message "Configured app registration $AppId as MCP resource (identifier URIs, v2 tokens, known MCP client callbacks + pre-authorization)." -Sev 'Info'
167+
168+
# Admin-consent the OIDC + offline_access delegated scopes for this app so Entra
169+
# issues the refresh token without a per-user consent prompt. Copilot Studio uses
170+
# Manual OAuth and never reads the challenge/discovery scope, so this app-registration
171+
# consent — not WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES — is what makes its refresh work.
172+
# Best-effort: the app still works without it (users may see a one-time prompt, or the
173+
# grant is retried the next time the client is saved), so a failure here is non-fatal.
174+
try {
175+
$ConsentResult = Grant-CippAppGraphConsent -AppId $AppId -Scopes @('openid', 'profile', 'offline_access')
176+
Write-Information "[MCP-Client] offline_access admin-consent for $AppId : $($ConsentResult.Action)"
177+
} catch {
178+
Write-LogMessage -headers $Headers -API 'ExecApiClient' -message "MCP client $AppId configured, but admin-consent for offline_access could not be written (refresh tokens may prompt on first use): $($_.Exception.Message)" -Sev 'Warning'
179+
}
180+
143181
return @{ Success = $true; IdentifierUris = @($IdentifierUris); RedirectUris = @($PublicRedirectUris) }
144182
} catch {
145183
$ErrMsg = $_.Exception.Message

Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineGroupTemplateState.ps1

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,17 @@ function Get-CIPPBaselineGroupTemplateState {
3535
if (-not $Template -or [string]::IsNullOrWhiteSpace($GroupName)) { return @{ Current = $null } }
3636

3737
if ("$($Template.groupType)" -eq 'dynamicDistribution') {
38-
$Distros = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoDynamicDistributionGroup')
39-
if ($Distros.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ExoDynamicDistributionGroup')) {
40-
return @{ Current = $null }
41-
}
42-
$Existing = $Distros | Where-Object { "$($_.Name)" -eq $GroupName } | Select-Object -First 1
43-
} else {
44-
$Groups = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'Groups')
45-
if ($Groups.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'Groups')) {
46-
return @{ Current = $null }
47-
}
48-
$Existing = $Groups | Where-Object { "$($_.displayName)" -eq $GroupName } | Select-Object -First 1
38+
# Dynamic Distribution Groups are not supported by CIPP: do not grade them (a graded DDL reads as
39+
# permanent drift and its executor write throws). Return not-applicable, consistent with the executor skip.
40+
return @{ Current = $null }
4941
}
5042

43+
$Groups = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'Groups')
44+
if ($Groups.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'Groups')) {
45+
return @{ Current = $null }
46+
}
47+
$Existing = $Groups | Where-Object { "$($_.displayName)" -eq $GroupName } | Select-Object -First 1
48+
5149
$Current = [PSCustomObject]@{ deployed = [bool]$Existing }
5250
# Carried for the executor, not graded.
5351
$Current | Add-Member -NotePropertyName 'templateBody' -NotePropertyValue $Template

0 commit comments

Comments
 (0)