diff --git a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecJITAdminListAllTenants.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecJITAdminListAllTenants.ps1
index 2a4ec14280..79ffdb7bd5 100644
--- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecJITAdminListAllTenants.ps1
+++ b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecJITAdminListAllTenants.ps1
@@ -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
@@ -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
}
}
@@ -61,6 +61,7 @@ function Push-ExecJITAdminListAllTenants {
jitAdminEnabled = $jitAdminEnabled
jitAdminExpiration = $jitAdminExpiration
memberOf = ($MemberOf | ConvertTo-Json -Depth 5 -Compress)
+ roleTemplateIds = @($MemberOf.roleTemplateId | Where-Object { $_ })
}
}
diff --git a/backend/Modules/CIPPCore/Public/Get-CIPPJITAdminAllowedRoles.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPJITAdminAllowedRoles.ps1
new file mode 100644
index 0000000000..8f646b2b91
--- /dev/null
+++ b/backend/Modules/CIPPCore/Public/Get-CIPPJITAdminAllowedRoles.ps1
@@ -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)
+ }
+}
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomRole.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomRole.ps1
index 37609dc2f7..b37fbc10dc 100644
--- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomRole.ps1
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomRole.ps1
@@ -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")
@@ -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
@@ -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 } }
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddJITRoleTemplate.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddJITRoleTemplate.ps1
new file mode 100644
index 0000000000..1d5a2567ae
--- /dev/null
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddJITRoleTemplate.ps1
@@ -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" }
+ })
+}
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditJITRoleTemplate.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditJITRoleTemplate.ps1
new file mode 100644
index 0000000000..b216337efc
--- /dev/null
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditJITRoleTemplate.ps1
@@ -0,0 +1,94 @@
+function Invoke-EditJITRoleTemplate {
+ <#
+ .FUNCTIONALITY
+ Entrypoint
+ .ROLE
+ Identity.Role.ReadWrite
+ .DESCRIPTION
+ Updates an existing JIT Role Template.
+ #>
+ [CmdletBinding()]
+ param($Request, $TriggerMetadata)
+
+ $APIName = $Request.Params.CIPPEndpoint
+ $Headers = $Request.Headers
+
+ try {
+ $GUID = $Request.Body.GUID
+ $TemplateName = $Request.Body.templateName
+
+ if ([string]::IsNullOrWhiteSpace($GUID)) {
+ throw 'GUID is required'
+ }
+ 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 "Editing JIT Role template '$GUID'" -Sev 'Info'
+
+ $UserDetails = ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails
+
+ $Table = Get-CippTable -tablename 'templates'
+ $SafeGUID = ConvertTo-CIPPODataFilterValue -Value $GUID -Type Guid
+ $Filter = "PartitionKey eq 'JITRoleTemplate' and RowKey eq '$SafeGUID'"
+ $ExistingTemplate = Get-CIPPAzDataTableEntity @Table -Filter $Filter
+
+ if (!$ExistingTemplate) {
+ throw "JIT Role Template with GUID '$GUID' not found"
+ }
+
+ $ExistingData = $ExistingTemplate.JSON | ConvertFrom-Json -Depth 100
+
+ # Check if template name is unique (excluding current template)
+ $AllTemplates = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'JITRoleTemplate'"
+ $DuplicateName = $AllTemplates | Where-Object { $_.RowKey -ne $GUID } | ForEach-Object {
+ try {
+ $data = $_.JSON | ConvertFrom-Json -Depth 100 -ErrorAction Stop
+ if ($data.templateName -eq $TemplateName) {
+ $data
+ }
+ } catch {}
+ }
+
+ if ($DuplicateName) {
+ throw "A JIT Role Template with name '$TemplateName' already exists"
+ }
+
+ $TemplateObject = @{
+ templateName = $TemplateName
+ roles = $Request.Body.roles
+ createdBy = $ExistingData.createdBy
+ createdDate = $ExistingData.createdDate
+ modifiedBy = $UserDetails
+ modifiedDate = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
+ }
+
+ $JSON = ConvertTo-Json -InputObject $TemplateObject -Depth 100 -Compress
+
+ $Table.Force = $true
+ Add-CIPPAzDataTableEntity @Table -Entity @{
+ JSON = "$JSON"
+ RowKey = "$GUID"
+ PartitionKey = 'JITRoleTemplate'
+ GUID = "$GUID"
+ }
+
+ $Result = "Updated JIT Role Template '$($TemplateName)' (GUID: $GUID)"
+ Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Info'
+ $StatusCode = [HttpStatusCode]::OK
+
+ } catch {
+ $ErrorMessage = Get-CippException -Exception $_
+ $Result = "Failed to update 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" }
+ })
+}
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1
index 9233c506da..44da1a17e9 100644
--- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1
@@ -53,6 +53,27 @@ function Invoke-ExecJITAdmin {
# Continue execution if we can't check the setting
}
+ # Enforce the caller's allowed JIT roles (from the JIT Role Template on their custom role).
+ # Get-CIPPJITAdminAllowedRoles is authoritative and fails closed for restricted callers, so we
+ # trust its result rather than swallowing errors here.
+ $RequestedRoles = @($Request.Body.AdminRoles.value | Where-Object { $_ })
+ if ($RequestedRoles.Count -gt 0) {
+ $AllowedRoles = Get-CIPPJITAdminAllowedRoles -Headers $Headers
+ if ($AllowedRoles.Restricted) {
+ $ForbiddenRoles = @($RequestedRoles | Where-Object { $AllowedRoles.AllowedRoleIds -notcontains $_ })
+ if ($ForbiddenRoles.Count -gt 0) {
+ $ForbiddenLabels = @($Request.Body.AdminRoles | Where-Object { $ForbiddenRoles -contains $_.value } | ForEach-Object { $_.label ?? $_.value })
+ if ($ForbiddenLabels.Count -eq 0) { $ForbiddenLabels = $ForbiddenRoles }
+ $ErrorMessage = "You are not permitted to assign the following role(s): $($ForbiddenLabels -join ', ')"
+ Write-LogMessage -headers $Headers -API $APIName -message $ErrorMessage -Sev 'Error'
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::BadRequest
+ Body = @{'Results' = @($ErrorMessage) }
+ })
+ }
+ }
+ }
+
if ($Request.Body.userAction -eq 'create') {
$Domain = $Request.Body.Domain.value ? $Request.Body.Domain.value : $Request.Body.Domain
$Username = "$($Request.Body.Username)@$($Domain)"
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1
index 3c5a66238d..06830b6c18 100644
--- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1
@@ -15,6 +15,18 @@
$Schema = Get-CIPPSchemaExtensions | Where-Object { $_.id -match '_cippUser' } | Select-Object -First 1
$TenantFilter = $Request.Query.TenantFilter
+ # Resolve which directory roles the caller may see. When restricted, a JIT admin is only shown if
+ # every directory role it currently holds is within the caller's allow-list (strict subset). JIT
+ # admins with no resolvable roles (e.g. scheduled-but-not-yet-active, or stale cache) are shown.
+ $AllowedRoles = Get-CIPPJITAdminAllowedRoles -Headers $Request.Headers
+ $FilterJITAdmin = {
+ param($RoleTemplateIds)
+ if (-not $AllowedRoles.Restricted) { return $true }
+ $Ids = @($RoleTemplateIds | Where-Object { $_ })
+ if ($Ids.Count -eq 0) { return $true }
+ return @($Ids | Where-Object { $AllowedRoles.AllowedRoleIds -notcontains $_ }).Count -eq 0
+ }
+
if ($TenantFilter -ne 'AllTenants') {
# Single tenant logic
$BulkRequests = [System.Collections.Generic.List[object]]::new()
@@ -29,27 +41,37 @@
$BulkRequests.Clear()
foreach ($User in $Users) {
+ # memberOf (groups + roles) for display
$BulkRequests.Add(@{
id = $User.id
method = 'GET'
url = "users/$($User.id)/memberOf?`$select=id,displayName"
})
+ # directory roles with roleTemplateId, used for allow-list filtering
+ $BulkRequests.Add(@{
+ id = "role_$($User.id)"
+ method = 'GET'
+ url = "users/$($User.id)/memberOf/microsoft.graph.directoryRole?`$select=id,displayName,roleTemplateId"
+ })
}
$RoleResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($BulkRequests)
# Write-Information ($RoleResults | ConvertTo-Json -Depth 10 )
$Results = $Users | ForEach-Object {
$MemberOf = ($RoleResults | Where-Object -Property id -EQ $_.id).body.value | Select-Object displayName, id
- [PSCustomObject]@{
- id = $_.id
- displayName = $_.displayName
- userPrincipalName = $_.userPrincipalName
- accountEnabled = $_.accountEnabled
- jitAdminEnabled = $_.($Schema.id).jitAdminEnabled
- jitAdminExpiration = $_.($Schema.id).jitAdminExpiration
- jitAdminStartDate = $_.($Schema.id).jitAdminStartDate
- jitAdminReason = $_.($Schema.id).jitAdminReason
- jitAdminCreatedBy = $_.($Schema.id).jitAdminCreatedBy
- memberOf = $MemberOf
+ $DirectoryRoles = ($RoleResults | Where-Object -Property id -EQ "role_$($_.id)").body.value | Select-Object displayName, id, roleTemplateId
+ if ((& $FilterJITAdmin ($DirectoryRoles.roleTemplateId))) {
+ [PSCustomObject]@{
+ id = $_.id
+ displayName = $_.displayName
+ userPrincipalName = $_.userPrincipalName
+ accountEnabled = $_.accountEnabled
+ jitAdminEnabled = $_.($Schema.id).jitAdminEnabled
+ jitAdminExpiration = $_.($Schema.id).jitAdminExpiration
+ jitAdminStartDate = $_.($Schema.id).jitAdminStartDate
+ jitAdminReason = $_.($Schema.id).jitAdminReason
+ jitAdminCreatedBy = $_.($Schema.id).jitAdminCreatedBy
+ memberOf = $MemberOf
+ }
}
}
@@ -102,6 +124,9 @@
Write-Information "Found $($Rows.Count) rows in the cache"
foreach ($row in ($Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant')) {
$UserObject = $row.JITAdminUser | ConvertFrom-Json
+ if (-not (& $FilterJITAdmin ($UserObject.roleTemplateIds))) {
+ continue
+ }
$Results.Add(
[PSCustomObject]@{
Tenant = $row.Tenant
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAllowedRoles.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAllowedRoles.ps1
new file mode 100644
index 0000000000..1a281ef002
--- /dev/null
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAllowedRoles.ps1
@@ -0,0 +1,24 @@
+function Invoke-ListJITAllowedRoles {
+ <#
+ .FUNCTIONALITY
+ Entrypoint,AnyTenant
+ .ROLE
+ Identity.Role.Read
+ .DESCRIPTION
+ Returns the directory roles the calling user is permitted to assign via JIT Admin, based on the
+ JIT Role Template(s) attached to their CIPP custom role(s). When the caller is unrestricted the
+ full role catalog is available (Restricted = false).
+ #>
+ [CmdletBinding()]
+ param($Request, $TriggerMetadata)
+
+ $Allowed = Get-CIPPJITAdminAllowedRoles -Headers $Request.Headers
+
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::OK
+ Body = @{
+ Restricted = $Allowed.Restricted
+ AllowedRoleIds = @($Allowed.AllowedRoleIds)
+ }
+ })
+}
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITRoleTemplates.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITRoleTemplates.ps1
new file mode 100644
index 0000000000..2632297bf4
--- /dev/null
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITRoleTemplates.ps1
@@ -0,0 +1,45 @@
+function Invoke-ListJITRoleTemplates {
+ <#
+ .FUNCTIONALITY
+ Entrypoint,AnyTenant
+ .ROLE
+ Identity.Role.Read
+ .DESCRIPTION
+ Lists JIT Role Templates - named allow-lists of directory roles used to restrict which roles a
+ CIPP custom role may assign via JIT Admin.
+ #>
+ [CmdletBinding()]
+ param($Request, $TriggerMetadata)
+
+ $APIName = $Request.Params.CIPPEndpoint
+ $Headers = $Request.Headers
+
+ $Table = Get-CippTable -tablename 'templates'
+ $Filter = "PartitionKey eq 'JITRoleTemplate'"
+
+ $Templates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) | ForEach-Object {
+ try {
+ $row = $_
+ $data = $row.JSON | ConvertFrom-Json -Depth 100 -ErrorAction Stop
+ $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $row.GUID -Force
+ $data | Add-Member -NotePropertyName 'RowKey' -NotePropertyValue $row.RowKey -Force
+ $data
+ } catch {
+ Write-LogMessage -headers $Headers -API $APIName -message "Failed to process JIT Role template: $($row.RowKey) - $($_.Exception.Message)" -sev 'Warning'
+ }
+ }
+
+ $Templates = $Templates | Sort-Object -Property templateName
+
+ # If a specific GUID is requested, filter to that template
+ if ($Request.query.GUID) {
+ $Templates = $Templates | Where-Object -Property GUID -EQ $Request.query.GUID
+ }
+
+ $Templates = ConvertTo-Json -InputObject @($Templates) -Depth 100
+
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::OK
+ Body = $Templates
+ })
+}
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-RemoveJITRoleTemplate.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-RemoveJITRoleTemplate.ps1
new file mode 100644
index 0000000000..89df493bd0
--- /dev/null
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-RemoveJITRoleTemplate.ps1
@@ -0,0 +1,50 @@
+function Invoke-RemoveJITRoleTemplate {
+ <#
+ .FUNCTIONALITY
+ Entrypoint
+ .ROLE
+ Identity.Role.ReadWrite
+ .DESCRIPTION
+ Deletes a JIT Role Template.
+ #>
+ [CmdletBinding()]
+ param($Request, $TriggerMetadata)
+
+ $APIName = $Request.Params.CIPPEndpoint
+ $Headers = $Request.Headers
+
+ try {
+ $ID = $Request.Query.ID ?? $Request.Body.ID
+
+ if ([string]::IsNullOrWhiteSpace($ID)) {
+ throw 'ID is required'
+ }
+
+ $Table = Get-CippTable -tablename 'templates'
+ $SafeID = ConvertTo-CIPPODataFilterValue -Value $ID -Type Guid
+ $Filter = "PartitionKey eq 'JITRoleTemplate' and RowKey eq '$SafeID'"
+ $Template = Get-CIPPAzDataTableEntity @Table -Filter $Filter
+
+ if ($Template) {
+ Remove-AzDataTableEntity @Table -Entity $Template
+ $Result = "Successfully deleted JIT Role Template with ID: $ID"
+ Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Info'
+ $StatusCode = [HttpStatusCode]::OK
+ } else {
+ $Result = "JIT Role Template with ID $ID not found"
+ Write-LogMessage -headers $Headers -API $APIName -message $Result -sev 'Warning'
+ $StatusCode = [HttpStatusCode]::NotFound
+ }
+
+ } catch {
+ $ErrorMessage = Get-CippException -Exception $_
+ $Result = "Failed to delete 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" }
+ })
+}
diff --git a/docs/user-documentation/cipp/advanced/authentication/cipp-roles/add.md b/docs/user-documentation/cipp/advanced/authentication/cipp-roles/add.md
index a2b9d34346..2c62caf7e8 100644
--- a/docs/user-documentation/cipp/advanced/authentication/cipp-roles/add.md
+++ b/docs/user-documentation/cipp/advanced/authentication/cipp-roles/add.md
@@ -15,6 +15,12 @@ Enter a unique name for the role
Select an Entra ID group to assign to this role. This will automatically assign the CIPP role permissions to anyone added to this group.
{% endstep %}
+{% step %}
+### (Optional) JIT Role Template
+
+Select a [JIT Role Template](../../../../identity/administration/jit-role-templates/README.md) to restrict which Entra ID directory roles members of this role can grant when creating a JIT Admin. Members will also only see existing JIT Admins whose roles fall entirely within the template. Leave blank to apply no restriction from this role - note that a template on any other role a user holds still applies (restrictions combine, they do not cancel out).
+{% endstep %}
+
{% step %}
### (Optional) Allowed Tenants
diff --git a/docs/user-documentation/identity/administration/jit-admin/add.md b/docs/user-documentation/identity/administration/jit-admin/add.md
index ae67aa40b7..e4deb47ebb 100644
--- a/docs/user-documentation/identity/administration/jit-admin/add.md
+++ b/docs/user-documentation/identity/administration/jit-admin/add.md
@@ -4,6 +4,20 @@ This page grants time-limited administrative access. You choose who gets it, wha
## Tenant and template
+| Option | Description |
+| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Tenant selection | Use the dropdown to select the tenant for JIT Admin access |
+| Template selection | If you have created templates, you are able to select one here to prepopulate many of the fields below. |
+| User selection | Select if you would like to create a new user or use an existing user. Your choice here will expand additional fields to enter or validate if you selected a JIT template. |
+| Start Date | Sets the start date for JIT Admin access |
+| End Date | Sets the end date and time for JIT Admin access |
+| Admin Roles | Toggle on this option and then select the Entra ID admin roles you want assigned to the user. Remember: Use the principle of least privilege to only assign the role with the minimum set of permissions needed to complete your tasks. The roles are returned from the Microsoft API. If you are looking for Global Administrator, you need to select Company Administrator. If your CIPP role has a [JIT Role Template](../jit-role-templates/README.md) assigned, only the roles permitted by that template are selectable here. |
+| Group Membership | Toggle on this option and then select the groups you want this admin to have access to. |
+| Reason | Enter the reason the JIT Admin is being requested. This will display on the table in [.](./ "mention") |
+| Generate TAP | Set this option to generate a Temporary Access Pass (TAP) to satisfy the need for strong authentication/MFA |
+| Expiration Action | Select what you want to happen to the user at expiration of the JIT admin access requested. |
+| Notification Action | Select the option or options for how you would like to be notified of JIT admin creation. Note that only options that are configured in CIPP settings will work. |
+
| Field | Description |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Select a tenant to create the JIT Admin in | The tenant the access is granted in. Required, and it has to be chosen before the template list and the tenant's Temporary Access Pass policy can be read. |
diff --git a/frontend/src/components/CippComponents/CippJitRoleTemplateApply.jsx b/frontend/src/components/CippComponents/CippJitRoleTemplateApply.jsx
new file mode 100644
index 0000000000..cf9da5c43a
--- /dev/null
+++ b/frontend/src/components/CippComponents/CippJitRoleTemplateApply.jsx
@@ -0,0 +1,70 @@
+import { useEffect, useRef } from "react";
+import { useWatch } from "react-hook-form";
+import CippFormComponent from "./CippFormComponent";
+import { useJitAllowedRoles } from "../../hooks/use-jit-allowed-roles";
+
+/**
+ * A convenience selector that pulls the roles from one or more JIT Role Templates into a target roles
+ * field (e.g. adminRoles / defaultRoles). Roles are merged additively - existing selections are kept -
+ * and remain fully editable afterward. If the current user is restricted by an assigned JIT Role
+ * Template, roles outside their allow-list are dropped so the field never offers a role they cannot grant.
+ */
+export const CippJitRoleTemplateApply = ({
+ formControl,
+ targetField,
+ name = "applyRoleTemplate",
+ label = "Apply JIT Role Template",
+}) => {
+ const { restricted, allowedRoleIds } = useJitAllowedRoles();
+ const selected = useWatch({ control: formControl.control, name });
+ const lastApplied = useRef(null);
+
+ useEffect(() => {
+ if (!selected || selected.length === 0) return;
+ const selectedKey = selected
+ .map((t) => t?.value)
+ .sort()
+ .join(",");
+ if (selectedKey === lastApplied.current) return;
+ lastApplied.current = selectedKey;
+
+ const templateRoles = selected
+ .flatMap((t) => t?.addedFields?.roles || [])
+ .filter((r) => r && r.value)
+ .filter((r) => (restricted ? allowedRoleIds.includes(r.value) : true))
+ .map((r) => ({ label: r.label, value: r.value }));
+
+ const existing = formControl.getValues(targetField) || [];
+ const merged = [...existing];
+ templateRoles.forEach((role) => {
+ if (!merged.some((m) => m.value === role.value)) {
+ merged.push(role);
+ }
+ });
+ formControl.setValue(targetField, merged, { shouldValidate: true, shouldDirty: true });
+ }, [selected]);
+
+ return (
+
+ );
+};
+
+export default CippJitRoleTemplateApply;
diff --git a/frontend/src/components/CippSettings/CippRoleAddEdit.jsx b/frontend/src/components/CippSettings/CippRoleAddEdit.jsx
index 75192d3940..200657f7bf 100644
--- a/frontend/src/components/CippSettings/CippRoleAddEdit.jsx
+++ b/frontend/src/components/CippSettings/CippRoleAddEdit.jsx
@@ -47,6 +47,7 @@ export const CippRoleAddEdit = ({ selectedRole }) => {
BlockedEndpoints: [],
IPRange: [],
Permissions: {},
+ AllowedRolesTemplate: null,
},
});
@@ -75,6 +76,10 @@ export const CippRoleAddEdit = ({ selectedRole }) => {
const setDefaults = useWatch({ control: formControl.control, name: "Defaults" });
const selectedPermissions = useWatch({ control: formControl.control, name: "Permissions" });
const selectedEntraGroup = useWatch({ control: formControl.control, name: "EntraGroup" });
+ const selectedRolesTemplate = useWatch({
+ control: formControl.control,
+ name: "AllowedRolesTemplate",
+ });
const ipRanges = useWatch({ control: formControl.control, name: "IPRange" });
const {
@@ -288,6 +293,7 @@ export const CippRoleAddEdit = ({ selectedRole }) => {
BlockedEndpoints: processedBlockedEndpoints,
IPRange: processedIPRanges,
EntraGroup: currentPermissions?.EntraGroup,
+ AllowedRolesTemplate: currentPermissions?.AllowedRolesTemplate || null,
});
}
}, [customRoleList, customRoleListSuccess, tenantsSuccess, baseRolePermissions]);
@@ -393,6 +399,7 @@ export const CippRoleAddEdit = ({ selectedRole }) => {
BlockedTenants: processedBlockedTenants,
BlockedEndpoints: processedBlockedEndpoints,
IPRange: processedIPRanges,
+ AllowedRolesTemplate: selectedRolesTemplate || null,
},
});
};
@@ -562,6 +569,26 @@ export const CippRoleAddEdit = ({ selectedRole }) => {
creatable={false}
helperText="Assigning an Entra group will automatically assign this role to all users in that group. This does not work with users invited directly to Static Web App."
/>
+
{!isBaseRole && (
<>
diff --git a/frontend/src/hooks/use-jit-allowed-roles.js b/frontend/src/hooks/use-jit-allowed-roles.js
new file mode 100644
index 0000000000..35860327cb
--- /dev/null
+++ b/frontend/src/hooks/use-jit-allowed-roles.js
@@ -0,0 +1,30 @@
+import { ApiGetCall } from "../api/ApiCall";
+
+/**
+ * Resolves which directory roles the current user may assign via JIT Admin, based on the JIT Role
+ * Template(s) attached to their CIPP custom role(s). When the user is unrestricted (no template, or a
+ * base/admin role), all roles are available so existing configurations are not disturbed.
+ *
+ * Backend enforcement in ExecJitAdmin is authoritative; this hook only shapes the picker options.
+ */
+export const useJitAllowedRoles = () => {
+ const query = ApiGetCall({
+ url: "/api/ListJITAllowedRoles",
+ queryKey: "JITAllowedRoles",
+ });
+
+ const restricted = query.data?.Restricted === true;
+ const allowedRoleIds = query.data?.AllowedRoleIds || [];
+
+ // Filter a list of role catalog entries ({ Name, ObjectId }) down to the allowed set.
+ const filterRoles = (roles = []) =>
+ restricted ? roles.filter((role) => allowedRoleIds.includes(role.ObjectId)) : roles;
+
+ return {
+ isLoading: query.isLoading,
+ isSuccess: query.isSuccess,
+ restricted,
+ allowedRoleIds,
+ filterRoles,
+ };
+};
diff --git a/frontend/src/layouts/config.js b/frontend/src/layouts/config.js
index 3be081ae15..7dcf982fca 100644
--- a/frontend/src/layouts/config.js
+++ b/frontend/src/layouts/config.js
@@ -86,6 +86,12 @@ export const nativeMenuItems = [
permissions: ['Identity.Role.*'],
scope: 'global',
},
+ {
+ title: 'JIT Role Templates',
+ path: '/identity/administration/jit-role-templates',
+ permissions: ['Identity.Role.*'],
+ scope: 'global',
+ },
{
title: 'Vacation Mode',
path: '/identity/administration/vacation-mode',
diff --git a/frontend/src/pages/identity/administration/jit-admin-templates/add.jsx b/frontend/src/pages/identity/administration/jit-admin-templates/add.jsx
index 9eef5d8603..5c6e1f3821 100644
--- a/frontend/src/pages/identity/administration/jit-admin-templates/add.jsx
+++ b/frontend/src/pages/identity/administration/jit-admin-templates/add.jsx
@@ -11,10 +11,13 @@ import { CippFormGroupSelector } from "../../../../components/CippComponents/Cip
import jitAdminRoles from "../../../../data/JitAdminRoles.json";
import countryList from "../../../../data/countryList.json";
import { useSettings } from "../../../../hooks/use-settings";
+import { useJitAllowedRoles } from "../../../../hooks/use-jit-allowed-roles";
+import { CippJitRoleTemplateApply } from "../../../../components/CippComponents/CippJitRoleTemplateApply";
import { useEffect } from "react";
const Page = () => {
const userSettingsDefaults = useSettings();
+ const { filterRoles } = useJitAllowedRoles();
const formControl = useForm({
mode: "onChange",
defaultValues: {
@@ -126,6 +129,9 @@ const Page = () => {
compareType="is"
compareValue={true}
>
+
+
+ {
label="Default Roles"
name="defaultRoles"
creatable={false}
- options={jitAdminRoles.map((role) => ({ label: role.Name, value: role.ObjectId }))}
+ options={filterRoles(jitAdminRoles).map((role) => ({
+ label: role.Name,
+ value: role.ObjectId,
+ }))}
formControl={formControl}
required={true}
validators={{
diff --git a/frontend/src/pages/identity/administration/jit-admin-templates/edit.jsx b/frontend/src/pages/identity/administration/jit-admin-templates/edit.jsx
index 00483ce151..4085d1bfaa 100644
--- a/frontend/src/pages/identity/administration/jit-admin-templates/edit.jsx
+++ b/frontend/src/pages/identity/administration/jit-admin-templates/edit.jsx
@@ -11,12 +11,15 @@ import { CippFormGroupSelector } from "../../../../components/CippComponents/Cip
import jitAdminRoles from "../../../../data/JitAdminRoles.json";
import countryList from "../../../../data/countryList.json";
import { useSettings } from "../../../../hooks/use-settings";
+import { useJitAllowedRoles } from "../../../../hooks/use-jit-allowed-roles";
+import { CippJitRoleTemplateApply } from "../../../../components/CippComponents/CippJitRoleTemplateApply";
import { useRouter } from "next/router";
import { ApiGetCall } from "../../../../api/ApiCall";
import { useEffect } from "react";
const Page = () => {
const userSettingsDefaults = useSettings();
+ const { filterRoles } = useJitAllowedRoles();
const router = useRouter();
const { id } = router.query;
@@ -149,6 +152,9 @@ const Page = () => {
compareType="is"
compareValue={true}
>
+
+
+ {
label="Default Roles"
name="defaultRoles"
creatable={false}
- options={jitAdminRoles.map((role) => ({ label: role.Name, value: role.ObjectId }))}
+ options={filterRoles(jitAdminRoles).map((role) => ({
+ label: role.Name,
+ value: role.ObjectId,
+ }))}
formControl={formControl}
required={true}
validators={{
diff --git a/frontend/src/pages/identity/administration/jit-admin/add.jsx b/frontend/src/pages/identity/administration/jit-admin/add.jsx
index 20acc1cffb..958c71bd09 100644
--- a/frontend/src/pages/identity/administration/jit-admin/add.jsx
+++ b/frontend/src/pages/identity/administration/jit-admin/add.jsx
@@ -12,12 +12,15 @@ import { CippFormDomainSelector } from '../../../../components/CippComponents/Ci
import { CippFormUserSelector } from '../../../../components/CippComponents/CippFormUserSelector'
import { CippFormGroupSelector } from '../../../../components/CippComponents/CippFormGroupSelector'
import { ApiGetCall } from '../../../../api/ApiCall'
+import { useJitAllowedRoles } from '../../../../hooks/use-jit-allowed-roles'
+import { CippJitRoleTemplateApply } from '../../../../components/CippComponents/CippJitRoleTemplateApply'
import { useEffect, useState } from 'react'
const Page = () => {
const formControl = useForm({ mode: 'onChange' })
const selectedTenant = useWatch({ control: formControl.control, name: 'tenantFilter' })
const [selectedTemplate, setSelectedTemplate] = useState(null)
+ const { filterRoles } = useJitAllowedRoles()
const jitAdminTemplates = ApiGetCall({
url: selectedTenant
@@ -463,13 +466,19 @@ const Page = () => {
compareType="is"
compareValue={true}
>
+
+
+ ({ label: role.Name, value: role.ObjectId }))}
+ options={filterRoles(jitAdminRoles).map((role) => ({
+ label: role.Name,
+ value: role.ObjectId,
+ }))}
formControl={formControl}
required={true}
validators={{
diff --git a/frontend/src/pages/identity/administration/jit-role-templates/add.jsx b/frontend/src/pages/identity/administration/jit-role-templates/add.jsx
new file mode 100644
index 0000000000..dd30ae1a5e
--- /dev/null
+++ b/frontend/src/pages/identity/administration/jit-role-templates/add.jsx
@@ -0,0 +1,67 @@
+import { Box } from "@mui/material";
+import { Grid } from "@mui/system";
+import CippFormPage from "../../../../components/CippFormPages/CippFormPage";
+import { Layout as DashboardLayout } from "../../../../layouts/index.js";
+import { useForm } from "react-hook-form";
+import CippFormComponent from "../../../../components/CippComponents/CippFormComponent";
+import jitAdminRoles from "../../../../data/JitAdminRoles.json";
+
+const Page = () => {
+ const formControl = useForm({
+ mode: "onChange",
+ defaultValues: {
+ templateName: "",
+ roles: [],
+ },
+ });
+
+ return (
+
+
+
+
+
+
+
+ ({ label: role.Name, value: role.ObjectId }))}
+ formControl={formControl}
+ required={true}
+ validators={{
+ required: "At least one role is required",
+ validate: (options) =>
+ options?.length ? true : "At least one role is required",
+ }}
+ helperText="CIPP roles assigned this template may only grant these directory roles via JIT Admin."
+ />
+
+
+
+
+ );
+};
+
+Page.getLayout = (page) => {page};
+
+export default Page;
diff --git a/frontend/src/pages/identity/administration/jit-role-templates/edit.jsx b/frontend/src/pages/identity/administration/jit-role-templates/edit.jsx
new file mode 100644
index 0000000000..c284793915
--- /dev/null
+++ b/frontend/src/pages/identity/administration/jit-role-templates/edit.jsx
@@ -0,0 +1,87 @@
+import { Box } from "@mui/material";
+import { Grid } from "@mui/system";
+import CippFormPage from "../../../../components/CippFormPages/CippFormPage";
+import { Layout as DashboardLayout } from "../../../../layouts/index.js";
+import { useForm } from "react-hook-form";
+import CippFormComponent from "../../../../components/CippComponents/CippFormComponent";
+import jitAdminRoles from "../../../../data/JitAdminRoles.json";
+import { ApiGetCall } from "../../../../api/ApiCall";
+import { useRouter } from "next/router";
+import { useEffect } from "react";
+
+const Page = () => {
+ const formControl = useForm({
+ mode: "onChange",
+ defaultValues: {
+ templateName: "",
+ roles: [],
+ },
+ });
+
+ const router = useRouter();
+ const { id } = router.query;
+
+ const template = ApiGetCall({
+ url: `/api/ListJITRoleTemplates?GUID=${id}`,
+ queryKey: `JITRoleTemplate-${id}`,
+ waiting: !!id,
+ });
+
+ useEffect(() => {
+ if (template.isSuccess && template.data?.[0]) {
+ const templateData = template.data[0];
+ formControl.reset({ ...templateData, GUID: id });
+ }
+ }, [template.isSuccess, template.data]);
+
+ return (
+
+
+
+
+
+
+
+ ({ label: role.Name, value: role.ObjectId }))}
+ formControl={formControl}
+ required={true}
+ validators={{
+ required: "At least one role is required",
+ validate: (options) =>
+ options?.length ? true : "At least one role is required",
+ }}
+ helperText="CIPP roles assigned this template may only grant these directory roles via JIT Admin."
+ />
+
+
+
+
+
+ );
+};
+
+Page.getLayout = (page) => {page};
+
+export default Page;
diff --git a/frontend/src/pages/identity/administration/jit-role-templates/index.js b/frontend/src/pages/identity/administration/jit-role-templates/index.js
new file mode 100644
index 0000000000..d15eb448d8
--- /dev/null
+++ b/frontend/src/pages/identity/administration/jit-role-templates/index.js
@@ -0,0 +1,78 @@
+import { Button } from "@mui/material";
+import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
+import { Layout as DashboardLayout } from "../../../../layouts/index.js";
+import { AddBox, Delete, Edit } from "@mui/icons-material";
+import Link from "next/link";
+import { CippPropertyListCard } from "../../../../components/CippCards/CippPropertyListCard";
+import { getCippTranslation } from "../../../../utils/get-cipp-translation";
+import { getCippFormatting } from "../../../../utils/get-cipp-formatting";
+
+const Page = () => {
+ const pageTitle = "JIT Role Templates";
+
+ const actions = [
+ {
+ label: "Edit Template",
+ icon: ,
+ link: "/identity/administration/jit-role-templates/edit?id=[GUID]",
+ },
+ {
+ label: "Delete Template",
+ type: "POST",
+ url: "/api/RemoveJITRoleTemplate",
+ icon: ,
+ data: {
+ ID: "GUID",
+ },
+ confirmText: "Do you want to delete the template?",
+ multiPost: false,
+ },
+ ];
+
+ const offCanvas = {
+ children: (data) => {
+ const keys = Object.keys(data).filter(
+ (key) => !key.includes("@odata") && !key.includes("@data")
+ );
+ const properties = [];
+ keys.forEach((key) => {
+ if (data[key] && data[key].length > 0) {
+ properties.push({
+ label: getCippTranslation(key),
+ value: getCippFormatting(data[key], key),
+ });
+ }
+ });
+ return (
+
+ );
+ },
+ };
+
+ return (
+ }>
+ Add JIT Role Template
+
+ }
+ offCanvas={offCanvas}
+ simpleColumns={["templateName", "roles", "createdBy", "createdDate"]}
+ />
+ );
+};
+
+Page.getLayout = (page) => {page};
+
+export default Page;
diff --git a/user-documentation/identity/administration/jit-role-templates/README.md b/user-documentation/identity/administration/jit-role-templates/README.md
new file mode 100644
index 0000000000..94fcd2f882
--- /dev/null
+++ b/user-documentation/identity/administration/jit-role-templates/README.md
@@ -0,0 +1,36 @@
+# JIT Role Templates
+
+This page allows you to manage the JIT Role Templates on your CIPP instance.
+
+A JIT Role Template is a named allow-list of Entra ID directory roles. When a template is assigned to a [CIPP custom role](../../../cipp/advanced/super-admin/custom-roles/README.md), members of that role can only grant the roles contained in the template when creating a JIT Admin, and can only see JIT Admins whose roles fall entirely within the template.
+
+{% hint style="info" %}
+Restriction combines the same way CIPP handles multiple custom roles elsewhere - restrictively, not additively. A custom role with no template assigned does not restrict anything on its own, but as soon as a user holds **any** role that has a template they are restricted, and their allowed roles are the **intersection** of every template they hold. An untemplated role therefore cannot be used to bypass a template assigned alongside it. `admin`/`superadmin` users are unaffected, and if no template is assigned anywhere nothing changes - so existing configurations are not disturbed until you explicitly assign one.
+{% endhint %}
+
+## Page Actions
+
+
+
+Add JIT Role Template
+
+Links to [add-jit-role-template.md](add-jit-role-template.md "mention")
+
+
+
+## Table Data
+
+| Column | Description |
+| ------------- | ------------------------------------------------------------------ |
+| Template Name | The name of the template given at creation or last edit |
+| Roles | The directory roles included in this allow-list |
+| Created By | The user that created the template |
+| Created Date | The date the template was created |
+
+## Table Actions
+
+
Action
Description
Bulk Action Available
Edit Template
Allows you to edit the template's name and roles
false
Delete Template
Deletes the selected template
false
+
+***
+
+{% include "../../../../.gitbook/includes/feature-request.md" %}
diff --git a/user-documentation/identity/administration/jit-role-templates/add-jit-role-template.md b/user-documentation/identity/administration/jit-role-templates/add-jit-role-template.md
new file mode 100644
index 0000000000..fc7c9081b0
--- /dev/null
+++ b/user-documentation/identity/administration/jit-role-templates/add-jit-role-template.md
@@ -0,0 +1,16 @@
+# Add JIT Role Template
+
+Here you can create a JIT Role Template. Give the template a name, select the directory roles it should allow, and hit save.
+
+| Option | Description |
+| ------------- | --------------------------------------------------------------------------------------------------------------------------------- |
+| Template Name | A unique name for the template |
+| Allowed Roles | The Entra ID directory roles this template permits. CIPP custom roles assigned this template may only grant these roles via JIT Admin. |
+
+{% hint style="info" %}
+The template only takes effect once it is assigned to a CIPP custom role on the [Add Role](../../../cipp/advanced/super-admin/custom-roles/add.md) page. A custom role with no template assigned can still grant all roles.
+{% endhint %}
+
+***
+
+{% include "../../../../.gitbook/includes/feature-request.md" %}