Skip to content

Commit a0b4405

Browse files
fix(tenants): support direct tenant auth checks
Update tenant onboarding and access validation to treat direct tenants separately from GDAP tenants. The backend now records direct-tenant service account metadata, blocks in-place GDAP→Direct conversion, cleans up stored direct refresh tokens when conversion is refused, and evaluates direct-tenant permissions from the authenticated service account (including Global Admin handling). Frontend settings pages were updated to show tenant type/service-account details, use AssignedRoles (with backward compatibility for GDAPRoles), hide GDAP-only actions where not applicable, and add a direct-tenant re-auth flow via wizard deep links. Adds Pester coverage for direct-token cleanup and the new direct-vs-GDAP access-check behavior. Synced from CyberDrain/CIPP@b231564
1 parent d850b41 commit a0b4405

6 files changed

Lines changed: 472 additions & 53 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
function Remove-CIPPDirectTenantToken {
2+
<#
3+
.FUNCTIONALITY
4+
Internal
5+
.SYNOPSIS
6+
Removes the per-tenant refresh token stored for a direct tenant.
7+
.DESCRIPTION
8+
Direct tenants authenticate with their own refresh token, kept in Key Vault (or the DevSecrets
9+
table when running locally) under the tenant id, and cached in an environment variable of the
10+
same name. This removes both so a tenant that is not a direct tenant cannot fall back to it.
11+
Cleanup is best effort - failures are logged rather than thrown, because callers use this while
12+
handling another outcome.
13+
.PARAMETER TenantId
14+
The customer id (GUID) of the tenant whose stored credential should be removed.
15+
#>
16+
[CmdletBinding(SupportsShouldProcess = $true)]
17+
param(
18+
[Parameter(Mandatory = $true)]
19+
[string]$TenantId
20+
)
21+
22+
if (-not $PSCmdlet.ShouldProcess($TenantId, 'Remove direct tenant refresh token')) {
23+
return
24+
}
25+
26+
try {
27+
if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') {
28+
$SecretName = $TenantId -replace '-', '_'
29+
$DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets'
30+
$Secret = Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'"
31+
if ($Secret -and $Secret.PSObject.Properties.Name -contains $SecretName) {
32+
$Secret.$SecretName = ''
33+
$null = Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force
34+
}
35+
Remove-Item -Path "env:\$SecretName" -ErrorAction SilentlyContinue
36+
} else {
37+
$KeyVaultName = Get-CippKeyVaultName
38+
$null = Remove-CippKeyVaultSecret -VaultName $KeyVaultName -Name $TenantId -ErrorAction Stop
39+
}
40+
41+
Remove-Item -Path "env:\$TenantId" -ErrorAction SilentlyContinue
42+
Write-Information "Removed stored direct tenant refresh token for $TenantId"
43+
} catch {
44+
Write-Warning "Failed to remove the stored direct tenant refresh token for $TenantId - $($_.Exception.Message)"
45+
}
46+
}

Modules/CIPPCore/Public/Test-CIPPAccessTenant.ps1

Lines changed: 86 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ function Test-CIPPAccessTenant {
2323
@{ Name = 'Domain Name Administrator'; Id = '8329153b-31d0-4727-b945-745eb3bc5f31'; Optional = $true }
2424
)
2525

26+
# Global Administrator implicitly grants every role listed above.
27+
$GlobalAdminRoleId = '62e90394-69f5-4237-9190-012177145e10'
28+
2629
$TenantParams = @{
2730
IncludeErrors = $true
2831
}
@@ -50,13 +53,20 @@ function Test-CIPPAccessTenant {
5053
$GraphStatus = $false
5154
$ExchangeStatus = $false
5255

56+
# Direct tenants authenticate with their own per-tenant refresh token rather than through a
57+
# GDAP relationship, so directory roles granted to the partner tenant do not apply to them.
58+
$IsDirectTenant = $Tenant.delegatedPrivilegeStatus -eq 'directTenant'
59+
$TenantType = if ($IsDirectTenant) { 'Direct' } else { 'GDAP' }
60+
5361
$Results = [PSCustomObject]@{
5462
TenantName = $Tenant.defaultDomainName
63+
TenantType = $TenantType
64+
ServiceAccount = ''
5565
GraphStatus = $false
5666
GraphTest = ''
5767
ExchangeStatus = $false
5868
ExchangeTest = ''
59-
GDAPRoles = ''
69+
AssignedRoles = ''
6070
MissingRoles = ''
6171
OrgManagementRoles = @()
6272
OrgManagementRolesMissing = @()
@@ -66,46 +76,89 @@ function Test-CIPPAccessTenant {
6676
$AddedText = ''
6777
try {
6878
$TenantId = $Tenant.customerId
69-
$BulkRequests = $ExpectedRoles | ForEach-Object { @(
70-
@{
71-
id = "roleManagement_$($_.Id)"
72-
method = 'GET'
73-
url = "roleManagement/directory/roleAssignments?`$filter=roleDefinitionId eq '$($_.Id)'&`$expand=principal"
74-
}
75-
)
76-
}
77-
$GDAPRolesGraph = New-GraphBulkRequest -tenantid $TenantId -Requests $BulkRequests
78-
$GDAPRoles = [System.Collections.Generic.List[object]]::new()
79+
$AssignedRoles = [System.Collections.Generic.List[object]]::new()
7980
$MissingRoles = [System.Collections.Generic.List[object]]::new()
8081

81-
foreach ($RoleId in $ExpectedRoles) {
82-
$GraphRole = $GDAPRolesGraph.body.value | Where-Object -Property roleDefinitionId -EQ $RoleId.Id
83-
$Role = $GraphRole.principal | Where-Object -Property organizationId -EQ $env:TenantID
82+
if ($IsDirectTenant) {
83+
# A direct tenant is reached with the service account's own delegated token, so the
84+
# roles that matter are the ones that account holds inside the tenant itself rather
85+
# than anything assigned to the partner tenant.
86+
$ServiceAccount = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/me?$select=id,displayName,userPrincipalName' -tenantid $TenantId -ErrorAction Stop
87+
$Results.ServiceAccount = $ServiceAccount.userPrincipalName
88+
89+
$Memberships = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/me/transitiveMemberOf' -tenantid $TenantId -ErrorAction Stop
90+
$DirectoryRoles = @($Memberships | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.directoryRole' })
91+
$IsGlobalAdmin = $DirectoryRoles.roleTemplateId -contains $GlobalAdminRoleId
92+
93+
foreach ($RoleId in $ExpectedRoles) {
94+
$HeldRole = $DirectoryRoles | Where-Object { $_.roleTemplateId -eq $RoleId.Id }
95+
if ($HeldRole) {
96+
$AssignedRoles.Add([PSCustomObject]@{
97+
Role = $RoleId.Name
98+
Group = $ServiceAccount.userPrincipalName
99+
})
100+
} elseif ($IsGlobalAdmin) {
101+
$AssignedRoles.Add([PSCustomObject]@{
102+
Role = $RoleId.Name
103+
Group = "$($ServiceAccount.userPrincipalName) (via Global Administrator)"
104+
})
105+
} else {
106+
$MissingRoles.Add(
107+
[PSCustomObject]@{
108+
Name = $RoleId.Name
109+
Type = 'Tenant'
110+
Optional = $RoleId.Optional
111+
}
112+
)
113+
}
114+
}
84115

85-
if (!$Role) {
86-
$MissingRoles.Add(
87-
[PSCustomObject]@{
88-
Name = $RoleId.Name
89-
Type = 'Tenant'
90-
Optional = $RoleId.Optional
116+
$RequiredMissingRoles = $MissingRoles | Where-Object { $_.Optional -ne $true }
117+
if (($RequiredMissingRoles | Measure-Object).Count -gt 0) {
118+
$AddedText = 'but the service account is missing required roles'
119+
} elseif (($MissingRoles | Measure-Object).Count -gt 0) {
120+
$AddedText = 'but the service account is missing optional roles'
121+
}
122+
} else {
123+
$BulkRequests = $ExpectedRoles | ForEach-Object { @(
124+
@{
125+
id = "roleManagement_$($_.Id)"
126+
method = 'GET'
127+
url = "roleManagement/directory/roleAssignments?`$filter=roleDefinitionId eq '$($_.Id)'&`$expand=principal"
91128
}
92129
)
93-
} else {
94-
$GDAPRoles.Add([PSCustomObject]@{
95-
Role = $RoleId.Name
96-
Group = $Role.displayName
97-
})
98130
}
99-
}
131+
$GDAPRolesGraph = New-GraphBulkRequest -tenantid $TenantId -Requests $BulkRequests
132+
133+
foreach ($RoleId in $ExpectedRoles) {
134+
$GraphRole = $GDAPRolesGraph.body.value | Where-Object -Property roleDefinitionId -EQ $RoleId.Id
135+
$Role = $GraphRole.principal | Where-Object -Property organizationId -EQ $env:TenantID
136+
137+
if (!$Role) {
138+
$MissingRoles.Add(
139+
[PSCustomObject]@{
140+
Name = $RoleId.Name
141+
Type = 'Tenant'
142+
Optional = $RoleId.Optional
143+
}
144+
)
145+
} else {
146+
$AssignedRoles.Add([PSCustomObject]@{
147+
Role = $RoleId.Name
148+
Group = $Role.displayName
149+
})
150+
}
151+
}
100152

101-
$RequiredMissingRoles = $MissingRoles | Where-Object { $_.Optional -ne $true }
102-
if (($RequiredMissingRoles | Measure-Object).Count -gt 0) {
103-
$AddedText = 'but missing required GDAP roles'
104-
} elseif (($MissingRoles | Measure-Object).Count -gt 0) {
105-
$AddedText = 'but missing optional GDAP roles'
153+
$RequiredMissingRoles = $MissingRoles | Where-Object { $_.Optional -ne $true }
154+
if (($RequiredMissingRoles | Measure-Object).Count -gt 0) {
155+
$AddedText = 'but missing required GDAP roles'
156+
} elseif (($MissingRoles | Measure-Object).Count -gt 0) {
157+
$AddedText = 'but missing optional GDAP roles'
158+
}
106159
}
107160

108-
$GraphTest = "Successfully connected to Graph $($AddedText)"
161+
$GraphTest = "Successfully connected to Graph $($AddedText)".Trim()
109162
$GraphStatus = $true
110163
} catch {
111164
$ErrorMessage = Get-CippException -Exception $_
@@ -176,7 +229,7 @@ function Test-CIPPAccessTenant {
176229
$Results.GraphTest = $GraphTest
177230
$Results.ExchangeStatus = $ExchangeStatus
178231
$Results.ExchangeTest = $ExchangeTest
179-
$Results.GDAPRoles = @($GDAPRoles)
232+
$Results.AssignedRoles = @($AssignedRoles)
180233
$Results.MissingRoles = @($MissingRoles)
181234

182235
$Headers = $Headers.UserDetails

Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAccessChecks.ps1

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,12 @@ function Invoke-ExecAccessChecks {
4848
TenantId = $Tenant.customerId
4949
TenantName = $Tenant.displayName
5050
DefaultDomainName = $Tenant.defaultDomainName
51+
TenantType = if ($Tenant.delegatedPrivilegeStatus -eq 'directTenant') { 'Direct' } else { 'GDAP' }
52+
ServiceAccount = $Tenant.directTenantUserPrincipalName
53+
ServiceAccountLastAuth = $Tenant.directTenantAuthDate
5154
GraphStatus = 'Not run yet'
5255
ExchangeStatus = 'Not run yet'
53-
GDAPRoles = ''
56+
AssignedRoles = ''
5457
MissingRoles = ''
5558
LastRun = ''
5659
GraphTest = ''
@@ -63,14 +66,21 @@ function Invoke-ExecAccessChecks {
6366
$Data = @($TenantCheck.Data | ConvertFrom-Json -ErrorAction Stop)
6467
$TenantResult.GraphStatus = $Data.GraphStatus
6568
$TenantResult.ExchangeStatus = $Data.ExchangeStatus
66-
$TenantResult.GDAPRoles = $Data.GDAPRoles
69+
# Fall back to the old property name so checks cached before the rename
70+
# keep rendering until the tenant is checked again.
71+
$TenantResult.AssignedRoles = $Data.AssignedRoles ?? $Data.GDAPRoles
6772
$TenantResult.MissingRoles = $Data.MissingRoles
6873
$TenantResult.LastRun = $Data.LastRun
6974
$TenantResult.GraphTest = $Data.GraphTest
7075
$TenantResult.ExchangeTest = $Data.ExchangeTest
7176
$TenantResult.OrgManagementRoles = $Data.OrgManagementRoles ? @($Data.OrgManagementRoles) : @()
7277
$TenantResult.OrgManagementRolesMissing = $Data.OrgManagementRolesMissing ? @($Data.OrgManagementRolesMissing) : @()
7378
$TenantResult.OrgManagementRepairNeeded = $Data.OrgManagementRolesMissing.Count -gt 0
79+
# The check reads the account live, so it also backfills direct tenants
80+
# onboarded before the service account was recorded on the tenant.
81+
if ($Data.ServiceAccount) {
82+
$TenantResult.ServiceAccount = $Data.ServiceAccount
83+
}
7484
}
7585
$TenantResult
7686
}

Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecAddTenant.ps1

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ function Invoke-ExecAddTenant {
1313
$tenantId = $Request.body.tenantId
1414
$defaultDomainName = $Request.body.defaultDomainName
1515

16+
# The account that consented is recorded so the connection can be traced and refreshed
17+
# later. The auth date is stamped server side rather than trusting the client.
18+
$ServiceAccount = $Request.body.username ?? ''
19+
$AuthDate = (Get-Date).ToUniversalTime()
20+
1621
# Get the Tenants table
1722
$TenantsTable = Get-CippTable -tablename 'Tenants'
1823
#force a refresh of the authentication info
@@ -23,11 +28,24 @@ function Invoke-ExecAddTenant {
2328
if ($tenantId -eq $env:TenantID) {
2429
# If the tenant is the partner tenant, return an error because you cannot add the partner tenant as direct tenant
2530
$Results = @{'message' = 'You cannot add the partner tenant as a direct tenant. Please connect the tenant using the "Connect to Partner Tenant" option. '; 'severity' = 'error'; }
31+
} elseif ($ExistingTenant -and $ExistingTenant.delegatedPrivilegeStatus -ne 'directTenant') {
32+
# Converting an existing GDAP tenant in place would silently change how CIPP
33+
# authenticates to it. Require the tenant to be offboarded first so the switch is
34+
# deliberate.
35+
# The sign-in that got us here already stored a per-tenant refresh token, so discard it
36+
# again - keeping it would leave a credential behind for a conversion we just refused.
37+
Remove-CIPPDirectTenantToken -TenantId $tenantId
38+
39+
$Results = @{'message' = "$($ExistingTenant.displayName) is already onboarded as a GDAP tenant and cannot be converted to a Direct Tenant. To manage it as a Direct Tenant, remove the tenant first under Settings > Tenants, then add it again using the Direct Tenant flow."; 'severity' = 'error' }
40+
Write-LogMessage -tenant $ExistingTenant.defaultDomainName -tenantid $ExistingTenant.customerId -API 'NewTenant' -message "Blocked conversion of GDAP tenant $($ExistingTenant.displayName) to a Direct Tenant, attempted by $ServiceAccount." -Sev 'Warn'
2641
} elseif ($ExistingTenant) {
27-
# Update existing tenant
28-
$ExistingTenant.delegatedPrivilegeStatus = 'directTenant'
42+
# Existing direct tenant - refresh the stored credentials and service account details.
43+
$ExistingTenant | Add-Member -NotePropertyName 'directTenantUserPrincipalName' -NotePropertyValue $ServiceAccount -Force
44+
$ExistingTenant | Add-Member -NotePropertyName 'directTenantAuthDate' -NotePropertyValue $AuthDate -Force
2945
Add-CIPPAzDataTableEntity @TenantsTable -Entity $ExistingTenant -Force | Out-Null
30-
$Results = @{'message' = 'Successfully updated tenant.'; 'severity' = 'success' }
46+
47+
$Results = @{'message' = "Successfully updated the credentials for $($ExistingTenant.displayName)."; 'severity' = 'success' }
48+
Write-LogMessage -tenant $ExistingTenant.defaultDomainName -tenantid $ExistingTenant.customerId -API 'NewTenant' -message "Refreshed Direct Tenant credentials for $($ExistingTenant.displayName) using $ServiceAccount." -Sev 'Info'
3149
} else {
3250
# Create new tenant entry
3351
try {
@@ -67,21 +85,23 @@ function Invoke-ExecAddTenant {
6785

6886
# Create new tenant object
6987
$NewTenant = [PSCustomObject]@{
70-
PartitionKey = 'Tenants'
71-
RowKey = $tenantId
72-
customerId = $tenantId
73-
displayName = $displayName
74-
defaultDomainName = $defaultDomainName
75-
initialDomainName = $initialDomainName
76-
delegatedPrivilegeStatus = 'directTenant'
77-
domains = ''
78-
Excluded = $false
79-
ExcludeUser = ''
80-
ExcludeDate = ''
81-
GraphErrorCount = 0
82-
LastGraphError = ''
83-
RequiresRefresh = $false
84-
LastRefresh = (Get-Date).ToUniversalTime()
88+
PartitionKey = 'Tenants'
89+
RowKey = $tenantId
90+
customerId = $tenantId
91+
displayName = $displayName
92+
defaultDomainName = $defaultDomainName
93+
initialDomainName = $initialDomainName
94+
delegatedPrivilegeStatus = 'directTenant'
95+
directTenantUserPrincipalName = $ServiceAccount
96+
directTenantAuthDate = $AuthDate
97+
domains = ''
98+
Excluded = $false
99+
ExcludeUser = ''
100+
ExcludeDate = ''
101+
GraphErrorCount = 0
102+
LastGraphError = ''
103+
RequiresRefresh = $false
104+
LastRefresh = (Get-Date).ToUniversalTime()
85105
}
86106

87107
# Add tenant to table

0 commit comments

Comments
 (0)