Skip to content

Commit faa32e4

Browse files
authored
Merge pull request #1097 from KelvinTegelaar/dev
[pull] dev from KelvinTegelaar:dev
2 parents 8128e1c + 98f0778 commit faa32e4

25 files changed

Lines changed: 654 additions & 97 deletions

Config/SAMManifest.json

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,34 @@
582582
{
583583
"id": "b7887744-6746-4312-813d-72daeaee7e2d",
584584
"type": "Scope"
585+
},
586+
{
587+
"id": "dd199f4a-f148-40a4-a2ec-f0069cc799ec",
588+
"type": "Role"
589+
},
590+
{
591+
"id": "8c026be3-8e26-4774-9372-8d5d6f21daff",
592+
"type": "Scope"
593+
},
594+
{
595+
"id": "fee28b28-e1f3-4841-818e-2704dc62245f",
596+
"type": "Role"
597+
},
598+
{
599+
"id": "62ade113-f8e0-4bf9-a6ba-5acb31db32fd",
600+
"type": "Scope"
601+
},
602+
{
603+
"id": "9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8",
604+
"type": "Role"
605+
},
606+
{
607+
"id": "31e08e0a-d3f7-4ca2-ac39-7343fb83e8ad",
608+
"type": "Role"
609+
},
610+
{
611+
"id": "1ff1be21-34eb-448c-9ac9-ce1f506b2a68",
612+
"type": "Scope"
585613
}
586614
]
587615
},
@@ -689,4 +717,4 @@
689717
"isEnabled": true,
690718
"allProperties": true
691719
}
692-
}
720+
}

Modules/CIPPCore/Public/Get-CIPPTestData.ps1

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,80 @@ function Get-CIPPTestData {
44
Cached wrapper around New-CIPPDbRequest for test functions
55
66
.DESCRIPTION
7-
Returns cached tenant data during test suite execution. The cache is
8-
backed by CIPP.TestDataCache (static ConcurrentDictionary in C#) so
9-
it is shared across all PowerShell runspaces within the worker process.
7+
Returns cached tenant data during test suite execution. The cache is backed by
8+
CIPP.TestDataCache (static ConcurrentDictionary in C#) so it is shared across all
9+
PowerShell runspaces within the worker process.
10+
11+
RECORDS ARE FIELD-PROJECTED. Only the fields listed for $Type in
12+
Get-CippTestDataFieldManifest are materialized. Reading an unlisted field returns $null
13+
with no error, so the test emits a wrong verdict silently. If you add a field read to a
14+
test, add it to the manifest first. A type absent from the manifest is fetched whole,
15+
which is the safe default.
16+
17+
Projection is record-level: a kept field keeps its entire subtree, so
18+
`$policy.conditions.users.includeRoles` only requires 'conditions'.
19+
20+
Records are PSCustomObject and must stay so. Hashtable output changes scalar semantics
21+
($x[0] becomes a key lookup returning $null; $x.Count returns the record's field count
22+
instead of 1) and only when a pipeline yields exactly one record — data-dependent, and
23+
easily missed in testing.
1024
1125
.PARAMETER TenantFilter
1226
The tenant domain or GUID to filter by
1327
1428
.PARAMETER Type
1529
The data type to retrieve (e.g., Users, Groups, ConditionalAccessPolicies)
30+
31+
.PARAMETER Fields
32+
Optional. Override the manifest for this call: keep only these top-level fields. Prefer
33+
the manifest; this is for one-off and diagnostic callers, not tests.
34+
35+
The field set is part of the cache key — it must be, or callers asking for the same
36+
tenant+type with different lists would receive each other's projection. Distinct lists
37+
therefore fragment the cache: the same rows parsed once per list, all alive together for
38+
the TTL. Per-call-site lists measured several times worse than one shared per-type entry.
39+
Fields belong to the type — see Get-CippTestDataFieldManifest.
40+
41+
.PARAMETER NoProjection
42+
Return every field, ignoring the manifest.
43+
44+
Required for the custom-script sandbox (Get-CippSandboxData): those scripts are
45+
customer-authored, so the fields they read are unknowable and a manifest built from our
46+
own tests would silently hand them incomplete records. Any caller fetching data on behalf
47+
of code we did not write must pass this.
48+
49+
.EXAMPLE
50+
Get-CIPPTestData -TenantFilter $Tenant -Type 'Users'
51+
Normal use: the manifest decides the fields. This is what tests should do.
52+
53+
.EXAMPLE
54+
Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' -NoProjection
55+
Every field. For callers serving code we did not author.
56+
57+
.NOTES
58+
Verifying a change: "0 errored" proves nothing, because the failure modes here are silent
59+
— a broken test still reports success. Diff test verdicts against a baseline, across more
60+
than one tenant: a bug that needs exactly one matching record will not show on a tenant
61+
that has two.
62+
63+
Useful endpoints: ExecTestRun then ListTests for verdicts; ListDBCache to confirm a field
64+
exists and its real casing before adding it to the manifest (type=_availableTypes lists
65+
the types); ListWorkerHealth?Action=CacheDiag for entries, hit rate and a per-type
66+
breakdown whose keys carry the projected field list.
1667
#>
1768
[CmdletBinding()]
1869
param(
1970
[Parameter(Mandatory = $false)]
2071
[string]$TenantFilter,
2172

2273
[Parameter(Mandatory = $false)]
23-
[string]$Type
74+
[string]$Type,
75+
76+
[Parameter(Mandatory = $false)]
77+
[string[]]$Fields,
78+
79+
[Parameter(Mandatory = $false)]
80+
[switch]$NoProjection
2481
)
2582

2683
# Enforce tenant lock when running inside custom script execution
@@ -32,14 +89,36 @@ function Get-CIPPTestData {
3289
throw 'TenantFilter is required.'
3390
}
3491

35-
$CacheKey = '{0}|{1}' -f $TenantFilter, $Type
92+
# Resolve the field set: an explicit -Fields wins, otherwise consult the per-type manifest.
93+
# -NoProjection suppresses both (the sandbox path must never receive projected records).
94+
$EffectiveFields = if ($NoProjection) {
95+
$null
96+
} elseif ($Fields) {
97+
$Fields
98+
} else {
99+
Get-CippTestDataFieldManifest -Type $Type
100+
}
101+
102+
# Normalize the field set (sorted + de-duplicated) so that callers asking for the same
103+
# fields in a different order share one cache entry instead of parsing twice.
104+
$NormalizedFields = if ($EffectiveFields) { @($EffectiveFields | Sort-Object -Unique) } else { $null }
105+
106+
$CacheKey = if ($NormalizedFields) {
107+
'{0}|{1}|{2}' -f $TenantFilter, $Type, ($NormalizedFields -join ',')
108+
} else {
109+
'{0}|{1}' -f $TenantFilter, $Type
110+
}
36111

37112
$CachedValue = $null
38113
if ([CIPP.TestDataCache]::TryGet($CacheKey, [ref]$CachedValue)) {
39114
return $CachedValue
40115
}
41116

42-
$Data = New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type
117+
$Data = if ($NormalizedFields) {
118+
New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type -Fields $NormalizedFields
119+
} else {
120+
New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type
121+
}
43122

44123
[CIPP.TestDataCache]::Set($CacheKey, $Data)
45124

Modules/CIPPCore/Public/Get-CippDbRoleMembers.ps1

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,47 @@
11
function Get-CippDbRoleMembers {
2+
<#
3+
.SYNOPSIS
4+
Resolve the members of a directory role from cached PIM + directoryRole data.
5+
6+
.DESCRIPTION
7+
Merges three sources into one member list, de-duplicated by principal id:
8+
Active - PIM roleAssignmentScheduleInstances with assignmentType 'Assigned'
9+
Eligible - PIM roleEligibilitySchedules (can activate, not currently active)
10+
Direct - directoryRole membership assigned outside PIM
11+
12+
A principal may be a user, a servicePrincipal, or a group — use '@odata.type' to tell
13+
them apart. userPrincipalName is null for anything that isn't a user, and appId is
14+
populated only for servicePrincipals.
15+
16+
.PARAMETER TenantFilter
17+
The tenant to resolve members for.
18+
19+
.PARAMETER RoleTemplateId
20+
The role's TEMPLATE id (e.g. Global Administrator = 62e90394-69f5-4237-9190-012177145e10).
21+
22+
NOT the directoryRole instance id. PIM's roleDefinitionId carries template ids, so passing
23+
an instance id (Roles.id) matches nothing and silently returns an empty list. When starting
24+
from a Get-CippDbRole record, pass $Role.roleTemplateId — never $Role.id.
25+
26+
.OUTPUTS
27+
PSCustomObject per member:
28+
id - principal object id
29+
displayName - principal display name
30+
userPrincipalName - users only; null for servicePrincipals and groups
31+
appId - servicePrincipals only; null otherwise
32+
'@odata.type' - '#microsoft.graph.user' | '...servicePrincipal' | '...group'
33+
AssignmentType - 'Active' | 'Eligible' | 'Direct'
34+
EndDateTime - when the assignment expires; null when it does not
35+
IsPermanent - $true when the assignment has no expiry
36+
37+
.NOTES
38+
Depends on the cached records carrying an expanded 'principal' object — the Graph APIs
39+
return only principalId by default, so the collectors request $expand=principal. Without
40+
it every displayName/userPrincipalName here is null.
41+
42+
.FUNCTIONALITY
43+
Internal
44+
#>
245
[CmdletBinding()]
346
param (
447
[Parameter(Mandatory = $true)]
@@ -27,8 +70,12 @@ function Get-CippDbRoleMembers {
2770
id = $member.principalId
2871
displayName = $member.principal.displayName
2972
userPrincipalName = $member.principal.userPrincipalName
73+
appId = $member.principal.appId
3074
'@odata.type' = $member.principal.'@odata.type'
3175
AssignmentType = 'Active'
76+
EndDateTime = $member.endDateTime
77+
# A PIM assignment with no endDateTime never expires.
78+
IsPermanent = ($null -eq $member.endDateTime)
3279
}
3380
$AllMembers.Add($memberObj)
3481
}
@@ -39,8 +86,12 @@ function Get-CippDbRoleMembers {
3986
id = $member.principalId
4087
displayName = $member.principal.displayName
4188
userPrincipalName = $member.principal.userPrincipalName
89+
appId = $member.principal.appId
4290
'@odata.type' = $member.principal.'@odata.type'
4391
AssignmentType = 'Eligible'
92+
# Eligibilities carry their expiry under scheduleInfo, not endDateTime.
93+
EndDateTime = $member.scheduleInfo.expiration.endDateTime
94+
IsPermanent = ($member.scheduleInfo.expiration.type -eq 'noExpiration')
4495
}
4596
$AllMembers.Add($memberObj)
4697
}
@@ -52,8 +103,12 @@ function Get-CippDbRoleMembers {
52103
id = $member.id
53104
displayName = $member.displayName
54105
userPrincipalName = $member.userPrincipalName
106+
appId = $member.appId
55107
'@odata.type' = $member.'@odata.type'
56108
AssignmentType = 'Direct'
109+
# directoryRole membership assigned outside PIM has no expiry.
110+
EndDateTime = $null
111+
IsPermanent = $true
57112
}
58113
$AllMembers.Add($memberObj)
59114
}

Modules/CIPPCore/Public/Get-CippSandboxData.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ function Get-CippSandboxData {
7070

7171
$Key = if ($Type) { $Type } else { '' }
7272
if (-not $Data.ContainsKey($Key)) {
73-
$Data[$Key] = @(Get-CIPPTestData -TenantFilter $TenantFilter -Type $Type)
73+
$Data[$Key] = @(Get-CIPPTestData -TenantFilter $TenantFilter -Type $Type -NoProjection)
7474
}
7575
}
7676

0 commit comments

Comments
 (0)