Thursday, December 18, 2025

Assign Teams with Roles of specific BU using Power shell script

 


<#

.SYNOPSIS

The latest cross BU role mapping feature helps easily mange the cross-bu role mapping, However, there's no tool for bulk cross-bu role assignment, with currently available tools mapping directly to the User's/team's BU. This script associates the users/teams with the roles of the specified BU.

  Assign Security Roles to Teams in Dataverse and relate the Field Security (Column Security) Profile

  "Field Service Resource" to those teams. PowerShell 5.1 compatible; robust paging; safe OData URLs.

  - Uses OAuth2 client credentials (ClientId/ClientSecret/TenantId) if AccessToken not provided.

  - Prefetches ALL Teams, Roles (with BU GUID), Business Units, and Field Security Profiles.

  - Associates roles via teamroles_association/$ref (fallback to teamroles/$ref).

  - Associates Field Security Profile via teamprofiles_association/$ref.

#>


param(

  [Parameter(Mandatory=$true)]

  [string]$url,  # e.g., https://yourorg.crm6.dynamics.com


  # Option A: Pre-supplied token (if omitted, client credentials are used)

  [string]$AccessToken,


  # Option B: Client credentials (used if AccessToken is empty)

  [Parameter(Mandatory=$true)]

  [string]$applicationId,

  [Parameter(Mandatory=$true)]

  [string]$clientSecret,

  [Parameter(Mandatory=$true)]

  [string]$tenantId

)


# -------------------------- CONFIG --------------------------

# Field Security (Column Security) Profile to relate to each team in TeamRoleMap

$FieldSecurityProfileName = "Field Service - Resource"

# ------------------------------------------------------------

# -------- Install --------

try {

  Install-Module Microsoft.Xrm.Data.PowerShell -RequiredVersion "2.8.21" -Force -Scope CurrentUser -AllowClobber

} catch { }

# -------- Install --------

# -------- Token acquisition (client credentials if needed) --------

function Get-AccessTokenWithClientCredentials([string]$tenantId, [string]$applicationId, [string]$clientSecret, [string]$url) {

  if ([string]::IsNullOrWhiteSpace($tenantId) -or [string]::IsNullOrWhiteSpace($applicationId) -or [string]::IsNullOrWhiteSpace($clientSecret)) {

    throw "Missing TenantId/ClientId/ClientSecret for client-credentials flow."

  }

  $tokenEndpoint = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"

  $body = @{

    grant_type    = "client_credentials"

    client_id     = $applicationId

    client_secret = $clientSecret

    scope         = "$url/.default"

  }

  try {

    $resp = Invoke-RestMethod -Method POST -Uri $tokenEndpoint -Body $body -ContentType "application/x-www-form-urlencoded"

    if (-not $resp.access_token) { throw "Token response missing access_token." }

    return $resp.access_token

  } catch {

    throw "Token acquisition failed: $($_.Exception.Message)"

  }

}


if ([string]::IsNullOrWhiteSpace($AccessToken)) {

  $AccessToken = Get-AccessTokenWithClientCredentials -tenantId $tenantId -applicationId $applicationId -clientSecret $clientSecret -url $url

}


# -------- Helpers --------

function Normalize([string]$s) {

  if ([string]::IsNullOrWhiteSpace($s)) { return "" }

  $t = $s.Trim()

  return $t 

}


# Standard headers for Dataverse Web API

$HeadersCommon = @{

  "Authorization"    = "Bearer $AccessToken"

  "Accept"           = "application/json"

  "OData-MaxVersion" = "4.0"

  "OData-Version"    = "4.0"

}

$JsonHeaders = $HeadersCommon.Clone()

$JsonHeaders["Content-Type"] = "application/json; charset=utf-8"


# --------Sample - TeamRoleMap (edit as needed) --------

$TeamRoleMap = @{

  "Operations" = @(

    @{ roleName = "Base Role";    buName = "Operations BU" }

    @{ roleName = "Base Role";    buName = "Finance BU" }

    @{ roleName = "Staff Role";   buName = "Operations BU" }

    @{ roleName = "Staff Role";   buName = "Finance BU" }

    @{ roleName = "Manager Role"; buName = "Finance BU" }

    @{ roleName = "Manager Role"; buName = "Operations BU" }

  )

  "Finance Team" = @(

    @{ roleName = "Base Role";  buName = "Operations BU" }

    @{ roleName = "Base Role";  buName = "Finance BU" }

    @{ roleName = "Staff Role"; buName = "Operations BU" }

    @{ roleName = "Staff Role"; buName = "Finance BU" }

  )

}


# -------- Paging helper (follows @odata.nextLink) --------

function Invoke-DvGetAll([string]$Uri) {

  $results = @()

  $next = $Uri

  while ($next) {

    try {

      $resp = Invoke-RestMethod -Method GET -Uri $next -Headers $HeadersCommon

    } catch {

      throw "GET failed for '$next': $($_.Exception.Message)"

    }

    if ($resp.value) { $results += $resp.value }

    $next = $resp.'@odata.nextLink'

  }

  return $results

}


# -------- Prefetch: Teams, Roles, BUs, Field Security Profiles --------

$teamsBase = "$url/api/data/v9.2/teams"

$rolesBase = "$url/api/data/v9.2/roles"

$busBase   = "$url/api/data/v9.2/businessunits"

$fspBase   = "$url/api/data/v9.2/fieldsecurityprofiles"  # entity set path (Web API ref)


$teams = Invoke-DvGetAll ($teamsBase + '?$select=teamid,name')

$roles = Invoke-DvGetAll ($rolesBase + '?$select=roleid,name,_businessunitid_value')

$businessUnits = Invoke-DvGetAll ($busBase + '?$select=businessunitid,name')

$fieldSecProfiles = Invoke-DvGetAll ($fspBase + '?$select=fieldsecurityprofileid,name')


# BU GUID -> Name map

$buMap = @{}

foreach ($b in $businessUnits) {

  if ($b.businessunitid) {

    $buName = ""

    if ($null -ne $b.name) { $buName = $b.name }

    $buMap[$b.businessunitid] = $buName

  }

}


# Team index: normalized name -> teamid

$teamIndex = @{}

foreach ($t in $teams) {

  $teamIndex[(Normalize $t.name)] = $t.teamid

}


# Role index keyed by "roleName\nbuName"

$roleIndex = @{}

foreach ($r in $roles) {

  $buName = ""

  if ($r._businessunitid_value -and $buMap.ContainsKey($r._businessunitid_value)) {

    $buName = $buMap[$r._businessunitid_value]

  }

  $key = "{0}`n{1}" -f (Normalize $r.name), (Normalize $buName)

  $roleIndex[$key] = $r.roleid

}


# Resolve Field Security Profile ID by name

$fspTarget = $null

$normFspName = Normalize $FieldSecurityProfileName

foreach ($fsp in $fieldSecProfiles) {

  if ((Normalize $fsp.name) -eq $normFspName) { $fspTarget = $fsp; break }

}

$fspId = $null

if ($fspTarget -and $fspTarget.fieldsecurityprofileid) {

  $fspId = [Guid]$fspTarget.fieldsecurityprofileid

  Write-Host ("Field Security Profile found: {0} ({1})" -f $FieldSecurityProfileName, $fspId)

} else {

  Write-Warning ("Field Security Profile not found: {0}. Teams will not be related to a profile." -f $FieldSecurityProfileName)

}


Write-Host ("Prefetched: teams={0}, roles={1}, BUs={2}, FSPs={3}" -f $teamIndex.Count, $roleIndex.Count, $buMap.Count, $fieldSecProfiles.Count)


# -------- Existing role assignments for a Team --------

function Get-TeamExistingRoleIds([Guid]$TeamId) {

  $set = New-Object System.Collections.Generic.HashSet[Guid]

  try {

    $url1 = "$url/api/data/v9.2/teams($TeamId)/teamroles_association" + '?$select=roleid,name'

    $rolesForTeam1 = Invoke-DvGetAll $url1

    if ($rolesForTeam1) { foreach ($rr in $rolesForTeam1) { if ($rr.roleid) { [void]$set.Add([Guid]$rr.roleid) } } }

    return $set

  } catch { Write-Warning ("teamroles_association fetch failed for TeamId {0}: {1}" -f $TeamId, $_.Exception.Message) }

  try {

    $url2 = "$url/api/data/v9.2/teams($TeamId)/teamroles" + '?$select=roleid,name'

    $rolesForTeam2 = Invoke-DvGetAll $url2

    if ($rolesForTeam2) { foreach ($rr in $rolesForTeam2) { if ($rr.roleid) { [void]$set.Add([Guid]$rr.roleid) } } }

  } catch { Write-Warning ("teamroles fetch failed for TeamId {0}: {1}" -f $TeamId, $_.Exception.Message) }

  return $set

}


# -------- Existing Field Security Profiles for a Team --------

function Get-TeamExistingFspIds([Guid]$TeamId) {

  $set = New-Object System.Collections.Generic.HashSet[Guid]

  try {

    # M:N nav on Team: teamprofiles_association  (Web API ref)

    $requestUrl = "$url/api/data/v9.2/teams($TeamId)/teamprofiles_association" + '?$select=fieldsecurityprofileid,name'

    $fsps = Invoke-DvGetAll $requestUrl

    if ($fsps) { foreach ($p in $fsps) { if ($p.fieldsecurityprofileid) { [void]$set.Add([Guid]$p.fieldsecurityprofileid) } } }

  } catch { Write-Warning ("teamprofiles_association fetch failed for TeamId {0}: {1}" -f $TeamId, $_.Exception.Message) }

  return $set

}


# -------- Associate a Role to a Team (POST $ref; fallback nav property) --------

function Associate-RoleTo-Team([Guid]$TeamId, [Guid]$RoleId) {

  $roleRef = "$url/api/data/v9.2/roles($RoleId)"

  try {

    $url1 = "$url/api/data/v9.2/teams($TeamId)/teamroles_association/`$ref"

    $bodyJson = @{ "@odata.id" = $roleRef } | ConvertTo-Json -Depth 1

    Invoke-RestMethod -Method POST -Uri $url1 -Headers $JsonHeaders -Body $bodyJson -ErrorAction Stop | Out-Null

    return

  } catch { Write-Warning ("Associate via teamroles_association failed: {0}" -f $_.Exception.Message) }

  try {

    $url2 = "$url/api/data/v9.2/teams($TeamId)/teamroles/`$ref"

    $bodyJson2 = @{ "@odata.id" = $roleRef } | ConvertTo-Json -Depth 1

    Invoke-RestMethod -Method POST -Uri $url2 -Headers $JsonHeaders -Body $bodyJson2 -ErrorAction Stop | Out-Null

    return

  } catch { throw "Associate via teamroles failed: $($_.Exception.Message)" }

}


# -------- Associate Field Security Profile to a Team (POST $ref) --------

function Associate-Fsp-To-Team([Guid]$TeamId, [Guid]$FspId) {

  if ($null -eq $FspId) { throw "FSP Id is null; cannot relate." }

  $fspRef = "$url/api/data/v9.2/fieldsecurityprofiles($FspId)"

  # Collection-valued nav associate (Web API associate doc)

  $requestUrl = "$url/api/data/v9.2/teams($TeamId)/teamprofiles_association/`$ref"

  $bodyJson = @{ "@odata.id" = $fspRef } | ConvertTo-Json -Depth 1

  Invoke-RestMethod -Method POST -Uri $requestUrl -Headers $JsonHeaders -Body $bodyJson -ErrorAction Stop | Out-Null

}


# -------- Drive assignment --------

$stats = [ordered]@{

  Teams = 0; AssignedRoles = 0; SkippedRoles = 0; RoleNotFound = 0; RoleFailures = 0;

  FspAssigned = 0; FspSkipped = 0; FspNotFound = 0; FspFailures = 0

}

$start = [datetime]::Now


foreach ($teamName in $TeamRoleMap.Keys) {

  $stats.Teams++

  $normTeam = Normalize $teamName

  $teamId = $teamIndex[$normTeam]


  Write-Host ""

  Write-Host ("-- {0} --" -f $teamName)


  if (-not $teamId) {

    $stats.RoleNotFound++

    Write-Warning ("Team not found: {0}" -f $teamName)

    continue

  }


  # Existing associations

  $existingRoles = Get-TeamExistingRoleIds -TeamId $teamId

  # Normalize roles to HashSet[Guid] (belt & suspenders)

  if ($existingRoles -isnot [System.Collections.Generic.HashSet[Guid]]) {

    $tmpR = New-Object System.Collections.Generic.HashSet[Guid]

    if ($null -ne $existingRoles) {

      if ($existingRoles -is [Guid]) { [void]$tmpR.Add([Guid]$existingRoles) }

      else { foreach ($g in $existingRoles) { try { [void]$tmpR.Add([Guid]$g) } catch { } } }

    }

    $existingRoles = $tmpR

  }


  $existingFsps = Get-TeamExistingFspIds -TeamId $teamId

  # Normalize FSPs to HashSet[Guid] (fixes Guid .Contains error)

  if ($existingFsps -isnot [System.Collections.Generic.HashSet[Guid]]) {

    $tmpF = New-Object System.Collections.Generic.HashSet[Guid]

    if ($null -ne $existingFsps) {

      if ($existingFsps -is [Guid]) { [void]$tmpF.Add([Guid]$existingFsps) }

      else { foreach ($g in $existingFsps) { try { [void]$tmpF.Add([Guid]$g) } catch { } } }

    }

    $existingFsps = $tmpF

  }


  # ---- Roles for this team ----

  $seen = New-Object System.Collections.Generic.HashSet[string]

  $desired = @()

  foreach ($entry in $TeamRoleMap[$teamName]) {

    $key = "{0}`n{1}" -f (Normalize $entry.roleName), (Normalize $entry.buName)

    if (-not $seen.Add($key)) { continue }

    $desired += [ordered]@{ roleName = $entry.roleName; buName = $entry.buName; key = $key }

  }


  foreach ($d in $desired) {

    $roleId = $roleIndex[$d.key]

    if (-not $roleId) {

      $stats.RoleNotFound++

      Write-Warning ("Role not found: '{0}' ({1})" -f $d.roleName, $d.buName)

      continue

    }

    $guidOk = $false; try { $null = [Guid]$roleId; $guidOk = $true } catch { $guidOk = $false }

    if (-not $guidOk) {

      $stats.RoleFailures++; Write-Warning ("Invalid roleId for '{0}' ({1}): '{2}'" -f $d.roleName, $d.buName, $roleId); continue

    }

    if ($existingRoles.Contains([Guid]$roleId)) {

      $stats.SkippedRoles++; Write-Host ("Skip (role already on team): '{0}' ({1})" -f $d.roleName, $d.buName); continue

    }

    try {

      Associate-RoleTo-Team -TeamId $teamId -RoleId ([Guid]$roleId)

      [void]$existingRoles.Add([Guid]$roleId)

      $stats.AssignedRoles++; Write-Host ("Role OK: '{0}' ({1})" -f $d.roleName, $d.buName)

    } catch {

      $stats.RoleFailures++; Write-Warning ("Role FAIL: '{0}' ({1}) - {2}" -f $d.roleName, $d.buName, $_.Exception.Message)

    }

  }


  # ---- Field Security Profile for this team ----

  if ($null -eq $fspId) {

    $stats.FspNotFound++

    Write-Warning ("Skip FSP relate: profile not found: {0}" -f $FieldSecurityProfileName)

  } else {

    if ($existingFsps.Contains([Guid]$fspId)) {

      $stats.FspSkipped++; Write-Host ("FSP Skip (already related): '{0}'" -f $FieldSecurityProfileName)

    } else {

      try {

        Associate-Fsp-To-Team -TeamId $teamId -FspId $fspId

        [void]$existingFsps.Add([Guid]$fspId)

        $stats.FspAssigned++; Write-Host ("FSP OK: '{0}'" -f $FieldSecurityProfileName)

      } catch {

        $stats.FspFailures++; Write-Warning ("FSP FAIL: '{0}' - {1}" -f $FieldSecurityProfileName, $_.Exception.Message)

      }

    }

  }

}


# ---- Elapsed time (as requested) ----

$secs = ([datetime]::Now - $start).TotalSeconds

Write-Host ""

Write-Host ("Done. Teams={0} Roles: Assigned={1} Skipped={2} NotFound={3} Failures={4} | FSP: Assigned={5} Skipped={6} NotFound={7} Failures={8} Time={9}s" -f `

  $stats.Teams, $stats.AssignedRoles, $stats.SkippedRoles, $stats.RoleNotFound, $stats.RoleFailures,

  $stats.FspAssigned, $stats.FspSkipped, $stats.FspNotFound, $stats.FspFailures, $secs)


No comments:

Post a Comment