AzureDevOpsDscv3.psm1
|
enum Ensure { Present Absent } enum SourceControl { Git Tfvc } enum RequiredAction { None Get New Set Remove Test Error } enum ProjectPermissionLevel { BuildAdministrators Contributors ProjectAdministrators ProjectValidUsers Readers ReleaseAdministrators } enum OrgPermissionLevel { ProjectCollectionAdministrators ProjectCollectionBuildAdministrators ProjectCollectionBuildServiceAccounts ProjectCollectionProxyServiceAccounts ProjectCollectionServiceAccounts ProjectCollectionTestServiceAccounts ProjectCollectionValidUsers ProjectScopedUsers SecurityServiceGroup } <# .SYNOPSIS Manages Azure DevOps projects for an organization. .PARAMETER ProjectName Azure DevOps project name. .PARAMETER Description Optional project description. .PARAMETER SourceControlType Source control type for the project (Git or Tfvc). .PARAMETER Organization Azure DevOps organization name. .PARAMETER Ensure Desired state: Present or Absent. .PARAMETER pat Personal access token used for authentication. .PARAMETER templateTypeId Process template type ID used when creating a project. .PARAMETER apiVersion Azure DevOps REST API version. #> [DscResource()] class ProjectResource { [DscProperty(Key)] [string]$ProjectName [DscProperty()] [string]$Description [DscProperty()] [string]$SourceControlType = [SourceControl]::Git [DscProperty(Mandatory)] [string]$Organization [DscProperty()] [Ensure]$Ensure = [Ensure]::Present [DscProperty(Mandatory)] [Alias('Token','PersonalAccessToken')] [System.String] $pat [DscProperty()] [string]$templateTypeId = "adcc42ab-9882-485e-a3ed-7678f01f66bc" [DscProperty()] [string]$apiVersion = "7.1" hidden [string] GetTokenValue() { if ($this.pat -is [hashtable]) { $keyNames = @("value", "secureString", "Token", "PersonalAccessToken", "pat", "_value") foreach ($key in $keyNames) { if ($this.pat.ContainsKey($key)) { return $this.pat[$key].ToString() } } $firstKey = $this.pat.Keys | Select-Object -First 1 if ($firstKey) { return $this.pat[$firstKey].ToString() } } return $this.pat.ToString() } hidden [object] CallApi([string]$Method, [string]$UriSuffix, [string]$Body) { [string]$StringToken = $this.GetTokenValue() $Base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$StringToken")) $BaseUrl = "https://dev.azure.com/$($this.Organization)/_apis" $Uri = $BaseUrl + "/projects" + $UriSuffix + "?api-version=$($this.apiVersion)" $Headers = @{ Authorization = "Basic $Base64AuthInfo" "Content-Type" = "application/json" } if ($Method -eq "PATCH") { $Headers["Content-Type"] = "application/json-patch+json" } if ($Method -eq "PATCH") { $Headers["Content-Type"] = "application/json-patch+json" } $Params = @{ Uri = $Uri Method = $Method Headers = $Headers ErrorAction = "Stop" } if ($Body) { $Params.Body = $Body } try { return Invoke-RestMethod @Params } catch { $errorMsg = $_ Write-Error "ProjectResource.CallApi error: $errorMsg" if ($_.Exception.Response) { $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream()) $reader.BaseStream.Position = 0 $reader.DiscardBufferedData() $responseBody = $reader.ReadToEnd() Write-Error "ProjectResource API Response Body: $responseBody" } throw } } [bool] Test() { try { $EncodedName = [uri]::EscapeDataString($this.ProjectName) $Project = $this.CallApi("GET", "/$EncodedName", $null) $Exists = $null -ne $Project if ($this.Ensure -eq [Ensure]::Present) { return $Exists } else { return !$Exists } } catch { return $this.Ensure -eq [Ensure]::Absent } } [void] Set() { if ($this.Ensure -eq [Ensure]::Present) { # Check if project already exists try { $EncodedName = [uri]::EscapeDataString($this.ProjectName) $Project = $this.CallApi("GET", "/$EncodedName", $null) if ($Project) { return } } catch { # Project doesn't exist, continue with creation } $Payload = @{ name = $this.ProjectName capabilities = @{ versioncontrol = @{ sourceControlType = $this.SourceControlType } processTemplate = @{ templateTypeId = $this.templateTypeId } } } if (![string]::IsNullOrEmpty($this.Description)) { $Payload.description = $this.Description } $JsonPayload = $Payload | ConvertTo-Json -Depth 10 $this.CallApi("POST", "", $JsonPayload) } else { Write-Host "DEBUG: Deleting project: $($this.ProjectName)" $EncodedName = [uri]::EscapeDataString($this.ProjectName) try { $Project = $this.CallApi("GET", "/$EncodedName", $null) if ($Project) { $ProjectId = $Project.id $this.CallApi("DELETE", "/$ProjectId", $null) } } catch { # Project not found; nothing to delete } } } [ProjectResource] Get() { try { $EncodedName = [uri]::EscapeDataString($this.ProjectName) $Project = $this.CallApi("GET", "/$EncodedName", $null) return [ProjectResource]@{ ProjectName = $Project.name Description = $Project.description SourceControlType = $Project.capabilities.versioncontrol.sourceControlType Ensure = [Ensure]::Present } } catch { return [ProjectResource]@{ ProjectName = $this.ProjectName Ensure = [Ensure]::Absent } } } } <# .SYNOPSIS Manages Azure DevOps user entitlements for an organization. .PARAMETER UserPrincipalName User principal name (email) to manage entitlements for. .PARAMETER Organization Azure DevOps organization name. .PARAMETER AccessLevel Access level: Stakeholder, Basic, or BasicPlusTestPlans. .PARAMETER Ensure Desired state: Present or Absent. .PARAMETER pat Personal access token used for authentication. .PARAMETER apiVersion Azure DevOps REST API version. #> [DscResource()] class OrganizationUserResource { [DscProperty(Key)] [string]$UserPrincipalName [DscProperty(Mandatory)] [string]$Organization [DscProperty()] [string]$AccessLevel = "Stakeholder" [DscProperty()] [Ensure]$Ensure = [Ensure]::Present [DscProperty(Mandatory)] [Alias('Token','PersonalAccessToken')] [string]$pat [DscProperty()] [string]$apiVersion = "7.1-preview.1" hidden [string] GetTokenValue() { if ($this.pat -is [hashtable]) { $keyNames = @("value", "secureString", "Token", "PersonalAccessToken", "pat", "_value") foreach ($key in $keyNames) { if ($this.pat.ContainsKey($key)) { return $this.pat[$key].ToString() } } $firstKey = $this.pat.Keys | Select-Object -First 1 if ($firstKey) { return $this.pat[$firstKey].ToString() } } return $this.pat.ToString() } hidden [string] GetOrganizationValue() { if ($this.Organization -is [hashtable]) { $firstValue = $this.Organization.Values | Select-Object -First 1 if ($firstValue) { return $firstValue.ToString() } } return $this.Organization } hidden [object] CallApi([string]$Method, [string]$UriSuffix, [string]$Body) { [string]$StringToken = $this.GetTokenValue() [string]$OrgName = $this.GetOrganizationValue() if ([string]::IsNullOrWhiteSpace($StringToken)) { throw "Token is null or empty" } if ([string]::IsNullOrWhiteSpace($OrgName)) { throw "Organization is null or empty" } $Base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$StringToken")) $BaseUrl = "https://vsaex.dev.azure.com/$OrgName/_apis" $Uri = $BaseUrl + $UriSuffix + "?api-version=$($this.apiVersion)" $Headers = @{ Authorization = "Basic $Base64AuthInfo" "Content-Type" = "application/json" } if ($Method -eq "PATCH") { $Headers["Content-Type"] = "application/json-patch+json" } $Params = @{ Uri = $Uri Method = $Method Headers = $Headers ErrorAction = "Stop" } if ($Body) { $Params.Body = $Body } try { return Invoke-RestMethod @Params } catch { $errorMsg = $_ Write-Error "OrganizationUserResource.CallApi error: $errorMsg" if ($_.Exception.Response) { $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream()) $reader.BaseStream.Position = 0 $reader.DiscardBufferedData() $responseBody = $reader.ReadToEnd() Write-Error "OrganizationUserResource API Response Body: $responseBody" } throw } } hidden [string] GetAccountLicenseType() { $result = switch ($this.AccessLevel) { "Stakeholder" { "stakeholder" } "Basic" { "express" } "BasicPlusTestPlans" { "advanced" } default { "stakeholder" } } return $result } [bool] Test() { try { Write-Verbose "Test() - Checking user: $($this.UserPrincipalName)" $users = $this.CallApi("GET", "/userentitlements", $null) if ($null -eq $users -or $null -eq $users.value) { Write-Verbose "Test() - No users returned from API" return $this.Ensure -eq [Ensure]::Absent } $existingUser = $users.value | Where-Object { $_.user.principalName -eq $this.UserPrincipalName } if ($this.Ensure -eq [Ensure]::Present) { if ($existingUser) { $currentLicense = $existingUser.accessLevel.accountLicenseType $desiredLicense = $this.GetAccountLicenseType() $match = $currentLicense -eq $desiredLicense Write-Verbose "Test() - User exists with license: $currentLicense, desired: $desiredLicense, match: $match" return $match } Write-Verbose "Test() - User does not exist" return $false } else { $exists = $null -ne $existingUser Write-Verbose "Test() - Ensure=Absent, user exists: $exists" return !$exists } } catch { Write-Error "Test() - Error: $_" return $this.Ensure -eq [Ensure]::Absent } } [void] Set() { try { if ($this.Ensure -eq [Ensure]::Present) { # Check if user already exists and matches desired license $users = $this.CallApi("GET", "/userentitlements", $null) $existingUser = $users.value | Where-Object { $_.user.principalName -eq $this.UserPrincipalName } if ($null -eq $existingUser) { $Payload = @{ accessLevel = @{ accountLicenseType = $this.GetAccountLicenseType() licensingSource = "account" } user = @{ principalName = $this.UserPrincipalName subjectKind = "user" } projectEntitlements = @() } $JsonPayload = $Payload | ConvertTo-Json -Depth 10 $response = $this.CallApi("POST", "/userentitlements", $JsonPayload) } else { $userId = $existingUser.id $Payload = @( @{ op = "replace" path = "/accessLevel" value = @{ accountLicenseType = $this.GetAccountLicenseType() licensingSource = "account" } } ) $JsonPayload = ConvertTo-Json -Depth 10 -InputObject $Payload $response = $this.CallApi("PATCH", "/userentitlements/$userId", $JsonPayload) } } else { $users = $this.CallApi("GET", "/userentitlements", $null) $existingUser = $users.value | Where-Object { $_.user.principalName -eq $this.UserPrincipalName } if ($existingUser) { $userId = $existingUser.id $this.CallApi("DELETE", "/userentitlements/$userId", $null) } } } catch { Write-Error "Set() - Error: $_" throw } } [OrganizationUserResource] Get() { try { Write-Verbose "Get() - Retrieving user: $($this.UserPrincipalName)" $users = $this.CallApi("GET", "/userentitlements", $null) if ($null -eq $users) { Write-Verbose "Get() - No users found" $result = [OrganizationUserResource]::new() $result.UserPrincipalName = $this.UserPrincipalName $result.Organization = $this.GetOrganizationValue() $result.Ensure = [Ensure]::Absent $result.pat = $this.pat $result.apiVersion = $this.apiVersion return $result } $existingUser = $users.value | Where-Object { $_.user.principalName -eq $this.UserPrincipalName } if ($existingUser) { Write-Verbose "Get() - User found" $licenseType = $existingUser.accessLevel.accountLicenseType $userAccessLevel = switch ($licenseType) { "stakeholder" { "Stakeholder" } "express" { "Basic" } "advanced" { "BasicPlusTestPlans" } default { "Stakeholder" } } $result = [OrganizationUserResource]::new() $result.UserPrincipalName = $existingUser.user.principalName $result.Organization = $this.GetOrganizationValue() $result.AccessLevel = $userAccessLevel $result.Ensure = [Ensure]::Present $result.pat = $this.pat $result.apiVersion = $this.apiVersion return $result } else { Write-Verbose "Get() - User not found" $result = [OrganizationUserResource]::new() $result.UserPrincipalName = $this.UserPrincipalName $result.Organization = $this.GetOrganizationValue() $result.AccessLevel = $this.AccessLevel $result.Ensure = [Ensure]::Absent $result.pat = $this.pat $result.apiVersion = $this.apiVersion return $result } } catch { Write-Error "Get() - Error: $_" $result = [OrganizationUserResource]::new() $result.UserPrincipalName = $this.UserPrincipalName $result.Organization = $this.GetOrganizationValue() $result.Ensure = [Ensure]::Absent $result.pat = $this.pat $result.apiVersion = $this.apiVersion return $result } } } <# .SYNOPSIS Manages Azure DevOps group entitlements for an organization. .PARAMETER GroupOriginId Origin ID of the group in Azure DevOps. .PARAMETER GroupDisplayName Optional display name for the group. .PARAMETER Organization Azure DevOps organization name. .PARAMETER AccessLevel Access level: Stakeholder, Basic, or BasicPlusTestPlans. .PARAMETER Ensure Desired state: Present or Absent. .PARAMETER pat Personal access token used for authentication. .PARAMETER apiVersion Azure DevOps REST API version. #> [DscResource()] class OrganizationGroupResource { [DscProperty(Key)] [string]$GroupOriginId [DscProperty()] [string]$GroupDisplayName [DscProperty(Mandatory)] [string]$Organization [DscProperty()] [string]$AccessLevel = "Stakeholder" [DscProperty()] [Ensure]$Ensure = [Ensure]::Present [DscProperty(Mandatory)] [Alias('Token','PersonalAccessToken')] [string]$pat [DscProperty()] [string]$apiVersion = "7.1-preview.1" hidden [string] GetTokenValue() { if ($this.pat -is [hashtable]) { $keyNames = @("value", "secureString", "Token", "PersonalAccessToken", "pat", "_value") foreach ($key in $keyNames) { if ($this.pat.ContainsKey($key)) { return $this.pat[$key].ToString() } } $firstKey = $this.pat.Keys | Select-Object -First 1 if ($firstKey) { return $this.pat[$firstKey].ToString() } } return $this.pat.ToString() } hidden [string] GetOrganizationValue() { if ($this.Organization -is [hashtable]) { $firstValue = $this.Organization.Values | Select-Object -First 1 if ($firstValue) { return $firstValue.ToString() } } return $this.Organization } hidden [object] CallApi([string]$Method, [string]$UriSuffix, [string]$Body) { [string]$StringToken = $this.GetTokenValue() [string]$OrgName = $this.GetOrganizationValue() if ([string]::IsNullOrWhiteSpace($StringToken)) { throw "Token is null or empty" } if ([string]::IsNullOrWhiteSpace($OrgName)) { throw "Organization is null or empty" } $Base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$StringToken")) $BaseUrl = "https://vsaex.dev.azure.com/$OrgName/_apis" $Uri = $BaseUrl + $UriSuffix + "?api-version=$($this.apiVersion)" $Headers = @{ Authorization = "Basic $Base64AuthInfo" "Content-Type" = "application/json" } if ($Method -eq "PATCH") { $Headers["Content-Type"] = "application/json-patch+json" } $Params = @{ Uri = $Uri Method = $Method Headers = $Headers ErrorAction = "Stop" } if ($Body) { $Params.Body = $Body } try { return Invoke-RestMethod @Params } catch { $errorMsg = $_ Write-Error "OrganizationGroupResource.CallApi error: $errorMsg" if ($_.Exception.Response) { $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream()) $reader.BaseStream.Position = 0 $reader.DiscardBufferedData() $responseBody = $reader.ReadToEnd() Write-Error "OrganizationGroupResource API Response Body: $responseBody" } throw } } hidden [string] GetAccountLicenseType() { $result = switch ($this.AccessLevel) { "Stakeholder" { "stakeholder" } "Basic" { "express" } "BasicPlusTestPlans" { "advanced" } default { "stakeholder" } } return $result } [bool] Test() { try { Write-Verbose "Test() - Checking group entitlement: $($this.GroupOriginId)" $groups = $this.CallApi("GET", "/groupentitlements", $null) if ($null -eq $groups -or $null -eq $groups.value) { Write-Verbose "Test() - No group entitlements returned from API" return $this.Ensure -eq [Ensure]::Absent } $existingGroup = $groups.value | Where-Object { $_.group.originId -eq $this.GroupOriginId } if ($this.Ensure -eq [Ensure]::Present) { if ($existingGroup) { $currentLicense = $existingGroup.licenseRule.accountLicenseType $desiredLicense = $this.GetAccountLicenseType() $match = $currentLicense -eq $desiredLicense Write-Verbose "Test() - Group exists with license: $currentLicense, desired: $desiredLicense, match: $match" return $match } Write-Verbose "Test() - Group entitlement does not exist" return $false } else { $exists = $null -ne $existingGroup Write-Verbose "Test() - Ensure=Absent, group exists: $exists" return !$exists } } catch { Write-Error "Test() - Error: $_" return $this.Ensure -eq [Ensure]::Absent } } [void] Set() { try { if ($this.Ensure -eq [Ensure]::Present) { $groups = $this.CallApi("GET", "/groupentitlements", $null) $existingGroup = $groups.value | Where-Object { $_.group.originId -eq $this.GroupOriginId } if ($null -eq $existingGroup) { $Payload = @{ group = @{ originId = $this.GroupOriginId subjectKind = "group" } licenseRule = @{ accountLicenseType = $this.GetAccountLicenseType() licensingSource = "account" } } if (![string]::IsNullOrWhiteSpace($this.GroupDisplayName)) { $Payload.group.displayName = $this.GroupDisplayName } $JsonPayload = $Payload | ConvertTo-Json -Depth 10 $response = $this.CallApi("POST", "/groupentitlements", $JsonPayload) } else { $groupId = $existingGroup.id $Payload = @( @{ op = "replace" path = "/licenseRule/accountLicenseType" value = $this.GetAccountLicenseType() } @{ op = "replace" path = "/licenseRule/licensingSource" value = "account" } ) $JsonPayload = ConvertTo-Json -Depth 10 -InputObject $Payload $response = $this.CallApi("PATCH", "/groupentitlements/$groupId", $JsonPayload) } } else { $groups = $this.CallApi("GET", "/groupentitlements", $null) $existingGroup = $groups.value | Where-Object { $_.group.originId -eq $this.GroupOriginId } if ($existingGroup) { $groupId = $existingGroup.id $this.CallApi("DELETE", "/groupentitlements/$groupId", $null) } } } catch { Write-Error "Set() - Error: $_" throw } } [OrganizationGroupResource] Get() { try { Write-Verbose "Get() - Retrieving group entitlement: $($this.GroupOriginId)" $groups = $this.CallApi("GET", "/groupentitlements", $null) if ($null -eq $groups) { Write-Verbose "Get() - No group entitlements found" $result = [OrganizationGroupResource]::new() $result.GroupOriginId = $this.GroupOriginId $result.Organization = $this.GetOrganizationValue() $result.Ensure = [Ensure]::Absent $result.pat = $this.pat $result.apiVersion = $this.apiVersion return $result } $existingGroup = $groups.value | Where-Object { $_.group.originId -eq $this.GroupOriginId } if ($existingGroup) { Write-Verbose "Get() - Group entitlement found" $licenseType = $existingGroup.licenseRule.accountLicenseType $groupAccessLevel = switch ($licenseType) { "stakeholder" { "Stakeholder" } "express" { "Basic" } "advanced" { "BasicPlusTestPlans" } default { "Stakeholder" } } $result = [OrganizationGroupResource]::new() $result.GroupOriginId = $existingGroup.group.originId $result.GroupDisplayName = $existingGroup.group.displayName $result.Organization = $this.GetOrganizationValue() $result.AccessLevel = $groupAccessLevel $result.Ensure = [Ensure]::Present $result.pat = $this.pat $result.apiVersion = $this.apiVersion return $result } else { Write-Verbose "Get() - Group entitlement not found" $result = [OrganizationGroupResource]::new() $result.GroupOriginId = $this.GroupOriginId $result.GroupDisplayName = $this.GroupDisplayName $result.Organization = $this.GetOrganizationValue() $result.AccessLevel = $this.AccessLevel $result.Ensure = [Ensure]::Absent $result.pat = $this.pat $result.apiVersion = $this.apiVersion return $result } } catch { Write-Error "Get() - Error: $_" $result = [OrganizationGroupResource]::new() $result.GroupOriginId = $this.GroupOriginId $result.GroupDisplayName = $this.GroupDisplayName $result.Organization = $this.GetOrganizationValue() $result.Ensure = [Ensure]::Absent $result.pat = $this.pat $result.apiVersion = $this.apiVersion return $result } } } <# .SYNOPSIS Manages Azure DevOps project-level user membership (permissions). .DESCRIPTION Adds or removes a user (by UPN) to/from a project-level security group such as Build Administrators, Contributors, Project Administrators, Project Valid Users, Readers, or Release Administrators. Uses the Azure DevOps Graph REST API. .PARAMETER ProjectName Azure DevOps project name. .PARAMETER UserPrincipalName User principal name (email) to add or remove from the project group. .PARAMETER PermissionLevel The project-level group to manage: BuildAdministrators, Contributors, ProjectAdministrators, ProjectValidUsers, Readers, or ReleaseAdministrators. .PARAMETER Organization Azure DevOps organization name. .PARAMETER Ensure Desired state: Present (add membership) or Absent (remove membership). .PARAMETER pat Personal access token used for authentication. .PARAMETER apiVersion Azure DevOps REST API version for the Graph endpoints. #> [DscResource()] class ProjectUserPermissionResource { [DscProperty(Key)] [string]$ProjectName [DscProperty(Key)] [string]$UserPrincipalName [DscProperty(Mandatory)] [ProjectPermissionLevel]$PermissionLevel [DscProperty(Mandatory)] [string]$Organization [DscProperty()] [Ensure]$Ensure = [Ensure]::Present [DscProperty(Mandatory)] [Alias('Token','PersonalAccessToken')] [string]$pat [DscProperty()] [string]$apiVersion = "7.1-preview.1" hidden [string] GetTokenValue() { if ($this.pat -is [hashtable]) { $keyNames = @("value", "secureString", "Token", "PersonalAccessToken", "pat", "_value") foreach ($key in $keyNames) { if ($this.pat.ContainsKey($key)) { return $this.pat[$key].ToString() } } $firstKey = $this.pat.Keys | Select-Object -First 1 if ($firstKey) { return $this.pat[$firstKey].ToString() } } return $this.pat.ToString() } hidden [string] GetOrganizationValue() { if ($this.Organization -is [hashtable]) { $firstValue = $this.Organization.Values | Select-Object -First 1 if ($firstValue) { return $firstValue.ToString() } } return $this.Organization } # Call the main dev.azure.com REST API hidden [object] CallProjectApi([string]$Method, [string]$UriSuffix, [string]$Body) { [string]$StringToken = $this.GetTokenValue() [string]$OrgName = $this.GetOrganizationValue() if ([string]::IsNullOrWhiteSpace($StringToken)) { throw "Token is null or empty" } if ([string]::IsNullOrWhiteSpace($OrgName)) { throw "Organization is null or empty" } $Base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$StringToken")) $Uri = "https://dev.azure.com/$OrgName/_apis" + $UriSuffix $Headers = @{ Authorization = "Basic $Base64AuthInfo" "Content-Type" = "application/json" } $Params = @{ Uri = $Uri Method = $Method Headers = $Headers ErrorAction = "Stop" } if ($Body) { $Params.Body = $Body } try { return Invoke-RestMethod @Params } catch { Write-Error "ProjectPermissionResource.CallProjectApi error: $_" throw } } # Call the vssps.dev.azure.com Graph REST API hidden [object] CallGraphApi([string]$Method, [string]$UriSuffix, [string]$Body) { [string]$StringToken = $this.GetTokenValue() [string]$OrgName = $this.GetOrganizationValue() if ([string]::IsNullOrWhiteSpace($StringToken)) { throw "Token is null or empty" } if ([string]::IsNullOrWhiteSpace($OrgName)) { throw "Organization is null or empty" } $Base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$StringToken")) $Uri = "https://vssps.dev.azure.com/$OrgName/_apis/graph" + $UriSuffix $Headers = @{ Authorization = "Basic $Base64AuthInfo" "Content-Type" = "application/json" } $Params = @{ Uri = $Uri Method = $Method Headers = $Headers ErrorAction = "Stop" } if ($Body) { $Params.Body = $Body } try { return Invoke-RestMethod @Params } catch { Write-Error "ProjectPermissionResource.CallGraphApi error: $_" throw } } # Map enum value to the Azure DevOps display name for the project group hidden [string] GetGroupDisplayName() { $result = switch ($this.PermissionLevel) { 'BuildAdministrators' { "Build Administrators" } 'Contributors' { "Contributors" } 'ProjectAdministrators' { "Project Administrators" } 'ProjectValidUsers' { "Project Valid Users" } 'Readers' { "Readers" } 'ReleaseAdministrators' { "Release Administrators" } default { throw "Unknown PermissionLevel: $($this.PermissionLevel)" } } return $result } # Resolve the project ID from its name hidden [string] GetProjectId() { $EncodedName = [uri]::EscapeDataString($this.ProjectName) $project = $this.CallProjectApi("GET", "/projects/$($EncodedName)?api-version=7.1", $null) return $project.id } # Get the scope descriptor for a project (needed for Graph group queries) hidden [string] GetProjectScopeDescriptor([string]$ProjectId) { $response = $this.CallGraphApi("GET", "/descriptors/$($ProjectId)?api-version=$($this.apiVersion)", $null) return $response.value } # Find the target group descriptor within the project scope hidden [string] GetGroupDescriptor([string]$ScopeDescriptor) { $targetName = $this.GetGroupDisplayName() $groups = $this.CallGraphApi("GET", "/groups?scopeDescriptor=$ScopeDescriptor&api-version=$($this.apiVersion)", $null) if ($null -eq $groups -or $null -eq $groups.value) { throw "No groups found for project '$($this.ProjectName)'" } $group = $groups.value | Where-Object { $_.displayName -eq $targetName } if ($null -eq $group) { throw "Group '$targetName' not found in project '$($this.ProjectName)'" } return $group.descriptor } # Resolve a user's descriptor by UPN hidden [string] GetUserDescriptor() { $users = $this.CallGraphApi("GET", "/users?api-version=$($this.apiVersion)", $null) if ($null -eq $users -or $null -eq $users.value) { throw "No users returned from Graph API" } $user = $users.value | Where-Object { $_.principalName -eq $this.UserPrincipalName } if ($null -eq $user) { throw "User '$($this.UserPrincipalName)' not found in the organization" } return $user.descriptor } # Check if the user is already a member of the target group hidden [bool] IsMember([string]$UserDescriptor, [string]$GroupDescriptor) { [string]$OrgName = $this.GetOrganizationValue() [string]$Token = $this.GetTokenValue() $Base64Auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$Token")) try { $encodedUser = [uri]::EscapeDataString($UserDescriptor) $encodedGroup = [uri]::EscapeDataString($GroupDescriptor) $null = Invoke-RestMethod -Uri "https://vssps.dev.azure.com/$OrgName/_apis/graph/memberships/$encodedUser/$($encodedGroup)?api-version=$($this.apiVersion)" ` -Method GET ` -Headers @{ Authorization = "Basic $Base64Auth" } ` -ErrorAction Stop return $true } catch { return $false } } [bool] Test() { try { Write-Verbose "Test() - Checking project permission: $($this.UserPrincipalName) -> $($this.PermissionLevel) in $($this.ProjectName)" $projectId = $this.GetProjectId() $scopeDescriptor = $this.GetProjectScopeDescriptor($projectId) $groupDescriptor = $this.GetGroupDescriptor($scopeDescriptor) $userDescriptor = $this.GetUserDescriptor() $isMember = $this.IsMember($userDescriptor, $groupDescriptor) if ($this.Ensure -eq [Ensure]::Present) { return $isMember } else { return -not $isMember } } catch { Write-Error "Test() - Error: $_" return $this.Ensure -eq [Ensure]::Absent } } [void] Set() { try { Write-Verbose "Set() - Setting project permission: $($this.UserPrincipalName) -> $($this.PermissionLevel) in $($this.ProjectName), Ensure=$($this.Ensure)" $projectId = $this.GetProjectId() $scopeDescriptor = $this.GetProjectScopeDescriptor($projectId) $groupDescriptor = $this.GetGroupDescriptor($scopeDescriptor) $userDescriptor = $this.GetUserDescriptor() $encodedUser = [uri]::EscapeDataString($userDescriptor) $encodedGroup = [uri]::EscapeDataString($groupDescriptor) if ($this.Ensure -eq [Ensure]::Present) { $this.CallGraphApi("PUT", "/memberships/$encodedUser/$($encodedGroup)?api-version=$($this.apiVersion)", "") Write-Verbose "Set() - Added '$($this.UserPrincipalName)' to '$($this.GetGroupDisplayName())' in project '$($this.ProjectName)'" } else { $this.CallGraphApi("DELETE", "/memberships/$encodedUser/$($encodedGroup)?api-version=$($this.apiVersion)", $null) Write-Verbose "Set() - Removed '$($this.UserPrincipalName)' from '$($this.GetGroupDisplayName())' in project '$($this.ProjectName)'" } } catch { Write-Error "Set() - Error: $_" throw } } [ProjectUserPermissionResource] Get() { try { Write-Verbose "Get() - Retrieving project permission: $($this.UserPrincipalName) -> $($this.PermissionLevel) in $($this.ProjectName)" $projectId = $this.GetProjectId() $scopeDescriptor = $this.GetProjectScopeDescriptor($projectId) $groupDescriptor = $this.GetGroupDescriptor($scopeDescriptor) $userDescriptor = $this.GetUserDescriptor() $isMember = $this.IsMember($userDescriptor, $groupDescriptor) $result = [ProjectUserPermissionResource]::new() $result.ProjectName = $this.ProjectName $result.UserPrincipalName = $this.UserPrincipalName $result.PermissionLevel = $this.PermissionLevel $result.Organization = $this.GetOrganizationValue() $result.Ensure = if ($isMember) { [Ensure]::Present } else { [Ensure]::Absent } $result.pat = $this.pat $result.apiVersion = $this.apiVersion return $result } catch { Write-Error "Get() - Error: $_" $result = [ProjectUserPermissionResource]::new() $result.ProjectName = $this.ProjectName $result.UserPrincipalName = $this.UserPrincipalName $result.PermissionLevel = $this.PermissionLevel $result.Organization = $this.GetOrganizationValue() $result.Ensure = [Ensure]::Absent $result.pat = $this.pat $result.apiVersion = $this.apiVersion return $result } } } <# .SYNOPSIS Manages Azure DevOps project-level security group membership (permissions) for Entra groups. .DESCRIPTION Adds or removes an Entra (Azure AD) security group to/from a project-level security group such as Build Administrators, Contributors, Project Administrators, Project Valid Users, Readers, or Release Administrators. Uses the Azure DevOps Graph REST API. .PARAMETER ProjectName Azure DevOps project name. .PARAMETER GroupOriginId The Origin ID (Object ID) of the Entra security group to add or remove from the project group. .PARAMETER GroupDisplayName Optional display name of the Entra security group (for reference). .PARAMETER PermissionLevel The project-level group to manage: BuildAdministrators, Contributors, ProjectAdministrators, ProjectValidUsers, Readers, or ReleaseAdministrators. .PARAMETER Organization Azure DevOps organization name. .PARAMETER Ensure Desired state: Present (add membership) or Absent (remove membership). .PARAMETER pat Personal access token used for authentication. .PARAMETER apiVersion Azure DevOps REST API version for the Graph endpoints. #> [DscResource()] class ProjectGroupPermissionResource { [DscProperty(Key)] [string]$ProjectName [DscProperty(Key)] [string]$GroupOriginId [DscProperty()] [string]$GroupDisplayName [DscProperty(Mandatory)] [ProjectPermissionLevel]$PermissionLevel [DscProperty(Mandatory)] [string]$Organization [DscProperty()] [Ensure]$Ensure = [Ensure]::Present [DscProperty(Mandatory)] [Alias('Token','PersonalAccessToken')] [string]$pat [DscProperty()] [string]$apiVersion = "7.1-preview.1" hidden [string] GetTokenValue() { if ($this.pat -is [hashtable]) { $keyNames = @("value", "secureString", "Token", "PersonalAccessToken", "pat", "_value") foreach ($key in $keyNames) { if ($this.pat.ContainsKey($key)) { return $this.pat[$key].ToString() } } $firstKey = $this.pat.Keys | Select-Object -First 1 if ($firstKey) { return $this.pat[$firstKey].ToString() } } return $this.pat.ToString() } hidden [string] GetOrganizationValue() { if ($this.Organization -is [hashtable]) { $firstValue = $this.Organization.Values | Select-Object -First 1 if ($firstValue) { return $firstValue.ToString() } } return $this.Organization } # Call the main dev.azure.com REST API hidden [object] CallProjectApi([string]$Method, [string]$UriSuffix, [string]$Body) { [string]$StringToken = $this.GetTokenValue() [string]$OrgName = $this.GetOrganizationValue() if ([string]::IsNullOrWhiteSpace($StringToken)) { throw "Token is null or empty" } if ([string]::IsNullOrWhiteSpace($OrgName)) { throw "Organization is null or empty" } $Base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$StringToken")) $Uri = "https://dev.azure.com/$OrgName/_apis" + $UriSuffix $Headers = @{ Authorization = "Basic $Base64AuthInfo" "Content-Type" = "application/json" } $Params = @{ Uri = $Uri Method = $Method Headers = $Headers ErrorAction = "Stop" } if ($Body) { $Params.Body = $Body } try { return Invoke-RestMethod @Params } catch { Write-Error "ProjectGroupPermissionResource.CallProjectApi error: $_" throw } } # Call the vssps.dev.azure.com Graph REST API hidden [object] CallGraphApi([string]$Method, [string]$UriSuffix, [string]$Body) { [string]$StringToken = $this.GetTokenValue() [string]$OrgName = $this.GetOrganizationValue() if ([string]::IsNullOrWhiteSpace($StringToken)) { throw "Token is null or empty" } if ([string]::IsNullOrWhiteSpace($OrgName)) { throw "Organization is null or empty" } $Base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$StringToken")) $Uri = "https://vssps.dev.azure.com/$OrgName/_apis/graph" + $UriSuffix $Headers = @{ Authorization = "Basic $Base64AuthInfo" "Content-Type" = "application/json" } $Params = @{ Uri = $Uri Method = $Method Headers = $Headers ErrorAction = "Stop" } if ($Body) { $Params.Body = $Body } try { return Invoke-RestMethod @Params } catch { Write-Error "ProjectGroupPermissionResource.CallGraphApi error: $_" throw } } # Map enum value to the Azure DevOps display name for the project group hidden [string] GetGroupDisplayName() { $result = switch ($this.PermissionLevel) { 'BuildAdministrators' { "Build Administrators" } 'Contributors' { "Contributors" } 'ProjectAdministrators' { "Project Administrators" } 'ProjectValidUsers' { "Project Valid Users" } 'Readers' { "Readers" } 'ReleaseAdministrators' { "Release Administrators" } default { throw "Unknown PermissionLevel: $($this.PermissionLevel)" } } return $result } # Resolve the project ID from its name hidden [string] GetProjectId() { $EncodedName = [uri]::EscapeDataString($this.ProjectName) $project = $this.CallProjectApi("GET", "/projects/$($EncodedName)?api-version=7.1", $null) return $project.id } # Get the scope descriptor for a project (needed for Graph group queries) hidden [string] GetProjectScopeDescriptor([string]$ProjectId) { $response = $this.CallGraphApi("GET", "/descriptors/$($ProjectId)?api-version=$($this.apiVersion)", $null) return $response.value } # Find the target group descriptor within the project scope hidden [string] GetGroupDescriptor([string]$ScopeDescriptor) { $targetName = $this.GetGroupDisplayName() $groups = $this.CallGraphApi("GET", "/groups?scopeDescriptor=$ScopeDescriptor&api-version=$($this.apiVersion)", $null) if ($null -eq $groups -or $null -eq $groups.value) { throw "No groups found for project '$($this.ProjectName)'" } $group = $groups.value | Where-Object { $_.displayName -eq $targetName } if ($null -eq $group) { throw "Group '$targetName' not found in project '$($this.ProjectName)'" } return $group.descriptor } # Resolve Entra security group descriptor by Origin ID, adding it to the org if needed hidden [string] GetSourceGroupDescriptor() { $groups = $this.CallGraphApi("GET", "/groups?api-version=$($this.apiVersion)", $null) if ($null -ne $groups -and $null -ne $groups.value) { $sourceGroup = $groups.value | Where-Object { $_.originId -eq $this.GroupOriginId } if ($null -ne $sourceGroup) { return $sourceGroup.descriptor } } # Group not found in the organization - add it from Entra Write-Verbose "GetSourceGroupDescriptor() - Group with Origin ID '$($this.GroupOriginId)' not found. Adding to organization..." $addGroupPayload = @{ originId = $this.GroupOriginId } $jsonPayload = $addGroupPayload | ConvertTo-Json -Depth 10 try { $newGroup = $this.CallGraphApi("POST", "/groups?api-version=$($this.apiVersion)", $jsonPayload) if ($null -eq $newGroup -or [string]::IsNullOrWhiteSpace($newGroup.descriptor)) { throw "Failed to add Entra group with Origin ID '$($this.GroupOriginId)' to the organization." } Write-Verbose "GetSourceGroupDescriptor() - Successfully added group. Descriptor: $($newGroup.descriptor)" return $newGroup.descriptor } catch { throw "Failed to add Entra security group with Origin ID '$($this.GroupOriginId)' to the organization. Error: $_" } } # Check if the source group is already a member of the target project group hidden [bool] IsMember([string]$SourceGroupDescriptor, [string]$ProjectGroupDescriptor) { [string]$OrgName = $this.GetOrganizationValue() [string]$Token = $this.GetTokenValue() $Base64Auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$Token")) try { $encodedSourceGroup = [uri]::EscapeDataString($SourceGroupDescriptor) $encodedProjectGroup = [uri]::EscapeDataString($ProjectGroupDescriptor) $null = Invoke-RestMethod -Uri "https://vssps.dev.azure.com/$OrgName/_apis/graph/memberships/$encodedSourceGroup/$($encodedProjectGroup)?api-version=$($this.apiVersion)" ` -Method GET ` -Headers @{ Authorization = "Basic $Base64Auth" } ` -ErrorAction Stop return $true } catch { return $false } } [bool] Test() { try { Write-Verbose "Test() - Checking project group permission: $($this.GroupOriginId) -> $($this.PermissionLevel) in $($this.ProjectName)" $projectId = $this.GetProjectId() $scopeDescriptor = $this.GetProjectScopeDescriptor($projectId) $projectGroupDesc = $this.GetGroupDescriptor($scopeDescriptor) $sourceGroupDescriptor = $this.GetSourceGroupDescriptor() $isMember = $this.IsMember($sourceGroupDescriptor, $projectGroupDesc) if ($this.Ensure -eq [Ensure]::Present) { return $isMember } else { return -not $isMember } } catch { Write-Error "Test() - Error: $_" return $this.Ensure -eq [Ensure]::Absent } } [void] Set() { try { Write-Verbose "Set() - Setting project group permission: $($this.GroupOriginId) -> $($this.PermissionLevel) in $($this.ProjectName), Ensure=$($this.Ensure)" $projectId = $this.GetProjectId() $scopeDescriptor = $this.GetProjectScopeDescriptor($projectId) $projectGroupDesc = $this.GetGroupDescriptor($scopeDescriptor) $sourceGroupDescriptor = $this.GetSourceGroupDescriptor() $encodedSourceGroup = [uri]::EscapeDataString($sourceGroupDescriptor) $encodedProjectGroup = [uri]::EscapeDataString($projectGroupDesc) if ($this.Ensure -eq [Ensure]::Present) { $this.CallGraphApi("PUT", "/memberships/$encodedSourceGroup/$($encodedProjectGroup)?api-version=$($this.apiVersion)", "") Write-Verbose "Set() - Added group '$($this.GroupOriginId)' to '$($this.GetGroupDisplayName())' in project '$($this.ProjectName)'" } else { $this.CallGraphApi("DELETE", "/memberships/$encodedSourceGroup/$($encodedProjectGroup)?api-version=$($this.apiVersion)", $null) Write-Verbose "Set() - Removed group '$($this.GroupOriginId)' from '$($this.GetGroupDisplayName())' in project '$($this.ProjectName)'" } } catch { Write-Error "Set() - Error: $_" throw } } [ProjectGroupPermissionResource] Get() { try { Write-Verbose "Get() - Retrieving project group permission: $($this.GroupOriginId) -> $($this.PermissionLevel) in $($this.ProjectName)" $projectId = $this.GetProjectId() $scopeDescriptor = $this.GetProjectScopeDescriptor($projectId) $projectGroupDesc = $this.GetGroupDescriptor($scopeDescriptor) $sourceGroupDescriptor = $this.GetSourceGroupDescriptor() $isMember = $this.IsMember($sourceGroupDescriptor, $projectGroupDesc) $result = [ProjectGroupPermissionResource]::new() $result.ProjectName = $this.ProjectName $result.GroupOriginId = $this.GroupOriginId $result.GroupDisplayName = $this.GroupDisplayName $result.PermissionLevel = $this.PermissionLevel $result.Organization = $this.GetOrganizationValue() $result.Ensure = if ($isMember) { [Ensure]::Present } else { [Ensure]::Absent } $result.pat = $this.pat $result.apiVersion = $this.apiVersion return $result } catch { Write-Error "Get() - Error: $_" $result = [ProjectGroupPermissionResource]::new() $result.ProjectName = $this.ProjectName $result.GroupOriginId = $this.GroupOriginId $result.GroupDisplayName = $this.GroupDisplayName $result.PermissionLevel = $this.PermissionLevel $result.Organization = $this.GetOrganizationValue() $result.Ensure = [Ensure]::Absent $result.pat = $this.pat $result.apiVersion = $this.apiVersion return $result } } } <# .SYNOPSIS Manages a user's membership in an organization-level (Project Collection) Azure DevOps security group. .DESCRIPTION Adds or removes a user (by UPN) to/from an organization-level security group such as Project Collection Administrators, Project Collection Valid Users, Project-Scoped Users, etc. Uses the Azure DevOps Graph REST API. .PARAMETER UserPrincipalName User principal name (email) to add or remove from the organization group. .PARAMETER PermissionLevel The organization-level group to manage: ProjectCollectionAdministrators, ProjectCollectionBuildAdministrators, ProjectCollectionBuildServiceAccounts, ProjectCollectionProxyServiceAccounts, ProjectCollectionServiceAccounts, ProjectCollectionTestServiceAccounts, ProjectCollectionValidUsers, ProjectScopedUsers, or SecurityServiceGroup. .PARAMETER Organization Azure DevOps organization name. .PARAMETER Ensure Desired state: Present (add membership) or Absent (remove membership). .PARAMETER pat Personal access token used for authentication. .PARAMETER apiVersion Azure DevOps REST API version for the Graph endpoints. #> [DscResource()] class OrganizationUserPermissionResource { [DscProperty(Key)] [string]$UserPrincipalName [DscProperty(Key)] [string]$Organization [DscProperty(Mandatory)] [OrgPermissionLevel]$PermissionLevel [DscProperty()] [Ensure]$Ensure = [Ensure]::Present [DscProperty(Mandatory)] [Alias('Token','PersonalAccessToken')] [string]$pat [DscProperty()] [string]$apiVersion = "7.1-preview.1" hidden [string] GetTokenValue() { if ($this.pat -is [hashtable]) { $keyNames = @("value", "secureString", "Token", "PersonalAccessToken", "pat", "_value") foreach ($key in $keyNames) { if ($this.pat.ContainsKey($key)) { return $this.pat[$key].ToString() } } $firstKey = $this.pat.Keys | Select-Object -First 1 if ($firstKey) { return $this.pat[$firstKey].ToString() } } return $this.pat.ToString() } hidden [string] GetOrganizationValue() { if ($this.Organization -is [hashtable]) { $firstValue = $this.Organization.Values | Select-Object -First 1 if ($firstValue) { return $firstValue.ToString() } } return $this.Organization } hidden [object] CallApi([string]$Method, [string]$Uri, [string]$Body) { [string]$StringToken = $this.GetTokenValue() if ([string]::IsNullOrWhiteSpace($StringToken)) { throw "Token is null or empty" } $Base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$StringToken")) $Headers = @{ Authorization = "Basic $Base64AuthInfo" "Content-Type" = "application/json" } $Params = @{ Uri = $Uri Method = $Method Headers = $Headers ErrorAction = "Stop" } if ($Body) { $Params.Body = $Body } try { return Invoke-RestMethod @Params } catch { Write-Error "OrganizationUserPermissionResource.CallApi error: $_" throw } } hidden [string] GetGroupDisplayName() { $result = switch ($this.PermissionLevel) { 'ProjectCollectionAdministrators' { 'Project Collection Administrators' } 'ProjectCollectionBuildAdministrators' { 'Project Collection Build Administrators' } 'ProjectCollectionBuildServiceAccounts'{ 'Project Collection Build Service Accounts' } 'ProjectCollectionProxyServiceAccounts'{ 'Project Collection Proxy Service Accounts' } 'ProjectCollectionServiceAccounts' { 'Project Collection Service Accounts' } 'ProjectCollectionTestServiceAccounts' { 'Project Collection Test Service Accounts' } 'ProjectCollectionValidUsers' { 'Project Collection Valid Users' } 'ProjectScopedUsers' { 'Project-Scoped Users' } 'SecurityServiceGroup' { 'Security Service Group' } default { throw "Unknown PermissionLevel: $($this.PermissionLevel)" } } return $result } hidden [string] GetOrgGroupDescriptor() { [string]$OrgName = $this.GetOrganizationValue() $targetName = $this.GetGroupDisplayName() $groups = $this.CallApi("GET", "https://vssps.dev.azure.com/$OrgName/_apis/graph/groups?api-version=$($this.apiVersion)", $null) if ($null -eq $groups -or $null -eq $groups.value) { throw "No groups found for organization '$OrgName'" } $escapedOrg = [regex]::Escape($OrgName) $group = $groups.value | Where-Object { $_.displayName -eq $targetName -and $_.principalName -match "^\[$escapedOrg\]" } if ($null -eq $group) { throw "Organization group '$targetName' not found in '$OrgName'" } return $group.descriptor } hidden [string] GetUserDescriptor() { [string]$OrgName = $this.GetOrganizationValue() $users = $this.CallApi("GET", "https://vssps.dev.azure.com/$OrgName/_apis/graph/users?api-version=$($this.apiVersion)", $null) if ($null -eq $users -or $null -eq $users.value) { throw "No users returned from Graph API" } $user = $users.value | Where-Object { $_.principalName -eq $this.UserPrincipalName } if ($null -eq $user) { throw "User '$($this.UserPrincipalName)' not found in the organization" } return $user.descriptor } hidden [bool] IsMember([string]$UserDescriptor, [string]$GroupDescriptor) { [string]$OrgName = $this.GetOrganizationValue() [string]$Token = $this.GetTokenValue() $Base64Auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$Token")) $encodedUser = [uri]::EscapeDataString($UserDescriptor) $encodedGroup = [uri]::EscapeDataString($GroupDescriptor) try { $null = Invoke-RestMethod -Uri "https://vssps.dev.azure.com/$OrgName/_apis/graph/memberships/$encodedUser/$($encodedGroup)?api-version=$($this.apiVersion)" ` -Method GET ` -Headers @{ Authorization = "Basic $Base64Auth" } ` -ErrorAction Stop return $true } catch { return $false } } [bool] Test() { try { Write-Verbose "Test() - Checking org permission: $($this.UserPrincipalName) -> $($this.PermissionLevel) in $($this.Organization)" $groupDescriptor = $this.GetOrgGroupDescriptor() $userDescriptor = $this.GetUserDescriptor() $isMember = $this.IsMember($userDescriptor, $groupDescriptor) if ($this.Ensure -eq [Ensure]::Present) { return $isMember } else { return -not $isMember } } catch { Write-Error "Test() - Error: $_" return $this.Ensure -eq [Ensure]::Absent } } [void] Set() { try { Write-Verbose "Set() - Setting org permission: $($this.UserPrincipalName) -> $($this.PermissionLevel), Ensure=$($this.Ensure)" [string]$OrgName = $this.GetOrganizationValue() $groupDescriptor = $this.GetOrgGroupDescriptor() $userDescriptor = $this.GetUserDescriptor() $encodedUser = [uri]::EscapeDataString($userDescriptor) $encodedGroup = [uri]::EscapeDataString($groupDescriptor) if ($this.Ensure -eq [Ensure]::Present) { $this.CallApi("PUT", "https://vssps.dev.azure.com/$OrgName/_apis/graph/memberships/$encodedUser/$($encodedGroup)?api-version=$($this.apiVersion)", "") Write-Verbose "Set() - Added '$($this.UserPrincipalName)' to '$($this.GetGroupDisplayName())' in organization '$OrgName'" } else { $this.CallApi("DELETE", "https://vssps.dev.azure.com/$OrgName/_apis/graph/memberships/$encodedUser/$($encodedGroup)?api-version=$($this.apiVersion)", $null) Write-Verbose "Set() - Removed '$($this.UserPrincipalName)' from '$($this.GetGroupDisplayName())' in organization '$OrgName'" } } catch { Write-Error "Set() - Error: $_" throw } } [OrganizationUserPermissionResource] Get() { try { Write-Verbose "Get() - Retrieving org permission: $($this.UserPrincipalName) -> $($this.PermissionLevel)" $groupDescriptor = $this.GetOrgGroupDescriptor() $userDescriptor = $this.GetUserDescriptor() $isMember = $this.IsMember($userDescriptor, $groupDescriptor) $result = [OrganizationUserPermissionResource]::new() $result.UserPrincipalName = $this.UserPrincipalName $result.Organization = $this.GetOrganizationValue() $result.PermissionLevel = $this.PermissionLevel $result.Ensure = if ($isMember) { [Ensure]::Present } else { [Ensure]::Absent } $result.pat = $this.pat $result.apiVersion = $this.apiVersion return $result } catch { Write-Error "Get() - Error: $_" $result = [OrganizationUserPermissionResource]::new() $result.UserPrincipalName = $this.UserPrincipalName $result.Organization = $this.GetOrganizationValue() $result.PermissionLevel = $this.PermissionLevel $result.Ensure = [Ensure]::Absent $result.pat = $this.pat $result.apiVersion = $this.apiVersion return $result } } } <# .SYNOPSIS Manages an Entra security group's membership in an organization-level (Project Collection) Azure DevOps security group. .DESCRIPTION Adds or removes an Entra (Azure AD) security group to/from an organization-level security group such as Project Collection Administrators, Project Collection Valid Users, Project-Scoped Users, etc. If the Entra group is not yet linked to the Azure DevOps organization, it is added automatically during Set. Uses the Azure DevOps Graph REST API. .PARAMETER GroupOriginId The Origin ID (Object ID) of the Entra security group. .PARAMETER GroupDisplayName Optional display name of the Entra security group (for reference). .PARAMETER PermissionLevel The organization-level group to manage: ProjectCollectionAdministrators, ProjectCollectionBuildAdministrators, ProjectCollectionBuildServiceAccounts, ProjectCollectionProxyServiceAccounts, ProjectCollectionServiceAccounts, ProjectCollectionTestServiceAccounts, ProjectCollectionValidUsers, ProjectScopedUsers, or SecurityServiceGroup. .PARAMETER Organization Azure DevOps organization name. .PARAMETER Ensure Desired state: Present (add membership) or Absent (remove membership). .PARAMETER pat Personal access token used for authentication. .PARAMETER apiVersion Azure DevOps REST API version for the Graph endpoints. #> [DscResource()] class OrganizationGroupPermissionResource { [DscProperty(Key)] [string]$GroupOriginId [DscProperty(Key)] [string]$Organization [DscProperty()] [string]$GroupDisplayName [DscProperty(Mandatory)] [OrgPermissionLevel]$PermissionLevel [DscProperty()] [Ensure]$Ensure = [Ensure]::Present [DscProperty(Mandatory)] [Alias('Token','PersonalAccessToken')] [string]$pat [DscProperty()] [string]$apiVersion = "7.1-preview.1" hidden [string] GetTokenValue() { if ($this.pat -is [hashtable]) { $keyNames = @("value", "secureString", "Token", "PersonalAccessToken", "pat", "_value") foreach ($key in $keyNames) { if ($this.pat.ContainsKey($key)) { return $this.pat[$key].ToString() } } $firstKey = $this.pat.Keys | Select-Object -First 1 if ($firstKey) { return $this.pat[$firstKey].ToString() } } return $this.pat.ToString() } hidden [string] GetOrganizationValue() { if ($this.Organization -is [hashtable]) { $firstValue = $this.Organization.Values | Select-Object -First 1 if ($firstValue) { return $firstValue.ToString() } } return $this.Organization } hidden [object] CallApi([string]$Method, [string]$Uri, [string]$Body) { [string]$StringToken = $this.GetTokenValue() if ([string]::IsNullOrWhiteSpace($StringToken)) { throw "Token is null or empty" } $Base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$StringToken")) $Headers = @{ Authorization = "Basic $Base64AuthInfo" "Content-Type" = "application/json" } $Params = @{ Uri = $Uri Method = $Method Headers = $Headers ErrorAction = "Stop" } if ($Body) { $Params.Body = $Body } try { return Invoke-RestMethod @Params } catch { Write-Error "OrganizationGroupPermissionResource.CallApi error: $_" throw } } hidden [string] GetGroupDisplayName() { $result = switch ($this.PermissionLevel) { 'ProjectCollectionAdministrators' { 'Project Collection Administrators' } 'ProjectCollectionBuildAdministrators' { 'Project Collection Build Administrators' } 'ProjectCollectionBuildServiceAccounts'{ 'Project Collection Build Service Accounts' } 'ProjectCollectionProxyServiceAccounts'{ 'Project Collection Proxy Service Accounts' } 'ProjectCollectionServiceAccounts' { 'Project Collection Service Accounts' } 'ProjectCollectionTestServiceAccounts' { 'Project Collection Test Service Accounts' } 'ProjectCollectionValidUsers' { 'Project Collection Valid Users' } 'ProjectScopedUsers' { 'Project-Scoped Users' } 'SecurityServiceGroup' { 'Security Service Group' } default { throw "Unknown PermissionLevel: $($this.PermissionLevel)" } } return $result } hidden [string] GetOrgGroupDescriptor() { [string]$OrgName = $this.GetOrganizationValue() $targetName = $this.GetGroupDisplayName() $groups = $this.CallApi("GET", "https://vssps.dev.azure.com/$OrgName/_apis/graph/groups?api-version=$($this.apiVersion)", $null) if ($null -eq $groups -or $null -eq $groups.value) { throw "No groups found for organization '$OrgName'" } $escapedOrg = [regex]::Escape($OrgName) $group = $groups.value | Where-Object { $_.displayName -eq $targetName -and $_.principalName -match "^\[$escapedOrg\]" } if ($null -eq $group) { throw "Organization group '$targetName' not found in '$OrgName'" } return $group.descriptor } hidden [string] GetSourceGroupDescriptor() { [string]$OrgName = $this.GetOrganizationValue() $groups = $this.CallApi("GET", "https://vssps.dev.azure.com/$OrgName/_apis/graph/groups?api-version=$($this.apiVersion)", $null) if ($null -ne $groups -and $null -ne $groups.value) { $sourceGroup = $groups.value | Where-Object { $_.originId -eq $this.GroupOriginId } if ($null -ne $sourceGroup) { return $sourceGroup.descriptor } } # Group not yet linked — add it from Entra Write-Verbose "GetSourceGroupDescriptor() - Group '$($this.GroupOriginId)' not found; linking to organization." $payload = @{ originId = $this.GroupOriginId } | ConvertTo-Json -Depth 10 $newGroup = $this.CallApi("POST", "https://vssps.dev.azure.com/$OrgName/_apis/graph/groups?api-version=$($this.apiVersion)", $payload) if ($null -eq $newGroup -or [string]::IsNullOrWhiteSpace($newGroup.descriptor)) { throw "Failed to link Entra group '$($this.GroupOriginId)' to the organization." } Write-Verbose "GetSourceGroupDescriptor() - Linked group. Descriptor: $($newGroup.descriptor)" return $newGroup.descriptor } hidden [bool] IsMember([string]$SourceGroupDescriptor, [string]$OrgGroupDescriptor) { [string]$OrgName = $this.GetOrganizationValue() [string]$Token = $this.GetTokenValue() $Base64Auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$Token")) $encodedSourceGroup = [uri]::EscapeDataString($SourceGroupDescriptor) $encodedOrgGroup = [uri]::EscapeDataString($OrgGroupDescriptor) try { $null = Invoke-RestMethod -Uri "https://vssps.dev.azure.com/$OrgName/_apis/graph/memberships/$encodedSourceGroup/$($encodedOrgGroup)?api-version=$($this.apiVersion)" ` -Method GET ` -Headers @{ Authorization = "Basic $Base64Auth" } ` -ErrorAction Stop return $true } catch { return $false } } [bool] Test() { try { Write-Verbose "Test() - Checking org group permission: $($this.GroupOriginId) -> $($this.PermissionLevel) in $($this.Organization)" $orgGroupDescriptor = $this.GetOrgGroupDescriptor() $sourceGroupDescriptor = $this.GetSourceGroupDescriptor() $isMember = $this.IsMember($sourceGroupDescriptor, $orgGroupDescriptor) if ($this.Ensure -eq [Ensure]::Present) { return $isMember } else { return -not $isMember } } catch { Write-Error "Test() - Error: $_" return $this.Ensure -eq [Ensure]::Absent } } [void] Set() { try { Write-Verbose "Set() - Setting org group permission: $($this.GroupOriginId) -> $($this.PermissionLevel), Ensure=$($this.Ensure)" [string]$OrgName = $this.GetOrganizationValue() $orgGroupDescriptor = $this.GetOrgGroupDescriptor() $sourceGroupDescriptor = $this.GetSourceGroupDescriptor() $encodedSourceGroup = [uri]::EscapeDataString($sourceGroupDescriptor) $encodedOrgGroup = [uri]::EscapeDataString($orgGroupDescriptor) if ($this.Ensure -eq [Ensure]::Present) { $this.CallApi("PUT", "https://vssps.dev.azure.com/$OrgName/_apis/graph/memberships/$encodedSourceGroup/$($encodedOrgGroup)?api-version=$($this.apiVersion)", "") Write-Verbose "Set() - Added group '$($this.GroupOriginId)' to '$($this.GetGroupDisplayName())' in organization '$OrgName'" } else { $this.CallApi("DELETE", "https://vssps.dev.azure.com/$OrgName/_apis/graph/memberships/$encodedSourceGroup/$($encodedOrgGroup)?api-version=$($this.apiVersion)", $null) Write-Verbose "Set() - Removed group '$($this.GroupOriginId)' from '$($this.GetGroupDisplayName())' in organization '$OrgName'" } } catch { Write-Error "Set() - Error: $_" throw } } [OrganizationGroupPermissionResource] Get() { try { Write-Verbose "Get() - Retrieving org group permission: $($this.GroupOriginId) -> $($this.PermissionLevel)" $orgGroupDescriptor = $this.GetOrgGroupDescriptor() $sourceGroupDescriptor = $this.GetSourceGroupDescriptor() $isMember = $this.IsMember($sourceGroupDescriptor, $orgGroupDescriptor) $result = [OrganizationGroupPermissionResource]::new() $result.GroupOriginId = $this.GroupOriginId $result.GroupDisplayName = $this.GroupDisplayName $result.Organization = $this.GetOrganizationValue() $result.PermissionLevel = $this.PermissionLevel $result.Ensure = if ($isMember) { [Ensure]::Present } else { [Ensure]::Absent } $result.pat = $this.pat $result.apiVersion = $this.apiVersion return $result } catch { Write-Error "Get() - Error: $_" $result = [OrganizationGroupPermissionResource]::new() $result.GroupOriginId = $this.GroupOriginId $result.GroupDisplayName = $this.GroupDisplayName $result.Organization = $this.GetOrganizationValue() $result.PermissionLevel = $this.PermissionLevel $result.Ensure = [Ensure]::Absent $result.pat = $this.pat $result.apiVersion = $this.apiVersion return $result } } } |