Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function Push-ExecJITAdminListAllTenants {
$BulkRequests.Add(@{
id = $User.id
method = 'GET'
url = "users/$($User.id)/memberOf/microsoft.graph.directoryRole/?`$select=id,displayName"
url = "users/$($User.id)/memberOf/microsoft.graph.directoryRole/?`$select=id,displayName,roleTemplateId"
})
}
# Ensure $BulkRequests is not empty or null before making the bulk request
Expand All @@ -45,7 +45,7 @@ function Push-ExecJITAdminListAllTenants {
if ($RoleResults) {
$userRoleResult = $RoleResults | Where-Object -Property id -EQ $currentUser.id
if ($userRoleResult -and $userRoleResult.body -and $userRoleResult.body.value) {
$MemberOf = $userRoleResult.body.value | Select-Object displayName, id
$MemberOf = $userRoleResult.body.value | Select-Object displayName, id, roleTemplateId
}
}

Expand All @@ -61,6 +61,7 @@ function Push-ExecJITAdminListAllTenants {
jitAdminEnabled = $jitAdminEnabled
jitAdminExpiration = $jitAdminExpiration
memberOf = ($MemberOf | ConvertTo-Json -Depth 5 -Compress)
roleTemplateIds = @($MemberOf.roleTemplateId | Where-Object { $_ })
}
}

Expand Down
148 changes: 148 additions & 0 deletions backend/Modules/CIPPCore/Public/Get-CIPPJITAdminAllowedRoles.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
function Get-CIPPJITAdminAllowedRoles {
<#
.SYNOPSIS
Resolve which directory roles the calling user is permitted to assign via JIT Admin.

.DESCRIPTION
JIT Role Templates are named allow-lists of Entra directory roles that can be attached to a
CIPP custom role (via the AllowedRolesTemplate property on the CustomRoles row). This function
resolves the calling user's roles and returns the effective allow-list.

Restrictive semantics, matching how CIPP combines multiple custom roles everywhere else
("assigning multiple custom roles is restrictive and not additive"):
- Base roles (superadmin/admin/editor/readonly) do not carry templates. admin/superadmin are
unaffected by custom roles and are always unrestricted.
- A custom role with NO template contributes "all roles" (the universal set), so it never
loosens the result - but on its own it does not restrict.
- If the caller holds AT LEAST ONE templated custom role they are restricted, and the allow-list
is the INTERSECTION of the templated roles' sets. An untemplated custom role therefore cannot
be used to bypass a template held alongside it.
- If NO custom role carries a template, the caller is unrestricted, so deployments with no
templates assigned anywhere are undisturbed.

Fails closed for restricted callers: a template (or role row) that cannot be read contributes an
empty set to the intersection rather than opening access, so a lookup failure cannot escalate.

.PARAMETER Headers
The request headers (containing x-ms-client-principal) used to resolve the caller.

.OUTPUTS
PSCustomObject with:
Restricted [bool] - $true when the allow-list should be enforced.
AllowedRoleIds [string[]] - directory role template IDs the caller may assign (only meaningful when Restricted).
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
$Headers
)

$Unrestricted = [PSCustomObject]@{ Restricted = $false; AllowedRoleIds = @() }

# Resolve the calling user's roles, including Entra group-based roles (mirrors Invoke-ExecRestoreBackup)
try {
$CallingUser = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json
} catch {
# Without a resolvable principal we cannot determine a custom role, so nothing is restricted.
return $Unrestricted
}

if (($CallingUser.userRoles | Measure-Object).Count -eq 2 -and $CallingUser.userRoles -contains 'authenticated' -and $CallingUser.userRoles -contains 'anonymous') {
$CallingUser = Test-CIPPAccessUserRole -User $CallingUser
}

# admin/superadmin are unaffected by custom roles (CIPP convention) -> never restricted.
if ($CallingUser.userRoles -contains 'admin' -or $CallingUser.userRoles -contains 'superadmin') {
return $Unrestricted
}

$DefaultRoles = @('superadmin', 'admin', 'editor', 'readonly', 'anonymous', 'authenticated')
$CustomRoleNames = @($CallingUser.userRoles | Where-Object { $DefaultRoles -notcontains $_ })

# No custom role -> unrestricted (base roles have no template concept).
if ($CustomRoleNames.Count -eq 0) {
return $Unrestricted
}

$Table = Get-CIPPTable -tablename 'CustomRoles'
$TemplateTable = Get-CIPPTable -tablename 'templates'

# Each templated custom role contributes one set of allowed role IDs. Untemplated custom roles
# contribute nothing (they represent the universal set and never tighten the intersection).
$TemplatedSets = [System.Collections.Generic.List[object]]::new()

foreach ($RoleName in $CustomRoleNames) {
try {
$SafeRole = ConvertTo-CIPPODataFilterValue -Value ($RoleName.ToLower()) -Type String
$RoleRow = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'CustomRoles' and RowKey eq '$SafeRole'"
} catch {
Write-Warning "JIT allowed-roles: failed to read custom role '$RoleName': $($_.Exception.Message)"
# Cannot confirm whether this role is templated -> fail closed: contribute an empty set.
$TemplatedSets.Add([string[]]@())
continue
}

# A role with no template assigned represents the universal set - skip it (it never restricts).
if (-not $RoleRow -or [string]::IsNullOrWhiteSpace($RoleRow.AllowedRolesTemplate)) {
continue
}

try {
$TemplateRef = $RoleRow.AllowedRolesTemplate | ConvertFrom-Json -ErrorAction Stop
} catch {
$TemplateRef = $RoleRow.AllowedRolesTemplate
}
$TemplateGuid = if ($TemplateRef -is [string]) { $TemplateRef } else { $TemplateRef.value ?? $TemplateRef.GUID }

# A blank template reference is equivalent to no template -> universal set, skip it.
if ([string]::IsNullOrWhiteSpace($TemplateGuid)) {
continue
}

try {
$SafeGuid = ConvertTo-CIPPODataFilterValue -Value $TemplateGuid -Type Guid
$TemplateRow = Get-CIPPAzDataTableEntity @TemplateTable -Filter "PartitionKey eq 'JITRoleTemplate' and RowKey eq '$SafeGuid'"
} catch {
Write-Warning "JIT allowed-roles: failed to read JIT Role Template '$TemplateGuid': $($_.Exception.Message)"
$TemplateRow = $null
}

# A templated role whose template cannot be resolved contributes an empty set (fail closed).
if (-not $TemplateRow) {
$TemplatedSets.Add([string[]]@())
continue
}

try {
$TemplateData = $TemplateRow.JSON | ConvertFrom-Json -Depth 10 -ErrorAction Stop
} catch {
$TemplatedSets.Add([string[]]@())
continue
}
$Ids = foreach ($Role in @($TemplateData.roles)) {
$Id = if ($Role -is [string]) { $Role } else { $Role.value ?? $Role.ObjectId }
if (-not [string]::IsNullOrWhiteSpace($Id)) { [string]$Id }
}
$TemplatedSets.Add([string[]]@($Ids))
}

# No templated custom role -> nothing restricts the caller.
if ($TemplatedSets.Count -eq 0) {
return $Unrestricted
}

# Restricted: the allow-list is the intersection of every templated role's set (most restrictive wins).
$Intersection = $null
foreach ($Set in $TemplatedSets) {
if ($null -eq $Intersection) {
$Intersection = [System.Collections.Generic.HashSet[string]]::new([string[]]@($Set))
} else {
$Intersection.IntersectWith([string[]]@($Set))
}
}

return [PSCustomObject]@{
Restricted = $true
AllowedRoleIds = @($Intersection)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,13 @@ function Invoke-ExecCustomRole {

if ($Request.Body.RoleName -notin $DefaultRoles.PSObject.Properties.Name) {
$Role = @{
'PartitionKey' = 'CustomRoles'
'RowKey' = "$($Request.Body.RoleName.ToLower())"
'Permissions' = "$($Request.Body.Permissions | ConvertTo-Json -Compress)"
'AllowedTenants' = "$($Request.Body.AllowedTenants | ConvertTo-Json -Compress)"
'BlockedTenants' = "$($Request.Body.BlockedTenants | ConvertTo-Json -Compress)"
'BlockedEndpoints' = "$($Request.Body.BlockedEndpoints | ConvertTo-Json -Compress)"
'PartitionKey' = 'CustomRoles'
'RowKey' = "$($Request.Body.RoleName.ToLower())"
'Permissions' = "$($Request.Body.Permissions | ConvertTo-Json -Compress)"
'AllowedTenants' = "$($Request.Body.AllowedTenants | ConvertTo-Json -Compress)"
'BlockedTenants' = "$($Request.Body.BlockedTenants | ConvertTo-Json -Compress)"
'BlockedEndpoints' = "$($Request.Body.BlockedEndpoints | ConvertTo-Json -Compress)"
'AllowedRolesTemplate' = "$($Request.Body.AllowedRolesTemplate | ConvertTo-Json -Compress)"
}
Add-CIPPAzDataTableEntity @Table -Entity $Role -Force | Out-Null
$Results.Add("Custom role $($Request.Body.RoleName) saved")
Expand Down Expand Up @@ -124,12 +125,13 @@ function Invoke-ExecCustomRole {
}

$NewRole = @{
'PartitionKey' = 'CustomRoles'
'RowKey' = "$($Request.Body.NewRoleName.ToLower())"
'Permissions' = $ExistingRole.Permissions
'AllowedTenants' = $ExistingRole.AllowedTenants
'BlockedTenants' = $ExistingRole.BlockedTenants
'BlockedEndpoints' = $ExistingRole.BlockedEndpoints
'PartitionKey' = 'CustomRoles'
'RowKey' = "$($Request.Body.NewRoleName.ToLower())"
'Permissions' = $ExistingRole.Permissions
'AllowedTenants' = $ExistingRole.AllowedTenants
'BlockedTenants' = $ExistingRole.BlockedTenants
'BlockedEndpoints' = $ExistingRole.BlockedEndpoints
'AllowedRolesTemplate' = $ExistingRole.AllowedRolesTemplate
}
Add-CIPPAzDataTableEntity @Table -Entity $NewRole -Force | Out-Null
# Clone IP ranges if they exist
Expand Down Expand Up @@ -218,6 +220,15 @@ function Invoke-ExecCustomRole {
} else {
$Role | Add-Member -NotePropertyName BlockedEndpoints -NotePropertyValue @() -Force
}
if ($Role.AllowedRolesTemplate) {
try {
$Role.AllowedRolesTemplate = $Role.AllowedRolesTemplate | ConvertFrom-Json
} catch {
$Role.AllowedRolesTemplate = $null
}
} else {
$Role | Add-Member -NotePropertyName AllowedRolesTemplate -NotePropertyValue $null -Force
}
$EntraRoleGroup = $EntraRoleGroups | Where-Object -Property RowKey -EQ $Role.RowKey
if ($EntraRoleGroup) {
$EntraGroup = $EntraRoleGroups | Where-Object -Property RowKey -EQ $Role.RowKey | Select-Object @{Name = 'label'; Expression = { $_.GroupName } }, @{Name = 'value'; Expression = { $_.GroupId } }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
function Invoke-AddJITRoleTemplate {
<#
.FUNCTIONALITY
Entrypoint
.ROLE
Identity.Role.ReadWrite
.DESCRIPTION
Creates a JIT Role Template - a named allow-list of directory roles that can be assigned to a
CIPP custom role to restrict which roles that role's members may grant via JIT Admin.
#>
[CmdletBinding()]
param($Request, $TriggerMetadata)

$APIName = $Request.Params.CIPPEndpoint
$Headers = $Request.Headers

try {
$TemplateName = $Request.Body.templateName

if ([string]::IsNullOrWhiteSpace($TemplateName)) {
throw 'templateName is required'
}
if (-not $Request.Body.roles -or @($Request.Body.roles).Count -eq 0) {
throw 'At least one role is required'
}

Write-LogMessage -headers $Headers -API $APIName -message "Creating JIT Role template '$TemplateName'" -Sev 'Info'

# Get user info for audit
$UserDetails = ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails

# Check if template name already exists
$Table = Get-CippTable -tablename 'templates'
$ExistingTemplates = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'JITRoleTemplate'"
$ExistingNames = $ExistingTemplates | ForEach-Object {
try {
$data = $_.JSON | ConvertFrom-Json -Depth 100 -ErrorAction Stop
if ($data.templateName -eq $TemplateName) {
$data
}
} catch {}
}

if ($ExistingNames) {
throw "A JIT Role Template with name '$TemplateName' already exists"
}

$TemplateObject = @{
templateName = $TemplateName
roles = $Request.Body.roles
createdBy = $UserDetails
createdDate = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
}

$GUID = (New-Guid).GUID
$JSON = ConvertTo-Json -InputObject $TemplateObject -Depth 100 -Compress

$Table.Force = $true
Add-CIPPAzDataTableEntity @Table -Entity @{
JSON = "$JSON"
RowKey = "$GUID"
PartitionKey = 'JITRoleTemplate'
GUID = "$GUID"
}

$Result = "Created JIT Role Template '$($TemplateName)' with GUID $GUID"
Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Info'
$StatusCode = [HttpStatusCode]::OK

} catch {
$ErrorMessage = Get-CippException -Exception $_
$Result = "Failed to create JIT Role Template: $($ErrorMessage.NormalizedError)"
Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage
$StatusCode = [HttpStatusCode]::InternalServerError
}

return ([HttpResponseContext]@{
StatusCode = $StatusCode
Body = @{'Results' = "$Result" }
})
}
Loading