mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 19:05:38 +02:00
640 lines
31 KiB
PowerShell
640 lines
31 KiB
PowerShell
# Layer 2 of the access marking: the signed-in USER's Intune RBAC, on top of the
|
|
# APP's consented scopes that Internal/AccessLevel.ps1 (Layer 1) diffs.
|
|
#
|
|
# For a delegated login the effective access is the intersection of both. The
|
|
# token carries scp (app consent) and wids (Entra directory roles) but nothing
|
|
# about Intune role assignments - a user holding only the built-in Read Only
|
|
# Operator role signs in with a token that says ReadWrite.All, Layer 1 marks
|
|
# everything Full, and every PATCH gets a 403. This file asks Graph what the
|
|
# user can actually do and lets Update-IntuneAccessLevels stamp the worse of
|
|
# the two answers. See Docs/EffectivePermissions-Plan.md.
|
|
#
|
|
# Rules, in priority order:
|
|
# * Setting off, no claims, or an app-only token -> no Layer 2 at all.
|
|
# Application permissions bypass Intune RBAC, so 'roles' IS the answer.
|
|
# * Intune Administrator / Global Administrator in wids -> Full for every
|
|
# Intune category, no Graph call. Both grant complete Intune RBAC.
|
|
# * Global Reader in wids -> read guaranteed for every Intune category, never
|
|
# write. Used as a FLOOR, not a short-circuit: the getEffectivePermissions
|
|
# call still runs (an additional Intune role may add write on top), but a
|
|
# category the response omits is still treated as readable, and when the
|
|
# call comes back empty the type is marked read-only instead of no-access.
|
|
# A user who holds Global Reader plus an Intune role that grants write keeps
|
|
# that write (it comes from the response); the floor only ever adds read.
|
|
# * Otherwise one GET of deviceManagement/getEffectivePermissions per token.
|
|
# The answer is cached under the token FINGERPRINT (tid|oid|iat): any newly
|
|
# minted token - routine renewal, explicit login, Force refresh in the
|
|
# Profile popup - misses the cache and re-asks. There is no TTL on purpose:
|
|
# Force refresh is the one refresh path, exactly as for the token itself.
|
|
# * Plus one GET of deviceManagement/resourceOperations: the CATALOGUE of
|
|
# resource actions that exist. getEffectivePermissions answers with the
|
|
# allowed actions only - notAllowedResourceActions comes back empty - so on
|
|
# its own it cannot tell "the role denies this action" from "no such action
|
|
# for this category", and both a denied write and a category that has no
|
|
# Assign action look identical. Without the catalogue the write check was a
|
|
# contradiction and every mapped type came out Full, which is the whole
|
|
# point of Layer 2 missing. The catalogue is tenant-level and
|
|
# token-independent, so it is cached per tenant and survives a refresh.
|
|
# * Layer 2 can only DOWNGRADE. A missing scope is fatal regardless of RBAC.
|
|
# * Unknown never colours. A failed call, a type with no category mapping,
|
|
# an API that Intune RBAC does not govern, or a category the response never
|
|
# mentions all return $null and Layer 1 stands. A sea of red on data we
|
|
# could not read is worse than no signal.
|
|
#
|
|
# Scope tags are NOT modelled. getEffectivePermissions is the global answer; a
|
|
# user scoped to a subset of tags can still 403 on specific objects.
|
|
|
|
# Directory role template ids that imply full Intune RBAC. Template ids are
|
|
# fixed across tenants (wids carries the template id, not the tenant's role
|
|
# object id).
|
|
$script:RbacFullAccessRoleTemplateIds = @(
|
|
"3a2c62db-5318-420d-8d74-23affee5d9d5", # Intune Administrator
|
|
"62e90394-69f5-4237-9190-012177145e10" # Global Administrator
|
|
)
|
|
|
|
# Directory role template ids that imply tenant-wide READ but no write. Global
|
|
# Reader reads every Intune resource; it is a floor on read, not a replacement
|
|
# for the per-action lookup (see the header). Kept separate from the full-access
|
|
# list so it downgrades a writable type to read-only rather than granting Full.
|
|
$script:RbacReadOnlyRoleTemplateIds = @(
|
|
"f2ef992c-3afb-46b9-b7cf-a126ee74c451" # Global Reader
|
|
)
|
|
|
|
# The Graph function and the scope it is asked for. Both are what the Intune
|
|
# portal uses. Kept as variables so a live finding is a one-line change.
|
|
$script:RbacEffectivePermissionsUrl = "/deviceManagement/getEffectivePermissions(scope='*')"
|
|
$script:RbacResourceActionPrefix = "Microsoft.Intune_"
|
|
|
|
# The action catalogue: every resource action Intune defines, one entry per
|
|
# action, with .id exactly '<prefix><Category>_<Action>'. Tenant-level and the
|
|
# same for every caller, so it is cached per tenant rather than per token.
|
|
$script:RbacResourceOperationsUrl = "/deviceManagement/resourceOperations"
|
|
|
|
# Actions that make a type writable. Only the ones that EXIST for a category
|
|
# count - Roles has no Assign, ManagedGooglePlay uses Modify - so a category
|
|
# with fewer write actions is not penalised for actions it lacks. Existence
|
|
# comes from the catalogue, which is why Layer 2 fetches it.
|
|
$script:RbacWriteActions = @("Create", "Update", "Delete", "Assign", "Modify")
|
|
|
|
# _API path -> Intune RBAC resource category (the middle segment of
|
|
# Microsoft.Intune_<Category>_<Action>). Longest prefix wins so a specific
|
|
# sub-path can override its parent. An entry may also override the action
|
|
# suffixes when a category does not use the plain Read/Create/... names.
|
|
#
|
|
# Every Category name below exists in the live resourceOperations catalogue, and
|
|
# a test asserts that against the catalogue fixture - a typo like the former
|
|
# 'Filters' (the portal's label; the resource is 'AssignmentFilter') now fails
|
|
# the suite instead of silently marking the type Unknown. UNVERIFIED marks the
|
|
# other half, the API -> category association, which the catalogue cannot
|
|
# confirm: it is safe because an action name the catalogue does not know yields
|
|
# Unknown, not a colour. Types can override any of this with _ResourceCategory.
|
|
$script:RbacApiCategoryMap = @{
|
|
# Device configuration family
|
|
"deviceManagement/deviceConfigurations" = "DeviceConfigurations"
|
|
"deviceManagement/configurationPolicies" = "DeviceConfigurations"
|
|
"deviceManagement/configurationPolicyTemplates" = "DeviceConfigurations"
|
|
"deviceManagement/groupPolicyConfigurations" = "DeviceConfigurations"
|
|
"deviceManagement/groupPolicyUploadedDefinitionFiles" = "DeviceConfigurations"
|
|
"deviceManagement/reusablePolicySettings" = "DeviceConfigurations"
|
|
"deviceManagement/hardwareConfigurations" = "DeviceConfigurations"
|
|
"deviceManagement/deviceManagementScripts" = "DeviceConfigurations"
|
|
"deviceManagement/deviceShellScripts" = "DeviceConfigurations"
|
|
"deviceManagement/deviceCustomAttributeShellScripts" = "DeviceConfigurations"
|
|
"deviceManagement/deviceHealthScripts" = "DeviceConfigurations" # UNVERIFIED (remediations)
|
|
"deviceManagement/windowsFeatureUpdateProfiles" = "DeviceConfigurations" # UNVERIFIED
|
|
"deviceManagement/windowsQualityUpdateProfiles" = "DeviceConfigurations" # UNVERIFIED
|
|
"deviceManagement/windowsQualityUpdatePolicies" = "DeviceConfigurations" # UNVERIFIED
|
|
"deviceManagement/windowsDriverUpdateProfiles" = "DeviceConfigurations" # UNVERIFIED
|
|
"deviceManagement/inventoryPolicies" = "DeviceConfigurations" # UNVERIFIED
|
|
"deviceManagement/deviceEnrollmentConfigurations" = "DeviceConfigurations" # UNVERIFIED (ESP / restrictions)
|
|
# Security baselines are intents; the template catalogue sits beside them.
|
|
"deviceManagement/intents" = "SecurityBaselines"
|
|
"deviceManagement/templates" = "SecurityBaselines"
|
|
# Compliance family (Intune spells the category 'Polices')
|
|
"deviceManagement/deviceCompliancePolicies" = "DeviceCompliancePolices"
|
|
"deviceManagement/compliancePolicies" = "DeviceCompliancePolices"
|
|
"deviceManagement/deviceComplianceScripts" = "DeviceCompliancePolices" # UNVERIFIED
|
|
"deviceManagement/notificationMessageTemplates" = "DeviceCompliancePolices" # UNVERIFIED
|
|
# Tenant administration
|
|
"deviceManagement/roleDefinitions" = "Roles"
|
|
"deviceManagement/roleScopeTags" = "Roles"
|
|
"deviceManagement/termsAndConditions" = "TermsAndConditions"
|
|
"deviceManagement/assignmentFilters" = "AssignmentFilter" # 'Filters' is the portal label, not the resource
|
|
"deviceManagement/operationApprovalPolicies" = @{
|
|
Category = "MultiAdminApproval"
|
|
Actions = @{ Read = "ReadAccessPolicy"; Create = "CreateAccessPolicy"; Update = "UpdateAccessPolicy"; Delete = "DeleteAccessPolicy" }
|
|
}
|
|
"deviceManagement/intuneBrandingProfiles" = "Customization"
|
|
"deviceManagement/settings" = "Organization" # UNVERIFIED
|
|
# Enrollment
|
|
"deviceManagement/appleUserInitiatedEnrollmentProfiles" = "AppleEnrollmentProfiles" # UNVERIFIED
|
|
"deviceManagement/depOnboardingSettings" = "AppleEnrollmentProfiles" # UNVERIFIED
|
|
# Android Enterprise ('AndroidSync'). There is no Create/Delete action for a
|
|
# profile: the one write action is UpdateEnrollmentProfiles, so it has to be
|
|
# named here or the type would look writable to anyone who can read.
|
|
"deviceManagement/androidForWorkEnrollmentProfiles" = @{
|
|
Category = "AndroidSync"
|
|
Actions = @{ Update = "UpdateEnrollmentProfiles" }
|
|
}
|
|
"deviceManagement/androidDeviceOwnerEnrollmentProfiles" = @{
|
|
Category = "AndroidSync"
|
|
Actions = @{ Update = "UpdateEnrollmentProfiles" }
|
|
}
|
|
"deviceManagement/androidManagedStoreAccountEnterpriseSettings" = "ManagedGooglePlay" # UNVERIFIED
|
|
# Autopilot profiles are governed by the Enrollment programs permission,
|
|
# whose *profile* actions are the AppleEnrollmentProfiles resource - the
|
|
# action names there are generic ("Read profile", "Assign profile") and the
|
|
# catalogue has no Windows- or Autopilot-specific resource at all. The
|
|
# alternative reading, EnrollmentProgramToken (the *token* actions), is the
|
|
# same portal permission group and every built-in role grants the two sets
|
|
# together, so the verdict is identical either way; only a custom role that
|
|
# ticks tokens without profiles could tell them apart.
|
|
"deviceManagement/windowsAutopilotDeploymentProfiles" = "AppleEnrollmentProfiles"
|
|
# Apps
|
|
"deviceAppManagement/mobileApps" = "MobileApps"
|
|
"deviceAppManagement/mobileAppConfigurations" = "MobileApps" # UNVERIFIED
|
|
"deviceAppManagement/iosLobAppProvisioningConfigurations" = "MobileApps" # UNVERIFIED
|
|
"deviceAppManagement/managedAppPolicies" = "ManagedApps"
|
|
"deviceAppManagement/targetedManagedAppConfigurations" = "ManagedApps"
|
|
"deviceAppManagement/policySets" = "PolicySets"
|
|
}
|
|
|
|
# Intune-governed APIs that deliberately have NO category. The completeness test
|
|
# (Tests/EffectivePermissions.Tests.ps1) fails on any deviceManagement/ or
|
|
# deviceAppManagement/ type that is in neither table, so a new type cannot fall
|
|
# through silently. Value = the reason.
|
|
$script:RbacUnmappedApis = @{
|
|
"deviceManagement/deviceCategories" = "no matching resource in the action catalogue"
|
|
"deviceManagement/virtualEndpoint" = "Windows 365 uses its own RBAC namespace"
|
|
"deviceAppManagement/vppTokens" = "no VPP resource in the action catalogue (MicrosoftStoreForBusiness is a different store)"
|
|
}
|
|
|
|
#region Category resolution
|
|
|
|
# The category entry for a policy type: @{ Category; Actions } or $null when the
|
|
# type is not Intune-governed / unmapped. _ResourceCategory on the type wins.
|
|
function Get-PolicyTypeRbacCategory {
|
|
[CmdletBinding()]
|
|
param($PolicyType)
|
|
|
|
if(-not $PolicyType) { return $null }
|
|
|
|
$override = $null
|
|
try { $override = $PolicyType._ResourceCategory } catch { }
|
|
if($override) { return @{ Category = [string]$override; Actions = @{} } }
|
|
|
|
$api = $null
|
|
try { $api = [string]$PolicyType._API } catch { }
|
|
return (Get-RbacCategoryForApi $api)
|
|
}
|
|
|
|
function Get-RbacCategoryForApi {
|
|
[CmdletBinding()]
|
|
param([string]$Api)
|
|
|
|
if([string]::IsNullOrWhiteSpace($Api)) { return $null }
|
|
$api = $Api.Trim().TrimStart('/')
|
|
|
|
# Longest matching prefix wins; a prefix must end at a path boundary.
|
|
# "$($key)?" not "$key?": on PS7 '?' is a legal variable-name character, so
|
|
# "$key?" reads the (empty) variable 'key?' and StartsWith("") matches all.
|
|
$best = $null
|
|
foreach($key in $script:RbacApiCategoryMap.Keys) {
|
|
if($api -eq $key -or $api.StartsWith("$key/", [System.StringComparison]::OrdinalIgnoreCase) -or
|
|
$api.StartsWith("$($key)?", [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
if(-not $best -or $key.Length -gt $best.Length) { $best = $key }
|
|
}
|
|
}
|
|
if(-not $best) { return $null }
|
|
|
|
$entry = $script:RbacApiCategoryMap[$best]
|
|
if($entry -is [string]) { return @{ Category = $entry; Actions = @{} } }
|
|
$actions = if($entry.Actions) { $entry.Actions } else { @{} }
|
|
return @{ Category = [string]$entry.Category; Actions = $actions }
|
|
}
|
|
|
|
# Full resource action name for a category + logical action, honouring the
|
|
# entry's suffix overrides.
|
|
function Get-RbacActionName {
|
|
[CmdletBinding()]
|
|
param($Entry, [string]$Action)
|
|
|
|
$suffix = $Action
|
|
if($Entry.Actions -and $Entry.Actions.ContainsKey($Action)) { $suffix = $Entry.Actions[$Action] }
|
|
return "$($script:RbacResourceActionPrefix)$($Entry.Category)_$suffix"
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Response parsing
|
|
|
|
# Turn a getEffectivePermissions payload into two sets: the actions the role
|
|
# allows, and the ones it explicitly denies. The shape is walked defensively -
|
|
# value[].resourceActions[].allowed/notAllowed - and a bare object with
|
|
# resourceActions is accepted too.
|
|
#
|
|
# NotAllowed is empty in practice: Graph answers with the allowed list only. It
|
|
# is still read, because when it is populated it is a better source for "this
|
|
# action exists" than the catalogue (Get-RbacActionCatalog falls back to it).
|
|
function ConvertTo-RbacActionSets {
|
|
[CmdletBinding()]
|
|
param($Response)
|
|
|
|
$allowed = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
|
$notAllowed = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
|
if(-not $Response) { return @{ Allowed = $allowed; NotAllowed = $notAllowed } }
|
|
|
|
$items = @()
|
|
if($Response.PSObject.Properties['value']) { $items = @($Response.value) } else { $items = @($Response) }
|
|
|
|
foreach($item in $items) {
|
|
if(-not $item) { continue }
|
|
$resourceActions = @()
|
|
if($item.PSObject.Properties['resourceActions']) { $resourceActions = @($item.resourceActions) }
|
|
elseif($item.PSObject.Properties['allowedResourceActions']) { $resourceActions = @($item) }
|
|
foreach($ra in $resourceActions) {
|
|
if(-not $ra) { continue }
|
|
foreach($a in @($ra.allowedResourceActions)) {
|
|
if($a) { [void]$allowed.Add(([string]$a).Trim()) }
|
|
}
|
|
foreach($a in @($ra.notAllowedResourceActions)) {
|
|
if($a) { [void]$notAllowed.Add(([string]$a).Trim()) }
|
|
}
|
|
}
|
|
}
|
|
return @{ Allowed = $allowed; NotAllowed = $notAllowed }
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Action catalogue
|
|
|
|
function Get-RbacCatalogCacheName {
|
|
param([string]$TenantId)
|
|
return "RbacResourceOperations_$TenantId"
|
|
}
|
|
|
|
# The set of resource action names that EXIST, from deviceManagement/
|
|
# resourceOperations. $null when the call fails or returns nothing usable -
|
|
# callers must treat that as "existence unknown", not as "nothing exists".
|
|
#
|
|
# Cached per tenant with no timeout and no token fingerprint: the catalogue is a
|
|
# property of the Intune service, not of the signed-in user, so a token refresh
|
|
# re-asks getEffectivePermissions but reuses this.
|
|
function Get-RbacActionCatalog {
|
|
[CmdletBinding()]
|
|
param([string]$TenantId, [int]$TokenId = 0)
|
|
|
|
# The cached value is a hashtable wrapping the set, and every return of the
|
|
# set has a leading comma. PowerShell enumerates a HashSet when it is written
|
|
# to the pipeline - both here and inside Get-CacheObject - so without those
|
|
# two guards the caller gets an object[] of names instead of the set, and
|
|
# object[].Contains() is case-SENSITIVE. A hashtable is not enumerated.
|
|
$cacheName = Get-RbacCatalogCacheName $TenantId
|
|
$cached = Get-CacheObject $cacheName
|
|
if($cached -and $cached.Actions.Count -gt 0) { return ,$cached.Actions }
|
|
|
|
$response = $null
|
|
try {
|
|
$params = @{ Url = $script:RbacResourceOperationsUrl; GraphVersion = "beta"; ODataMetadata = "minimal"; NoError = $true; AllPages = $true }
|
|
if($TokenId -gt 0) { $params.TokenId = $TokenId }
|
|
$response = Invoke-MSGraphAPI @params
|
|
}
|
|
catch { }
|
|
|
|
$actions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
|
foreach($op in @($response.value)) {
|
|
if($op -and $op.id) { [void]$actions.Add(([string]$op.id).Trim()) }
|
|
}
|
|
if($actions.Count -eq 0) {
|
|
Write-Log ("Intune RBAC marking: the resource action catalogue ($($script:RbacResourceOperationsUrl)) could not be read. " +
|
|
"A type is marked read-only only when the role allows no write action at all for its category.") 2
|
|
return $null
|
|
}
|
|
|
|
Set-CacheObject $cacheName @{ Actions = $actions } "TenantCache_$TenantId" -Persistent
|
|
return ,$actions
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Context (fetch + cache)
|
|
|
|
function Get-RbacTokenFingerprint {
|
|
[CmdletBinding()]
|
|
param($Claims)
|
|
if(-not $Claims) { return $null }
|
|
return "$($Claims.tid)|$($Claims.oid)|$($Claims.iat)"
|
|
}
|
|
|
|
function Test-RbacAppOnlyClaims {
|
|
[CmdletBinding()]
|
|
param($Claims)
|
|
if(-not $Claims) { return $true }
|
|
if($Claims.PSObject.Properties['idtyp'] -and ([string]$Claims.idtyp) -eq 'app') { return $true }
|
|
# No scp at all means no delegated consent - app-only or unreadable.
|
|
return (-not $Claims.PSObject.Properties['scp'] -or -not $Claims.scp)
|
|
}
|
|
|
|
function Test-RbacFullAccessRole {
|
|
[CmdletBinding()]
|
|
param($Claims)
|
|
if(-not $Claims -or -not $Claims.PSObject.Properties['wids']) { return $false }
|
|
foreach($w in @($Claims.wids)) {
|
|
if($w -and $script:RbacFullAccessRoleTemplateIds -contains ([string]$w).Trim()) { return $true }
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Test-RbacReadOnlyRole {
|
|
[CmdletBinding()]
|
|
param($Claims)
|
|
if(-not $Claims -or -not $Claims.PSObject.Properties['wids']) { return $false }
|
|
foreach($w in @($Claims.wids)) {
|
|
if($w -and $script:RbacReadOnlyRoleTemplateIds -contains ([string]$w).Trim()) { return $true }
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Test-RbacAccessMarkingEnabled {
|
|
[CmdletBinding()]
|
|
param()
|
|
$v = $null
|
|
try { $v = Get-SettingValue "UseRbacAccessMarking" } catch { }
|
|
# Compare as text: `$false -eq ""` is TRUE in PowerShell (the right side is
|
|
# coerced to bool), which would read an explicit off as "unset".
|
|
$text = [string]$v
|
|
if($text -eq "") { return $true }
|
|
return ($text -eq "true")
|
|
}
|
|
|
|
function Get-RbacCacheName {
|
|
param([string]$TenantId)
|
|
return "EffectivePermissions_$TenantId"
|
|
}
|
|
|
|
# The Layer 2 context for the current (or given) token, or $null when Layer 2
|
|
# does not apply. Never prompts and never throws: every failure path returns
|
|
# $null after one log line, and a failed fetch is remembered for the token so a
|
|
# nav rebuild does not re-ask until the token changes.
|
|
#
|
|
# Context shape:
|
|
# Fingerprint, TenantId, AsOf (token iat), Source ('DirectoryRole'|'Graph'),
|
|
# AllAllowed (bool), Allowed / NotAllowed / Catalog (HashSet[string], Catalog
|
|
# $null when the catalogue could not be read), Raw (response)
|
|
function Get-IntuneRbacContext {
|
|
[CmdletBinding()]
|
|
param($Claims, [int]$TokenId = 0, [switch]$IgnoreSetting)
|
|
|
|
if(-not $IgnoreSetting -and -not (Test-RbacAccessMarkingEnabled)) { return $null }
|
|
|
|
if(-not $Claims) { $Claims = Get-AccessTokenClaims }
|
|
if(-not $Claims) { return $null }
|
|
if(Test-RbacAppOnlyClaims $Claims) {
|
|
Write-LogDebug "Intune RBAC marking: app-only token, application permissions are the effective set"
|
|
return $null
|
|
}
|
|
|
|
$fingerprint = Get-RbacTokenFingerprint $Claims
|
|
$tenantId = [string]$Claims.tid
|
|
$asOf = $null
|
|
try { if($Claims.iat) { $asOf = [datetime]::new(1970, 1, 1, 0, 0, 0, 0, [System.DateTimeKind]::Utc).AddSeconds([double]$Claims.iat).ToLocalTime() } } catch { }
|
|
|
|
$cacheName = Get-RbacCacheName $tenantId
|
|
$cached = Get-CacheObject $cacheName
|
|
if($cached -and $cached.Fingerprint -eq $fingerprint) {
|
|
if($cached.Failed) { return $null }
|
|
return $cached
|
|
}
|
|
|
|
$ctx = [PSCustomObject]@{
|
|
Fingerprint = $fingerprint
|
|
TenantId = $tenantId
|
|
AsOf = $asOf
|
|
Source = $null
|
|
AllAllowed = $false
|
|
AllRead = $false
|
|
Allowed = $null
|
|
NotAllowed = $null
|
|
Catalog = $null
|
|
Raw = $null
|
|
Failed = $false
|
|
Error = $null
|
|
}
|
|
|
|
if(Test-RbacFullAccessRole $Claims) {
|
|
$ctx.Source = "DirectoryRole"
|
|
$ctx.AllAllowed = $true
|
|
Write-Log "Intune RBAC marking: Intune Administrator / Global Administrator role in token; full Intune access assumed, no permission lookup"
|
|
}
|
|
else {
|
|
# Global Reader (or equivalent) guarantees read everywhere. Recorded now
|
|
# so it applies whether or not the per-action lookup below succeeds.
|
|
$ctx.AllRead = (Test-RbacReadOnlyRole $Claims)
|
|
$response = $null
|
|
try {
|
|
$params = @{ Url = $script:RbacEffectivePermissionsUrl; GraphVersion = "beta"; ODataMetadata = "minimal"; NoError = $true }
|
|
if($TokenId -gt 0) { $params.TokenId = $TokenId }
|
|
$response = Invoke-MSGraphAPI @params
|
|
}
|
|
catch {
|
|
$ctx.Error = $_.Exception.Message
|
|
}
|
|
|
|
$sets = ConvertTo-RbacActionSets $response
|
|
if(-not $response -or ($sets.Allowed.Count -eq 0 -and $sets.NotAllowed.Count -eq 0)) {
|
|
if($ctx.AllRead) {
|
|
# A pure Global Reader with no Intune RBAC assignment: the lookup
|
|
# says nothing, but the directory role still guarantees read. Mark
|
|
# from the role instead of failing, so the type shows read-only.
|
|
$ctx.Source = "DirectoryRole"
|
|
Write-Log "Intune RBAC marking: Global Reader directory role in token; read-only Intune access assumed (getEffectivePermissions returned nothing)"
|
|
}
|
|
else {
|
|
$ctx.Failed = $true
|
|
if(-not $ctx.Error) { $ctx.Error = "empty or unreadable response" }
|
|
Write-Log ("Intune RBAC marking unavailable: getEffectivePermissions returned nothing usable ($($ctx.Error)). " +
|
|
"The navigation reflects token scopes only until the next token refresh.") 2
|
|
}
|
|
}
|
|
else {
|
|
$ctx.Source = "Graph"
|
|
$ctx.Allowed = $sets.Allowed
|
|
$ctx.NotAllowed = $sets.NotAllowed
|
|
$ctx.Raw = $response
|
|
# A populated notAllowed list would be the authoritative statement of
|
|
# what exists; the catalogue is what makes the verdict possible when
|
|
# it is empty, which is every response seen so far.
|
|
$ctx.Catalog = Get-RbacActionCatalog -TenantId $tenantId -TokenId $TokenId
|
|
if(-not $ctx.Catalog -and $sets.NotAllowed.Count -gt 0) {
|
|
$ctx.Catalog = [System.Collections.Generic.HashSet[string]]::new($sets.Allowed, [System.StringComparer]::OrdinalIgnoreCase)
|
|
foreach($a in $sets.NotAllowed) { [void]$ctx.Catalog.Add($a) }
|
|
}
|
|
$of = if($ctx.Catalog) { " of $($ctx.Catalog.Count)" } else { "" }
|
|
Write-Log "Intune RBAC marking: $($sets.Allowed.Count)$of Intune resource actions allowed for the signed-in user"
|
|
}
|
|
}
|
|
|
|
# Persistent + tenant tag: no timeout (the fingerprint is the invalidation)
|
|
# and Clear-TenantCache sweeps it with the other per-tenant entries.
|
|
Set-CacheObject $cacheName $ctx "TenantCache_$tenantId" -Persistent
|
|
if($ctx.Failed) { return $null }
|
|
return $ctx
|
|
}
|
|
|
|
function Clear-IntuneRbacContext {
|
|
[CmdletBinding()]
|
|
param([string]$TenantId)
|
|
|
|
if($TenantId) {
|
|
Clear-CacheObject -Name (Get-RbacCacheName $TenantId)
|
|
Clear-CacheObject -Name (Get-RbacCatalogCacheName $TenantId)
|
|
return
|
|
}
|
|
foreach($name in @($script:cacheObjects.Keys | Where-Object { $_ -like "EffectivePermissions_*" -or $_ -like "RbacResourceOperations_*" })) {
|
|
Clear-CacheObject -Name $name
|
|
}
|
|
}
|
|
|
|
function Invoke-RbacEventUserDisconnected {
|
|
param($Snapshot)
|
|
$tenantId = $null
|
|
try { $tenantId = [string]$Snapshot.TenantId } catch { }
|
|
Clear-IntuneRbacContext -TenantId $tenantId
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Per-type evaluation
|
|
|
|
# True when the type declares at least one ReadWrite permission, i.e. it is a
|
|
# writable feature rather than read-only by design. Shared by the read-guarantee
|
|
# and write-check paths.
|
|
function Test-PolicyTypeDeclaresWrite {
|
|
[CmdletBinding()]
|
|
param($PolicyType)
|
|
foreach($perm in @($PolicyType._Permissions | Where-Object { $_ })) {
|
|
if(Get-PermissionReadVariant $perm) { return $true }
|
|
}
|
|
return $false
|
|
}
|
|
|
|
# Layer 2 verdict for one policy type against a context. Returns
|
|
# @{ Level = [APIAccess]; Info = <tooltip text>; Missing = @(actions) } or $null
|
|
# for Unknown (no category, category never mentioned, no context).
|
|
function Get-PolicyTypeRbacAccess {
|
|
[CmdletBinding()]
|
|
param($PolicyType, $Context)
|
|
|
|
if(-not $PolicyType -or -not $Context) { return $null }
|
|
$entry = Get-PolicyTypeRbacCategory $PolicyType
|
|
if(-not $entry) { return $null }
|
|
|
|
if($Context.AllAllowed) { return @{ Level = [APIAccess]::Full; Info = ""; Missing = @() } }
|
|
|
|
# A directory read-only role (Global Reader) with no per-action data: read is
|
|
# guaranteed, write is not. Read-only-by-design types are Full; the rest are
|
|
# read-only. Handled before the Allowed check so a pure Global Reader whose
|
|
# getEffectivePermissions came back empty is still marked.
|
|
if($Context.AllRead -and -not $Context.Allowed) {
|
|
if(-not (Test-PolicyTypeDeclaresWrite $PolicyType)) { return @{ Level = [APIAccess]::Full; Info = ""; Missing = @() } }
|
|
return @{
|
|
Level = [APIAccess]::Limited
|
|
Info = "Intune role: read-only for $($entry.Category) (Global Reader directory role grants read, not write)"
|
|
Missing = @()
|
|
}
|
|
}
|
|
if(-not $Context.Allowed) { return $null }
|
|
|
|
# The catalogue says which actions exist. Without it (the call failed) every
|
|
# existence question is unanswerable, and the verdict falls back to the one
|
|
# thing the allowed list alone can prove - see the write check below.
|
|
$catalog = $Context.Catalog
|
|
$readAction = Get-RbacActionName $entry "Read"
|
|
|
|
if($catalog -and -not $catalog.Contains($readAction)) {
|
|
# Intune has no such action: this row of the category table is wrong, not
|
|
# the user's role. Say nothing rather than something false.
|
|
return $null
|
|
}
|
|
|
|
# AllRead (Global Reader) guarantees read even when the response omits this
|
|
# category's read action.
|
|
if(-not $Context.AllRead -and -not $Context.Allowed.Contains($readAction)) {
|
|
if(-not $catalog) {
|
|
# Could be a denied read or a wrong mapping. Unknown.
|
|
return $null
|
|
}
|
|
return @{
|
|
Level = [APIAccess]::None
|
|
Info = "Intune role: no read access to $($entry.Category) (missing $readAction)"
|
|
Missing = @($readAction)
|
|
}
|
|
}
|
|
|
|
# Read-only by design (declares only Read scopes) needs nothing more.
|
|
if(-not (Test-PolicyTypeDeclaresWrite $PolicyType)) { return @{ Level = [APIAccess]::Full; Info = ""; Missing = @() } }
|
|
|
|
$missing = @()
|
|
$exists = 0
|
|
foreach($action in $script:RbacWriteActions) {
|
|
$name = Get-RbacActionName $entry $action
|
|
if($catalog -and -not $catalog.Contains($name)) { continue } # no such action for this category
|
|
$exists++
|
|
if(-not $Context.Allowed.Contains($name)) { $missing += $name }
|
|
}
|
|
if(-not $catalog) {
|
|
# Naming individual missing actions would be inventing them, so the only
|
|
# honest degraded verdict is the coarse one: a role that allows no write
|
|
# action at all for the category cannot write it.
|
|
if($missing.Count -lt $exists) { return @{ Level = [APIAccess]::Full; Info = ""; Missing = @() } }
|
|
return @{
|
|
Level = [APIAccess]::Limited
|
|
Info = "Intune role: read-only for $($entry.Category) (no write action allowed)"
|
|
Missing = @()
|
|
}
|
|
}
|
|
if($missing.Count -eq 0) { return @{ Level = [APIAccess]::Full; Info = ""; Missing = @() } }
|
|
|
|
# "read-only" is only true when the role allows no write action at all. A role
|
|
# that can Create and Update but not Delete or Assign - a common custom role - is
|
|
# still Limited, but calling that read-only misdescribes what the user can do.
|
|
$short = @($missing | ForEach-Object { $_.Substring($_.LastIndexOf('_') + 1) })
|
|
$info = if($missing.Count -ge $exists) {
|
|
"Intune role: read-only for $($entry.Category) (missing $($short -join ', '))"
|
|
}
|
|
else {
|
|
"Intune role: partial write access to $($entry.Category) (missing $($short -join ', '))"
|
|
}
|
|
return @{
|
|
Level = [APIAccess]::Limited
|
|
Info = $info
|
|
Missing = $missing
|
|
# Carried as data, not only as tooltip text, so the Permissions popup's
|
|
# Role/Effective/Result columns can say "Partial write" instead of
|
|
# contradicting the tooltip with "Read" and "Read-only".
|
|
Partial = ($missing.Count -lt $exists)
|
|
}
|
|
}
|
|
|
|
# The worse of two levels. None outranks Limited outranks Full.
|
|
function Get-WorstAccessLevel {
|
|
[CmdletBinding()]
|
|
[OutputType([APIAccess])]
|
|
param([APIAccess]$A, [APIAccess]$B)
|
|
if($A -eq [APIAccess]::None -or $B -eq [APIAccess]::None) { return [APIAccess]::None }
|
|
if($A -eq [APIAccess]::Limited -or $B -eq [APIAccess]::Limited) { return [APIAccess]::Limited }
|
|
return [APIAccess]::Full
|
|
}
|
|
|
|
#endregion
|
|
|
|
function Invoke-RbacEventAppInitialized {
|
|
Add-SettingsObject -Title "Use Intune role permissions for access marking" -Key "UseRbacAccessMarking" -Type "Boolean" `
|
|
-Description "Also ask Intune which resource actions the signed-in user's role allows, and mark menu items the user cannot change (orange) or read (red). Off = mark from the app's token scopes only. Refresh the token from the Profile popup after a role change." `
|
|
-DefaultValue $true -Section "General"
|
|
}
|
|
|
|
Add-AppEventHandler "AppInitialized" "Invoke-RbacEventAppInitialized"
|
|
Add-AppEventHandler "AuthenticationUserDisconnected" "Invoke-RbacEventUserDisconnected"
|