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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,16 @@ All notable changes to this module are recorded here. Format follows
provider it is handed can use the one name. A contract test pins the alias on every provider but
Active Directory, which stores nothing.

- **The Entra connect reads the tenant's licences once.** `Get-EntraCapability` reads the subscribed
SKUs at connect time and records on the connection whether the tenant holds Entra ID P1 and P2
(or Governance). `New-EntraEnvironment` then skips the Conditional Access step without P1 and the
role eligibility step without P2, once, with one message naming the licence and the step, instead
of nine policy refusals and three eligibility warnings that each said the same thing in Graph's
words; each skipped step carries its `Reason` in the result. `-IncludeUnlicensed` attempts them
regardless, and a tenant whose SKUs the app cannot read gets every step as before. The report and
the verifier stop asking for eligibilities a tenant without P2 cannot hold, so the one read Graph
refuses is never made.

### Changed

- **The Entra report takes the shared parameters.** `-Format` and `-Path` are kept as aliases of
Expand Down
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,17 @@ native application with no secret) is created without S256 PKCE. Neither has a p
`New-PingOnePopulation.Tests.ps1` and `New-PingOneApplication.Tests.ps1` assert both. PingOne's own
applications and built-in resources are matched by type, never name, and never touched.

### The Entra connect reads the tenant's licences once, and unknown means attempt everything

`Get-EntraCapability` runs inside `Connect-EntraEnvironment` and puts `Capabilities` on the
connection: `Known`, `EntraP1`, `EntraP2`. `New-EntraEnvironment` skips Conditional Access
policies without P1 and role eligibilities without P2 with one message, and the report and the
verifier do not ask Graph for eligibilities a tenant without P2 cannot hold. The rule that keeps
this safe: `Known = $false` - the app could not read `/subscribedSkus` - means every step runs, as
it did before the probe existed. The probe may remove noise; it may never remove a step the
tenant would have run. Teardown deliberately ignores it: a tenant whose P2 lapsed may still hold
eligibilities Graph will not show, and deleting the role definitions would orphan them.

### Every HTTP provider handles text encoding the same way, and none of it is optional

Windows PowerShell 5.1 corrupts non-ASCII text in both directions, silently, and PowerShell 7 hides
Expand Down
60 changes: 60 additions & 0 deletions Providers/Entra/Private/Get-EntraCapability.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
function Get-EntraCapability {
<#
.SYNOPSIS
Reads once, at connect time, which licence-gated features the tenant can hold
.DESCRIPTION
Two of the seed's fourteen steps need a licence the tenant may not have: Conditional
Access policies need Entra ID P1, and role eligibilities - Privileged Identity Management -
need Entra ID P2 or Entra ID Governance. Without the probe each of those steps found out
for itself, one refusal per object: nine policy failures and three eligibility warnings,
each saying the same thing in Graph's words. With it, the connection knows before the
first request, and the seed skips the step once with one message.

The answer comes from the tenant's subscribed SKUs: a plan counts when its SKU is enabled
and the plan itself is provisioned. A tenant that will not let the app read its SKUs -
the read needs Organization.Read.All or Directory.Read.All, which the connect already
requires for /organization - answers Known = $false, and every step is then attempted as
before, so the probe can only ever remove noise, never a step the tenant would have run.
.PARAMETER Connection
The connection being established; the same hashtable Connect-EntraEnvironment builds
.OUTPUTS
PSCustomObject with Known, EntraP1, EntraP2 and Plans, the provisioned plan names
.EXAMPLE
PS> Get-EntraCapability -Connection $candidate

Known True, EntraP1 False, EntraP2 False for a tenant on free Entra ID.
#>
[CmdletBinding()]
[OutputType([PSCustomObject])]
param(
[Parameter(Mandatory = $true)]
[hashtable]$Connection
)

$plans = New-Object System.Collections.Generic.List[string]
$known = $true
try {
foreach ($sku in @((Invoke-EntraRequest -Method GET -Path '/subscribedSkus' -Connection $Connection).value)) {
if ([string]$sku.capabilityStatus -ne 'Enabled') { continue }
foreach ($plan in @($sku.servicePlans)) {
if ([string]$plan.provisioningStatus -eq 'Success' -and $plan.servicePlanName) { $plans.Add([string]$plan.servicePlanName) }
}
}
}
catch {
$known = $false
Write-Verbose "Could not read the tenant's subscribed SKUs, so its licences are unknown and every step will be attempted: $($_.Exception.Message)"
}

# P2 and Governance both carry Privileged Identity Management; either includes P1.
$p2 = @($plans | Where-Object { $_ -match '^AAD_PREMIUM_P2$|GOVERNANCE' }).Count -gt 0
$p1 = $p2 -or @($plans | Where-Object { $_ -eq 'AAD_PREMIUM' }).Count -gt 0

return [PSCustomObject]@{
PSTypeName = 'EntraCapability'
Known = $known
EntraP1 = $p1
EntraP2 = $p2
Plans = @($plans | Sort-Object -Unique)
}
}
13 changes: 12 additions & 1 deletion Providers/Entra/Public/Connect-EntraEnvironment.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@
Returns the connection summary

.OUTPUTS
EntraConnection when -PassThru is supplied
EntraConnection when -PassThru is supplied, carrying Capabilities: whether the tenant's
licences could be read, and whether it holds Entra ID P1 and P2

.EXAMPLE
PS> Connect-EntraEnvironment -TenantId b818de68-9112-40b3-adc2-6838048cd611 `
Expand Down Expand Up @@ -218,6 +219,7 @@
AccessToken = $null
TokenExpiresOn = $null
TokenRoles = @()
Capabilities = $null
}

if ($Interactive) {
Expand Down Expand Up @@ -294,6 +296,14 @@
return
}

# Read once what the tenant is licensed for, so the seed can skip a step the tenant cannot
# hold with one message rather than discover it one refusal per object. A tenant that will
# not let the app read its SKUs answers "unknown", and every step is attempted as before.
$candidate.Capabilities = Get-EntraCapability -Connection $candidate
if ($candidate.Capabilities.Known) {
Write-Verbose ("Tenant licences: Entra ID P1 {0}, P2 {1}" -f $candidate.Capabilities.EntraP1, $candidate.Capabilities.EntraP2)
}

$script:EntraConnection = $candidate

Write-Verbose "Connected to $($candidate.TenantName) as $ClientId, seeding under $Prefix on $($candidate.UpnSuffix)"
Expand Down Expand Up @@ -325,6 +335,7 @@
UpnSuffix = $candidate.UpnSuffix
VerifiedDomains = $candidate.VerifiedDomains
GrantedRoles = $candidate.TokenRoles
Capabilities = $candidate.Capabilities
}
}
}
17 changes: 12 additions & 5 deletions Providers/Entra/Public/Get-EntraEnvironmentReport.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,19 @@
# a null count - which prints as blank rather than as a zero that would read as "none".
$eligibilities = @()
$eligibilityCount = $null
try {
$eligibilities = @(Get-EntraSeededObject -Type RoleEligibilities -Connection $connection -ErrorAction Stop)
$eligibilityCount = $eligibilities.Count
$capability = $connection.Capabilities
if ($capability -and $capability.Known -and -not $capability.EntraP2) {
# Without P2 there can be none, and the read would be refused: zero, not unknown.
$eligibilityCount = 0
}
catch {
Write-Warning "Could not read the role eligibilities, so they are reported as unknown: $($_.Exception.Message)"
else {
try {
$eligibilities = @(Get-EntraSeededObject -Type RoleEligibilities -Connection $connection -ErrorAction Stop)
$eligibilityCount = $eligibilities.Count
}
catch {
Write-Warning "Could not read the role eligibilities, so they are reported as unknown: $($_.Exception.Message)"
}
}

# Counted three different ways on purpose, because the three disagree and the disagreement
Expand Down
43 changes: 39 additions & 4 deletions Providers/Entra/Public/New-EntraEnvironment.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,19 @@
users, groups and devices, which all exist by now
9. Custom directory roles - definitions only, assigned to nobody
10. Role eligibilities - eligible schedules over those definitions, naming seeded
principals and scoped to seeded units. Needs Entra ID P2; warns and continues without
principals and scoped to seeded units. Needs Entra ID P2 or Entra ID Governance
11. Named locations - referenced by the policies below
12. Authentication strengths - referenced by the policies below
13. Conditional Access policies - scoped to groups, conditioned on locations
13. Conditional Access policies - scoped to groups, conditioned on locations. Needs
Entra ID P1
14. Containment - places anything a replication race left outside its unit

The two licence-gated steps are decided once, from the licences the connect read. A
tenant without P1 skips the policies, one without P2 skips the eligibilities, each with
one message naming the licence, rather than nine policy refusals and three eligibility
warnings that each say the same thing in Graph's words. A tenant whose licences could not
be read gets every step, and -IncludeUnlicensed attempts them regardless.

This creates roughly 1,190 objects. Everything that can be sent
through Graph's $batch endpoint is, in chunks of twenty, which is the difference
between a run of a few minutes and a run of well over an hour.
Expand All @@ -46,6 +53,9 @@

.PARAMETER Skip
Steps to leave out. Takes the same names as Remove-EntraEnvironment's -Keep.
.PARAMETER IncludeUnlicensed
Attempt the Conditional Access and role eligibility steps even when the licences the
connect read say the tenant cannot hold them

.PARAMETER SkuPartNumber
Which SKU the licensing step should assign. Defaults to an automatically chosen one
Expand Down Expand Up @@ -104,6 +114,9 @@
[ValidateNotNullOrEmpty()]
[string]$SkuPartNumber,

[Parameter()]
[switch]$IncludeUnlicensed,

[Parameter()]
[switch]$ShowProgress,

Expand Down Expand Up @@ -157,21 +170,41 @@
[PSCustomObject]@{ Name = 'Containment'; Action = { Update-EntraContainment -PassThru:$true -ShowProgress:$ShowProgress } }
)

# What the tenant cannot hold, decided once from the licences the connect read. A tenant
# whose licences could not be read gets every step, so this can only remove noise, never a
# step the tenant would have run.
$unlicensed = [ordered]@{}
$capability = $connection.Capabilities
if (-not $IncludeUnlicensed -and $capability -and $capability.Known) {
if (-not $capability.EntraP1) { $unlicensed['ConditionalAccessPolicies'] = 'Conditional Access needs Entra ID P1' }
if (-not $capability.EntraP2) { $unlicensed['RoleEligibilities'] = 'Privileged Identity Management needs Entra ID P2 or Entra ID Governance' }
foreach ($name in @($unlicensed.Keys)) { if ($Skip -contains $name) { $unlicensed.Remove($name) } }
}
if ($unlicensed.Count -gt 0) {
$named = @($unlicensed.GetEnumerator() | ForEach-Object { '{0} ({1})' -f $_.Key, $_.Value }) -join ' and '
Write-Warning ("Skipping $named`: the tenant '$($connection.TenantName)' is not licensed for that. The rest of the " +
'environment is seeded regardless; -IncludeUnlicensed attempts the skipped step anyway.')
}

$outcomes = [System.Collections.Generic.List[object]]::new()
$stepIndex = 0

foreach ($step in $steps) {
$stepIndex++

if ($Skip -contains $step.Name) {
Write-Verbose "Skipping step $($step.Name)"
$reason = $null
if ($Skip -contains $step.Name) { $reason = 'Skipped by -Skip' }
elseif ($unlicensed.Contains($step.Name)) { $reason = $unlicensed[$step.Name] }
if ($reason) {
Write-Verbose "Skipping step $($step.Name): $reason"
$outcomes.Add([PSCustomObject]@{
PSTypeName = 'EntraEnvironmentStep'
Step = $step.Name
Status = 'Skipped'
Count = 0
Items = @()
Error = $null
Reason = $reason
})
continue
}
Expand All @@ -188,6 +221,7 @@
Count = $items.Count
Items = $items
Error = $null
Reason = $null
})
Write-Verbose "Step $($step.Name) produced $($items.Count) object(s)"
}
Expand All @@ -202,6 +236,7 @@
Count = 0
Items = @()
Error = $_.Exception.Message
Reason = $null
})
}
}
Expand Down
6 changes: 6 additions & 0 deletions Providers/Entra/Public/Test-EntraEnvironment.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,14 @@ function Test-EntraEnvironment {
}

# --- Everything the licence decides, counted -------------------------------------------
$capability = $connection.Capabilities
foreach ($type in 'ServicePrincipals', 'NamedLocations', 'ConditionalAccessPolicies', 'AdministrativeUnits',
'AuthenticationStrengths', 'DirectoryRoles', 'RoleEligibilities') {
if ($type -eq 'RoleEligibilities' -and $capability -and $capability.Known -and -not $capability.EntraP2) {
# Without P2 there can be none, and the read would be refused.
$checks.Add((New-TestEnvironmentCheck -Name $type -FoundCount 0))
continue
}
try {
$count = @(Get-EntraSeededObject -Type $type -Connection $connection).Count
$checks.Add((New-TestEnvironmentCheck -Name $type -FoundCount $count))
Expand Down
2 changes: 1 addition & 1 deletion Providers/Entra/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ the SecretStore is shared, and what `-VaultPassword` is for, is in the
| Requirement | Minimum | Notes |
|---|---|---|
| **PowerShell** | 5.1 | Desktop and Core; developed on pwsh 7.6 |
| **Entra tenant** | Any | Dynamic groups need Entra ID P1; everything else works without |
| **Entra tenant** | Any | Dynamic groups and Conditional Access policies need Entra ID P1; role eligibilities need P2 or Governance. The connect reads the tenant's plans once, and the seed skips a step the tenant cannot hold with one message; everything else works without a licence |
| **App registration** | Certificate credential | Created for you by `New-TestServiceApp`, or supply your own |
| **Permissions** | Directory writes | See below — the token does not tell you the whole story |
| **Modules** | none | Deliberately zero dependencies |
Expand Down
1 change: 1 addition & 0 deletions Tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ promise the README makes, or a regression for a bug that reached a real director
| `Core\Confirm-TestTeardown.Tests.ps1` | That the one teardown question reaches the cmdlet as written and its answer is returned, and that a host which cannot ask is read as a refusal, never a yes |
| `Core\ConvertTo-TestBase64Url.Tests.ps1` | Padding, URL-unsafe characters, byte-exact round trips including leading zeros |
| `Core\Initialize-TestSecretVault.Tests.ps1` | That a locked store is detected by the error it throws, that an unlock failure is fatal rather than a warning, and that the vault is proven before anything remote is created |
| `Providers\Entra\Get-EntraCapability.Tests.ps1` | That a plan counts only on an enabled SKU and when provisioned, that P2 or Governance implies P1, and that a tenant whose SKUs cannot be read answers unknown rather than unlicensed, so the probe can only remove noise, never a step |
| `Providers\Entra\SeedData.Tests.ps1` | The shape and referential integrity of all 1,185 seed rows |
| `Providers\Entra\New-EntraClientAssertion.Tests.ps1` | That the JWT verifies against its own public key, that `x5t` is the thumbprint **bytes** not its hex text, and that the audience is the v2.0 tenant endpoint |
| `Providers\Entra\Invoke-EntraRequest.Tests.ps1` | UTF-8 both ways, query encoding, pagination and its loop guard, both retry policies, and that the inner exception is set rather than stringified |
Expand Down
Loading