|
| 1 | +function Test-CippApiClientRoleGrant { |
| 2 | + <# |
| 3 | + .SYNOPSIS |
| 4 | + Validates that the caller of an API client management action is permitted to |
| 5 | + create, modify, reset, or delete an API client holding the supplied role(s). |
| 6 | +
|
| 7 | + .DESCRIPTION |
| 8 | + Prevents privilege escalation through the ApiClients table. The ExecApiClient |
| 9 | + endpoint is gated at CIPP.Extension.ReadWrite (editor-grantable), but the role |
| 10 | + assigned to an API client becomes that client's effective privilege at request |
| 11 | + time (see Test-CIPPAccess). Without this check an editor could mint a client |
| 12 | + with the 'superadmin' role, or reset the secret of an existing superadmin |
| 13 | + client, and escalate. |
| 14 | +
|
| 15 | + A caller may only manage a client whose effective permissions are a subset of |
| 16 | + the caller's own effective permissions. Superadmins may grant any role. Roles |
| 17 | + are compared by computed permission set (built-in and custom), matching exactly |
| 18 | + how Test-CIPPAccess evaluates an API client (single role, no base-role ceiling). |
| 19 | +
|
| 20 | + .PARAMETER Request |
| 21 | + The HTTP request, used to resolve the caller's roles. Handles both interactive |
| 22 | + user principals and API-client principals. |
| 23 | +
|
| 24 | + .PARAMETER Role |
| 25 | + One or more roles to validate, e.g. the requested new role and the existing |
| 26 | + client's current role. An empty/missing role is treated as the runtime |
| 27 | + 'cipp-api' fallback that Test-CIPPAccess applies to roleless clients. |
| 28 | +
|
| 29 | + .OUTPUTS |
| 30 | + [pscustomobject] with Allowed [bool] and Message [string]. Fails closed. |
| 31 | +
|
| 32 | + .FUNCTIONALITY |
| 33 | + Internal |
| 34 | + #> |
| 35 | + [CmdletBinding()] |
| 36 | + param( |
| 37 | + [Parameter(Mandatory = $true)] |
| 38 | + $Request, |
| 39 | + |
| 40 | + [Parameter(Mandatory = $true)] |
| 41 | + [AllowEmptyCollection()] |
| 42 | + [AllowEmptyString()] |
| 43 | + [string[]]$Role |
| 44 | + ) |
| 45 | + |
| 46 | + function New-Denial { |
| 47 | + param([string]$Message) |
| 48 | + [pscustomobject]@{ Allowed = $false; Message = $Message } |
| 49 | + } |
| 50 | + |
| 51 | + # Resolve the caller's roles. Mirror Test-CIPPAccess's principal detection so this |
| 52 | + # works whether the caller is an interactive user or an API client. |
| 53 | + try { |
| 54 | + if ($Request.Headers.'x-ms-client-principal-idp' -eq 'aad' -and $Request.Headers.'x-ms-client-principal-name' -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { |
| 55 | + $CallerClient = Get-CippApiClient -AppId $Request.Headers.'x-ms-client-principal-name' |
| 56 | + if ($CallerClient.Role) { |
| 57 | + $CallerRoles = @($CallerClient.Role) |
| 58 | + } else { |
| 59 | + $CallerRoles = @('cipp-api') |
| 60 | + } |
| 61 | + } else { |
| 62 | + $CallerRoles = @(Get-CIPPAccessRole -Request $Request) |
| 63 | + } |
| 64 | + } catch { |
| 65 | + return (New-Denial "Unable to resolve your roles for authorization: $($_.Exception.Message)") |
| 66 | + } |
| 67 | + |
| 68 | + if (-not $CallerRoles -or $CallerRoles.Count -eq 0) { |
| 69 | + return (New-Denial 'Unable to determine your roles; cannot authorize this API client operation.') |
| 70 | + } |
| 71 | + |
| 72 | + # Superadmin may grant or manage any role. |
| 73 | + if ($CallerRoles -contains 'superadmin') { |
| 74 | + return [pscustomobject]@{ Allowed = $true; Message = $null } |
| 75 | + } |
| 76 | + |
| 77 | + $DefaultRoles = @('superadmin', 'admin', 'editor', 'readonly') |
| 78 | + $CallerPermissions = @(Get-CippAllowedPermissions -UserRoles $CallerRoles) |
| 79 | + |
| 80 | + # Normalize: a roleless client resolves to the 'cipp-api' fallback at request time, |
| 81 | + # so validate against that to mirror real client evaluation and stay future-proof. |
| 82 | + $TargetRoles = @($Role | ForEach-Object { |
| 83 | + if ([string]::IsNullOrWhiteSpace($_)) { 'cipp-api' } else { $_.Trim() } |
| 84 | + } | Sort-Object -Unique) |
| 85 | + |
| 86 | + foreach ($TargetRole in $TargetRoles) { |
| 87 | + # anonymous/authenticated are SWA placeholder roles, never valid client roles. |
| 88 | + if (@('anonymous', 'authenticated') -contains $TargetRole) { |
| 89 | + return (New-Denial "The role '$TargetRole' cannot be assigned to an API client.") |
| 90 | + } |
| 91 | + |
| 92 | + # Confirm the role exists. 'cipp-api' is an implicit runtime fallback and may |
| 93 | + # legitimately not be present in the CustomRoles table, so it is exempt. |
| 94 | + if ($DefaultRoles -notcontains $TargetRole -and $TargetRole -ne 'cipp-api') { |
| 95 | + try { |
| 96 | + $null = Get-CIPPRolePermissions -RoleName $TargetRole |
| 97 | + } catch { |
| 98 | + return (New-Denial "The role '$TargetRole' does not exist.") |
| 99 | + } |
| 100 | + } |
| 101 | + |
| 102 | + # Effective permissions a client holding this role would receive, computed the |
| 103 | + # same way Test-CIPPAccess evaluates an API client (single role, no base ceiling). |
| 104 | + $RolePermissions = @(Get-CippAllowedPermissions -UserRoles @($TargetRole)) |
| 105 | + $Escalation = @($RolePermissions | Where-Object { $CallerPermissions -notcontains $_ }) |
| 106 | + |
| 107 | + if ($Escalation.Count -gt 0) { |
| 108 | + return (New-Denial "You do not have sufficient permissions to manage an API client with the '$TargetRole' role; it grants permissions beyond your own.") |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + return [pscustomobject]@{ Allowed = $true; Message = $null } |
| 113 | +} |
0 commit comments