From 78c20467750035c0495453739643859778dcf652 Mon Sep 17 00:00:00 2001 From: Michal Machniak Date: Fri, 12 Jun 2026 22:42:20 +0200 Subject: [PATCH 01/11] Add Organization permissions for user and group --- .../devops.example.dsc-native.config.yaml | 0 .../devops.example.dsc-powershell.config.yaml | 55 ++ ...ization-group-permission.dsc.resource.json | 145 +++++ .../devops.organization-group-permission.ps1 | 354 ++++++++++++ ...nization-user-permission.dsc.resource.json | 136 +++++ .../devops.organization-user-permission.ps1 | 324 +++++++++++ module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 | 6 +- module/AzureDevOpsDscv3/AzureDevOpsDscv3.psm1 | 524 +++++++++++++++++- 8 files changed, 1539 insertions(+), 5 deletions(-) rename dsc-resources/devops.example.dsc.config.yaml => dsc-config/devops.example.dsc-native.config.yaml (100%) create mode 100644 dsc-config/devops.example.dsc-powershell.config.yaml create mode 100644 dsc-resources/organization-group-permission/devops.organization-group-permission.dsc.resource.json create mode 100644 dsc-resources/organization-group-permission/devops.organization-group-permission.ps1 create mode 100644 dsc-resources/organization-user-permission/devops.organization-user-permission.dsc.resource.json create mode 100644 dsc-resources/organization-user-permission/devops.organization-user-permission.ps1 diff --git a/dsc-resources/devops.example.dsc.config.yaml b/dsc-config/devops.example.dsc-native.config.yaml similarity index 100% rename from dsc-resources/devops.example.dsc.config.yaml rename to dsc-config/devops.example.dsc-native.config.yaml diff --git a/dsc-config/devops.example.dsc-powershell.config.yaml b/dsc-config/devops.example.dsc-powershell.config.yaml new file mode 100644 index 0000000..010b2d9 --- /dev/null +++ b/dsc-config/devops.example.dsc-powershell.config.yaml @@ -0,0 +1,55 @@ +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + Token: + type: string + defaultValue: PAT-Token +resources: +- name: Working with classic DSC resources for ADO v4 + type: Microsoft.Windows/WindowsPowerShell + properties: + resources: + - name: Create ADO project - DSC + type: AzureDevOpsDscv3/ProjectResource + properties: + Organization: ExampleOrganization + ProjectName: TestDSC + Description: "Project created via DSC v3 with secure parameters" + pat: "[parameters('Token')]" + SourceControlType: Git + Ensure: Present + - name: AddUser + type: AzureDevOpsDscv3/OrganizationUserResource + properties: + UserPrincipalName: UserPrincipalName + Organization: ExampleOrganization + AccessLevel: Basic # or Stakeholder, BasicPlusTestPlans + Ensure: Present + pat: "[parameters('Token')]" + - name: AddGroup + type: AzureDevOpsDscv3/OrganizationGroupResource + properties: + GroupOriginId: EntraID-GroupObjectID # group descriptor + GroupDisplayName: EntraID-GroupDisplayName + Organization: ExampleOrganization + AccessLevel: Basic + Ensure: Present + pat: "[parameters('Token')]" + - name: AddPermissionProject + type: AzureDevOpsDscv3/ProjectUserPermissionResource + properties: + ProjectName: TestDSCv11 + PermissionLevel: ProjectAdministrators # ProjectAdministrators, ProjectValidUsers, Readers, or ReleaseAdministrators. + UserPrincipalName: UserPrincipalName + Organization: ExampleOrganization + Ensure: Absent + pat: "[parameters('Token')]" + - name: AddPermissionProject + type: AzureDevOpsDscv3/ProjectGroupPermissionResource + properties: + ProjectName: TestDSCv11 + PermissionLevel: ProjectAdministrators # ProjectAdministrators, ProjectValidUsers, Readers, or ReleaseAdministrators. + GroupDisplayName: EntraID-GroupDisplayName + GroupOriginId: EntraID-GroupObjectID # group descriptor + Organization: ExampleOrganization + Ensure: Absent + pat: "[parameters('Token')]" \ No newline at end of file diff --git a/dsc-resources/organization-group-permission/devops.organization-group-permission.dsc.resource.json b/dsc-resources/organization-group-permission/devops.organization-group-permission.dsc.resource.json new file mode 100644 index 0000000..e0d2365 --- /dev/null +++ b/dsc-resources/organization-group-permission/devops.organization-group-permission.dsc.resource.json @@ -0,0 +1,145 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "AzureDevOps/OrganizationGroupPermission", + "version": "0.1.0", + "description": "Manages an Entra security group's membership in an organization-level (Project Collection) Azure DevOps security group.", + "tags": [ + "AzureDevOps", + "DevOps", + "Organization", + "Permission", + "Group", + "Entra", + "Windows", + "Linux", + "macOS" + ], + "condition": "[not(equals(tryWhich('pwsh'), null()))]", + "get": { + "executable": "pwsh", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$input | ./devops.organization-group-permission.ps1 Get" + ], + "input": "stdin" + }, + "set": { + "executable": "pwsh", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$input | ./devops.organization-group-permission.ps1 Set" + ], + "input": "stdin", + "implementsPretest": false, + "return": "state" + }, + "test": { + "executable": "pwsh", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$input | ./devops.organization-group-permission.ps1 Test" + ], + "input": "stdin", + "return": "state" + }, + "exitCodes": { + "0": "Success", + "1": "Operation failed (API error or unexpected exception)" + }, + "schema": { + "embedded": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "AzureDevOps/OrganizationGroupPermission", + "description": "Properties that describe an Entra security group's membership in an organization-level Azure DevOps security group.", + "type": "object", + "required": [ + "groupOriginId", + "permissionLevel", + "organization", + "pat" + ], + "properties": { + "groupOriginId": { + "title": "Group Origin ID", + "description": "The Azure AD Object ID (origin ID) of the Entra security group. Part of the composite key.", + "type": "string" + }, + "groupDisplayName": { + "title": "Group Display Name", + "description": "Optional display name of the Entra security group (informational only).", + "type": [ + "string", + "null" + ] + }, + "permissionLevel": { + "title": "Permission Level", + "description": "The organization-level security group to manage the Entra group's membership in.", + "type": "string", + "enum": [ + "ProjectCollectionAdministrators", + "ProjectCollectionBuildAdministrators", + "ProjectCollectionBuildServiceAccounts", + "ProjectCollectionProxyServiceAccounts", + "ProjectCollectionServiceAccounts", + "ProjectCollectionTestServiceAccounts", + "ProjectCollectionValidUsers", + "ProjectScopedUsers", + "SecurityServiceGroup" + ] + }, + "organization": { + "title": "Organization", + "description": "The Azure DevOps organization name. Part of the composite key.", + "type": "string" + }, + "ensure": { + "title": "Ensure", + "description": "Whether the Entra group should be a member ('Present') or not a member ('Absent') of the organization group. Defaults to 'Present'.", + "type": "string", + "enum": [ + "Present", + "Absent" + ], + "default": "Present" + }, + "pat": { + "title": "Personal Access Token", + "description": "A Personal Access Token with 'Graph > Read & manage' permissions.", + "type": "string" + }, + "apiVersion": { + "title": "API Version", + "description": "Azure DevOps REST API version to use for Graph endpoints. Defaults to '7.1-preview.1'.", + "type": "string", + "default": "7.1-preview.1" + }, + "_inDesiredState": { + "title": "In Desired State", + "description": "Indicates whether the resource is in the desired state. Populated by the Test operation.", + "type": [ + "boolean", + "null" + ], + "default": null + } + }, + "additionalProperties": false + } + } +} diff --git a/dsc-resources/organization-group-permission/devops.organization-group-permission.ps1 b/dsc-resources/organization-group-permission/devops.organization-group-permission.ps1 new file mode 100644 index 0000000..66b82f6 --- /dev/null +++ b/dsc-resources/organization-group-permission/devops.organization-group-permission.ps1 @@ -0,0 +1,354 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.SYNOPSIS + DSC v3 resource script for managing Entra group membership in Azure DevOps organization-level groups. + +.DESCRIPTION + Implements Get, Set, and Test operations for an Entra security group's membership + in an organization-level (Project Collection) Azure DevOps 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. + Requires network access to https://dev.azure.com and https://vssps.dev.azure.com, + and a PAT with 'Graph > Read & manage' permissions. + +.PARAMETER Operation + The DSC operation to perform: Get, Set, or Test. + +.PARAMETER jsonInput + JSON string received via pipeline containing the desired state properties. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true, Position = 0)] + [ValidateSet('Get', 'Set', 'Test')] + [string]$Operation, + + [Parameter(Mandatory = $true, Position = 1, ValueFromPipeline = $true)] + [string]$jsonInput +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +function Write-DscTrace { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Error', 'Warn', 'Info', 'Debug', 'Trace')] + [string]$Level, + + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [string]$Message + ) + + $trace = @{ $Level.ToLower() = $Message } | ConvertTo-Json -Compress + $host.ui.WriteErrorLine($trace) +} + +function Get-TokenValue { + param([object]$Value) + + if ($Value -is [System.Collections.IDictionary]) { + foreach ($key in @('value', 'secureString', 'Token', 'PersonalAccessToken', 'pat', '_value')) { + if ($Value.Contains($key)) { return $Value[$key].ToString() } + } + $first = $Value.Keys | Select-Object -First 1 + if ($first) { return $Value[$first].ToString() } + } + if ($null -ne $Value.PSObject -and $Value.PSObject.Properties['secureString']) { + return $Value.secureString.ToString() + } + return $Value.ToString() +} + +function Invoke-AdoApi { + param( + [string]$Method, + [string]$Uri, + [string]$Token, + [string]$Body, + [string]$ContentType = 'application/json' + ) + + $base64 = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$Token")) + $headers = @{ + Authorization = "Basic $base64" + 'Content-Type' = $ContentType + } + $params = @{ + Uri = $Uri + Method = $Method + Headers = $headers + ErrorAction = 'Stop' + } + if ($Body) { $params.Body = $Body } + + try { + return Invoke-RestMethod @params + } + catch { + $msg = $_.ToString() + if ($_.Exception.Response) { + try { + $reader = [System.IO.StreamReader]::new($_.Exception.Response.GetResponseStream()) + $reader.BaseStream.Position = 0 + $reader.DiscardBufferedData() + $msg += " | Response: $($reader.ReadToEnd())" + } catch {} + } + throw $msg + } +} + +function Get-GroupDisplayName { + param([string]$PermissionLevel) + $map = @{ + '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' + } + if (-not $map.ContainsKey($PermissionLevel)) { + throw "Unknown permissionLevel '$PermissionLevel'." + } + return $map[$PermissionLevel] +} + +function Get-CollectionId { + param([string]$Organization, [string]$Token) + + $uri = "https://dev.azure.com/$Organization/_apis/connectionData?connectOptions=none&lastChangeId=-1&lastChangeId64=-1" + $response = Invoke-AdoApi -Method GET -Uri $uri -Token $Token + return $response.instanceId +} + +function Get-CollectionScopeDescriptor { + param([string]$Organization, [string]$CollectionId, [string]$Token, [string]$ApiVersion) + + $uri = "https://vssps.dev.azure.com/$Organization/_apis/graph/descriptors/$($CollectionId)?api-version=$ApiVersion" + $response = Invoke-AdoApi -Method GET -Uri $uri -Token $Token + return $response.value +} + +function Get-OrgGroupDescriptor { + param([string]$Organization, [string]$ScopeDescriptor, [string]$GroupDisplayName, [string]$Token, [string]$ApiVersion) + + $uri = "https://vssps.dev.azure.com/$Organization/_apis/graph/groups?scopeDescriptor=$ScopeDescriptor&api-version=$ApiVersion" + $groups = Invoke-AdoApi -Method GET -Uri $uri -Token $Token + + if ($null -eq $groups -or $null -eq $groups.value) { + throw "No groups found for the organization scope." + } + $group = $groups.value | Where-Object { $_.displayName -eq $GroupDisplayName } | Select-Object -First 1 + if ($null -eq $group) { + throw "Organization group '$GroupDisplayName' not found." + } + return $group.descriptor +} + +function Get-OrEnsureSourceGroupDescriptor { + param([string]$Organization, [string]$GroupOriginId, [string]$Token, [string]$ApiVersion) + + $uri = "https://vssps.dev.azure.com/$Organization/_apis/graph/groups?api-version=$ApiVersion" + $groups = Invoke-AdoApi -Method GET -Uri $uri -Token $Token + + if ($null -ne $groups -and $null -ne $groups.value) { + $existing = $groups.value | Where-Object { $_.originId -eq $GroupOriginId } | Select-Object -First 1 + if ($null -ne $existing) { return $existing.descriptor } + } + + # Group not yet linked — add it from Entra + Write-DscTrace -Level Info -Message "Entra group '$GroupOriginId' not found in organization; linking it now." + $payload = @{ originId = $GroupOriginId } | ConvertTo-Json -Depth 5 + $addUri = "https://vssps.dev.azure.com/$Organization/_apis/graph/groups?api-version=$ApiVersion" + $newGroup = Invoke-AdoApi -Method POST -Uri $addUri -Token $Token -Body $payload + + if ($null -eq $newGroup -or [string]::IsNullOrWhiteSpace($newGroup.descriptor)) { + throw "Failed to link Entra group '$GroupOriginId' to the organization." + } + return $newGroup.descriptor +} + +function Get-SourceGroupDescriptorIfExists { + param([string]$Organization, [string]$GroupOriginId, [string]$Token, [string]$ApiVersion) + + $uri = "https://vssps.dev.azure.com/$Organization/_apis/graph/groups?api-version=$ApiVersion" + $groups = Invoke-AdoApi -Method GET -Uri $uri -Token $Token + + if ($null -ne $groups -and $null -ne $groups.value) { + $existing = $groups.value | Where-Object { $_.originId -eq $GroupOriginId } | Select-Object -First 1 + if ($null -ne $existing) { return $existing.descriptor } + } + return $null +} + +function Test-IsMember { + param([string]$Organization, [string]$SubjectDescriptor, [string]$ContainerDescriptor, [string]$Token, [string]$ApiVersion) + + try { + $encSubject = [uri]::EscapeDataString($SubjectDescriptor) + $encContainer = [uri]::EscapeDataString($ContainerDescriptor) + $uri = "https://vssps.dev.azure.com/$Organization/_apis/graph/memberships/$($encSubject)/$($encContainer)?api-version=$ApiVersion" + Invoke-AdoApi -Method GET -Uri $uri -Token $Token | Out-Null + return $true + } + catch { + return $false + } +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +$desired = $jsonInput | ConvertFrom-Json + +$org = $desired.organization +$originId = $desired.groupOriginId +$displayName = $desired.groupDisplayName +$permLevel = $desired.permissionLevel +$token = Get-TokenValue -Value $desired.pat +$apiVersion = if ($desired.apiVersion) { $desired.apiVersion } else { '7.1-preview.1' } +$ensure = if ($desired.ensure) { $desired.ensure } else { 'Present' } + +if ([string]::IsNullOrWhiteSpace($token)) { + Write-DscTrace -Level Error -Message 'pat is required and must not be empty.' + exit 1 +} +if ([string]::IsNullOrWhiteSpace($org)) { + Write-DscTrace -Level Error -Message 'organization is required and must not be empty.' + exit 1 +} + +$groupDisplayName = Get-GroupDisplayName -PermissionLevel $permLevel + +switch ($Operation) { + + 'Get' { + try { + $collectionId = Get-CollectionId -Organization $org -Token $token + $scopeDescriptor = Get-CollectionScopeDescriptor -Organization $org -CollectionId $collectionId -Token $token -ApiVersion $apiVersion + $orgGroupDesc = Get-OrgGroupDescriptor -Organization $org -ScopeDescriptor $scopeDescriptor -GroupDisplayName $groupDisplayName -Token $token -ApiVersion $apiVersion + $sourceGroupDesc = Get-SourceGroupDescriptorIfExists -Organization $org -GroupOriginId $originId -Token $token -ApiVersion $apiVersion + + $isMember = $false + if ($null -ne $sourceGroupDesc) { + $isMember = Test-IsMember -Organization $org -SubjectDescriptor $sourceGroupDesc -ContainerDescriptor $orgGroupDesc -Token $token -ApiVersion $apiVersion + } + + $rEnsure = if ($isMember) { 'Present' } else { 'Absent' } + [ordered]@{ + groupOriginId = $originId + groupDisplayName = $displayName + permissionLevel = $permLevel + organization = $org + ensure = $rEnsure + pat = $desired.pat + apiVersion = $apiVersion + _inDesiredState = $null + } | ConvertTo-Json -Compress + } + catch { + Write-DscTrace -Level Error -Message "Get failed: $_" + exit 1 + } + } + + 'Test' { + try { + $collectionId = Get-CollectionId -Organization $org -Token $token + $scopeDescriptor = Get-CollectionScopeDescriptor -Organization $org -CollectionId $collectionId -Token $token -ApiVersion $apiVersion + $orgGroupDesc = Get-OrgGroupDescriptor -Organization $org -ScopeDescriptor $scopeDescriptor -GroupDisplayName $groupDisplayName -Token $token -ApiVersion $apiVersion + $sourceGroupDesc = Get-SourceGroupDescriptorIfExists -Organization $org -GroupOriginId $originId -Token $token -ApiVersion $apiVersion + + $isMember = $false + if ($null -ne $sourceGroupDesc) { + $isMember = Test-IsMember -Organization $org -SubjectDescriptor $sourceGroupDesc -ContainerDescriptor $orgGroupDesc -Token $token -ApiVersion $apiVersion + } + + $inDesiredState = if ($ensure -eq 'Present') { $isMember } else { -not $isMember } + $rEnsure = if ($isMember) { 'Present' } else { 'Absent' } + + [ordered]@{ + groupOriginId = $originId + groupDisplayName = $displayName + permissionLevel = $permLevel + organization = $org + ensure = $rEnsure + pat = $desired.pat + apiVersion = $apiVersion + _inDesiredState = $inDesiredState + } | ConvertTo-Json -Compress + } + catch { + Write-DscTrace -Level Error -Message "Test failed: $_" + exit 1 + } + } + + 'Set' { + try { + $collectionId = Get-CollectionId -Organization $org -Token $token + $scopeDescriptor = Get-CollectionScopeDescriptor -Organization $org -CollectionId $collectionId -Token $token -ApiVersion $apiVersion + $orgGroupDesc = Get-OrgGroupDescriptor -Organization $org -ScopeDescriptor $scopeDescriptor -GroupDisplayName $groupDisplayName -Token $token -ApiVersion $apiVersion + + if ($ensure -eq 'Present') { + # Ensure/link the Entra group before adding membership + $sourceGroupDesc = Get-OrEnsureSourceGroupDescriptor -Organization $org -GroupOriginId $originId -Token $token -ApiVersion $apiVersion + + Write-DscTrace -Level Info -Message "Adding Entra group '$originId' to '$groupDisplayName' in organization '$org'." + $encSource = [uri]::EscapeDataString($sourceGroupDesc) + $encTarget = [uri]::EscapeDataString($orgGroupDesc) + $uri = "https://vssps.dev.azure.com/$org/_apis/graph/memberships/$($encSource)/$($encTarget)?api-version=$apiVersion" + Invoke-AdoApi -Method PUT -Uri $uri -Token $token -Body '' | Out-Null + Write-DscTrace -Level Info -Message "Membership added successfully." + } + else { + $sourceGroupDesc = Get-SourceGroupDescriptorIfExists -Organization $org -GroupOriginId $originId -Token $token -ApiVersion $apiVersion + + if ($null -ne $sourceGroupDesc) { + Write-DscTrace -Level Info -Message "Removing Entra group '$originId' from '$groupDisplayName' in organization '$org'." + $encSource = [uri]::EscapeDataString($sourceGroupDesc) + $encTarget = [uri]::EscapeDataString($orgGroupDesc) + $uri = "https://vssps.dev.azure.com/$org/_apis/graph/memberships/$($encSource)/$($encTarget)?api-version=$apiVersion" + Invoke-AdoApi -Method DELETE -Uri $uri -Token $token | Out-Null + Write-DscTrace -Level Info -Message "Membership removed successfully." + } + else { + Write-DscTrace -Level Info -Message "Entra group '$originId' is not linked to the organization; no action required." + } + } + + # Return current state after set + $sourceGroupDesc = Get-SourceGroupDescriptorIfExists -Organization $org -GroupOriginId $originId -Token $token -ApiVersion $apiVersion + $isMember = $false + if ($null -ne $sourceGroupDesc) { + $isMember = Test-IsMember -Organization $org -SubjectDescriptor $sourceGroupDesc -ContainerDescriptor $orgGroupDesc -Token $token -ApiVersion $apiVersion + } + + $rEnsure = if ($isMember) { 'Present' } else { 'Absent' } + [ordered]@{ + groupOriginId = $originId + groupDisplayName = $displayName + permissionLevel = $permLevel + organization = $org + ensure = $rEnsure + pat = $desired.pat + apiVersion = $apiVersion + _inDesiredState = $null + } | ConvertTo-Json -Compress + } + catch { + Write-DscTrace -Level Error -Message "Set failed: $_" + exit 1 + } + } +} diff --git a/dsc-resources/organization-user-permission/devops.organization-user-permission.dsc.resource.json b/dsc-resources/organization-user-permission/devops.organization-user-permission.dsc.resource.json new file mode 100644 index 0000000..08bd234 --- /dev/null +++ b/dsc-resources/organization-user-permission/devops.organization-user-permission.dsc.resource.json @@ -0,0 +1,136 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "AzureDevOps/OrganizationUserPermission", + "version": "0.1.0", + "description": "Manages a user's membership in an organization-level (Project Collection) Azure DevOps security group.", + "tags": [ + "AzureDevOps", + "DevOps", + "Organization", + "Permission", + "User", + "Windows", + "Linux", + "macOS" + ], + "condition": "[not(equals(tryWhich('pwsh'), null()))]", + "get": { + "executable": "pwsh", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$input | ./devops.organization-user-permission.ps1 Get" + ], + "input": "stdin" + }, + "set": { + "executable": "pwsh", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$input | ./devops.organization-user-permission.ps1 Set" + ], + "input": "stdin", + "implementsPretest": false, + "return": "state" + }, + "test": { + "executable": "pwsh", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$input | ./devops.organization-user-permission.ps1 Test" + ], + "input": "stdin", + "return": "state" + }, + "exitCodes": { + "0": "Success", + "1": "Operation failed (API error or unexpected exception)" + }, + "schema": { + "embedded": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "AzureDevOps/OrganizationUserPermission", + "description": "Properties that describe a user's membership in an organization-level Azure DevOps security group.", + "type": "object", + "required": [ + "userPrincipalName", + "permissionLevel", + "organization", + "pat" + ], + "properties": { + "userPrincipalName": { + "title": "User Principal Name", + "description": "The UPN (email address) of the user. Part of the composite key.", + "type": "string" + }, + "permissionLevel": { + "title": "Permission Level", + "description": "The organization-level security group to manage the user's membership in.", + "type": "string", + "enum": [ + "ProjectCollectionAdministrators", + "ProjectCollectionBuildAdministrators", + "ProjectCollectionBuildServiceAccounts", + "ProjectCollectionProxyServiceAccounts", + "ProjectCollectionServiceAccounts", + "ProjectCollectionTestServiceAccounts", + "ProjectCollectionValidUsers", + "ProjectScopedUsers", + "SecurityServiceGroup" + ] + }, + "organization": { + "title": "Organization", + "description": "The Azure DevOps organization name. Part of the composite key.", + "type": "string" + }, + "ensure": { + "title": "Ensure", + "description": "Whether the user should be a member ('Present') or not a member ('Absent') of the group. Defaults to 'Present'.", + "type": "string", + "enum": [ + "Present", + "Absent" + ], + "default": "Present" + }, + "pat": { + "title": "Personal Access Token", + "description": "A Personal Access Token with 'Graph > Read & manage' permissions.", + "type": "string" + }, + "apiVersion": { + "title": "API Version", + "description": "Azure DevOps REST API version to use for Graph endpoints. Defaults to '7.1-preview.1'.", + "type": "string", + "default": "7.1-preview.1" + }, + "_inDesiredState": { + "title": "In Desired State", + "description": "Indicates whether the resource is in the desired state. Populated by the Test operation.", + "type": [ + "boolean", + "null" + ], + "default": null + } + }, + "additionalProperties": false + } + } +} diff --git a/dsc-resources/organization-user-permission/devops.organization-user-permission.ps1 b/dsc-resources/organization-user-permission/devops.organization-user-permission.ps1 new file mode 100644 index 0000000..42ea533 --- /dev/null +++ b/dsc-resources/organization-user-permission/devops.organization-user-permission.ps1 @@ -0,0 +1,324 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.SYNOPSIS + DSC v3 resource script for managing user membership in Azure DevOps organization-level groups. + +.DESCRIPTION + Implements Get, Set, and Test operations for user membership in an organization-level + (Project Collection) Azure DevOps security group such as Project Collection Administrators, + Project Collection Valid Users, Project-Scoped Users, etc. + Requires network access to https://dev.azure.com and https://vssps.dev.azure.com, + and a PAT with 'Graph > Read & manage' permissions. + +.PARAMETER Operation + The DSC operation to perform: Get, Set, or Test. + +.PARAMETER jsonInput + JSON string received via pipeline containing the desired state properties. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true, Position = 0)] + [ValidateSet('Get', 'Set', 'Test')] + [string]$Operation, + + [Parameter(Mandatory = $true, Position = 1, ValueFromPipeline = $true)] + [string]$jsonInput +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +function Write-DscTrace { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Error', 'Warn', 'Info', 'Debug', 'Trace')] + [string]$Level, + + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [string]$Message + ) + + $trace = @{ $Level.ToLower() = $Message } | ConvertTo-Json -Compress + $host.ui.WriteErrorLine($trace) +} + +function Get-TokenValue { + param([object]$Value) + + if ($Value -is [System.Collections.IDictionary]) { + foreach ($key in @('value', 'secureString', 'Token', 'PersonalAccessToken', 'pat', '_value')) { + if ($Value.Contains($key)) { return $Value[$key].ToString() } + } + $first = $Value.Keys | Select-Object -First 1 + if ($first) { return $Value[$first].ToString() } + } + if ($null -ne $Value.PSObject -and $Value.PSObject.Properties['secureString']) { + return $Value.secureString.ToString() + } + return $Value.ToString() +} + +function Invoke-AdoApi { + param( + [string]$Method, + [string]$Uri, + [string]$Token, + [string]$Body, + [string]$ContentType = 'application/json' + ) + + $base64 = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$Token")) + $headers = @{ + Authorization = "Basic $base64" + 'Content-Type' = $ContentType + } + $params = @{ + Uri = $Uri + Method = $Method + Headers = $headers + ErrorAction = 'Stop' + } + if ($Body) { $params.Body = $Body } + + try { + return Invoke-RestMethod @params + } + catch { + $msg = $_.ToString() + if ($_.Exception.Response) { + try { + $reader = [System.IO.StreamReader]::new($_.Exception.Response.GetResponseStream()) + $reader.BaseStream.Position = 0 + $reader.DiscardBufferedData() + $msg += " | Response: $($reader.ReadToEnd())" + } catch {} + } + throw $msg + } +} + +function Get-GroupDisplayName { + param([string]$PermissionLevel) + $map = @{ + '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' + } + if (-not $map.ContainsKey($PermissionLevel)) { + throw "Unknown permissionLevel '$PermissionLevel'." + } + return $map[$PermissionLevel] +} + +function Get-CollectionId { + param([string]$Organization, [string]$Token) + + $uri = "https://dev.azure.com/$Organization/_apis/connectionData?connectOptions=none&lastChangeId=-1&lastChangeId64=-1" + $response = Invoke-AdoApi -Method GET -Uri $uri -Token $Token + return $response.instanceId +} + +function Get-CollectionScopeDescriptor { + param([string]$Organization, [string]$CollectionId, [string]$Token, [string]$ApiVersion) + + $uri = "https://vssps.dev.azure.com/$Organization/_apis/graph/descriptors/$($CollectionId)?api-version=$ApiVersion" + $response = Invoke-AdoApi -Method GET -Uri $uri -Token $Token + return $response.value +} + +function Get-OrgGroupDescriptor { + param([string]$Organization, [string]$ScopeDescriptor, [string]$GroupDisplayName, [string]$Token, [string]$ApiVersion) + + $uri = "https://vssps.dev.azure.com/$Organization/_apis/graph/groups?scopeDescriptor=$ScopeDescriptor&api-version=$ApiVersion" + $groups = Invoke-AdoApi -Method GET -Uri $uri -Token $Token + + if ($null -eq $groups -or $null -eq $groups.value) { + throw "No groups found for the organization scope." + } + $group = $groups.value | Where-Object { $_.displayName -eq $GroupDisplayName } | Select-Object -First 1 + if ($null -eq $group) { + throw "Organization group '$GroupDisplayName' not found." + } + return $group.descriptor +} + +function Get-UserDescriptor { + param([string]$Organization, [string]$Upn, [string]$Token, [string]$ApiVersion) + + $uri = "https://vssps.dev.azure.com/$Organization/_apis/graph/users?api-version=$ApiVersion" + $users = Invoke-AdoApi -Method GET -Uri $uri -Token $Token + + if ($null -eq $users -or $null -eq $users.value) { + throw "No users returned from Graph API." + } + $user = $users.value | Where-Object { $_.principalName -eq $Upn } | Select-Object -First 1 + if ($null -eq $user) { + throw "User '$Upn' not found in the organization." + } + return $user.descriptor +} + +function Get-UserDescriptorIfExists { + param([string]$Organization, [string]$Upn, [string]$Token, [string]$ApiVersion) + + $uri = "https://vssps.dev.azure.com/$Organization/_apis/graph/users?api-version=$ApiVersion" + $users = Invoke-AdoApi -Method GET -Uri $uri -Token $Token + + if ($null -eq $users -or $null -eq $users.value) { return $null } + $user = $users.value | Where-Object { $_.principalName -eq $Upn } | Select-Object -First 1 + return $user?.descriptor +} + +function Test-IsMember { + param([string]$Organization, [string]$SubjectDescriptor, [string]$ContainerDescriptor, [string]$Token, [string]$ApiVersion) + + try { + $encSubject = [uri]::EscapeDataString($SubjectDescriptor) + $encContainer = [uri]::EscapeDataString($ContainerDescriptor) + $uri = "https://vssps.dev.azure.com/$Organization/_apis/graph/memberships/$($encSubject)/$($encContainer)?api-version=$ApiVersion" + Invoke-AdoApi -Method GET -Uri $uri -Token $Token | Out-Null + return $true + } + catch { + return $false + } +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +$desired = $jsonInput | ConvertFrom-Json + +$org = $desired.organization +$upn = $desired.userPrincipalName +$permLevel = $desired.permissionLevel +$token = Get-TokenValue -Value $desired.pat +$apiVersion = if ($desired.apiVersion) { $desired.apiVersion } else { '7.1-preview.1' } +$ensure = if ($desired.ensure) { $desired.ensure } else { 'Present' } + +if ([string]::IsNullOrWhiteSpace($token)) { + Write-DscTrace -Level Error -Message 'pat is required and must not be empty.' + exit 1 +} +if ([string]::IsNullOrWhiteSpace($org)) { + Write-DscTrace -Level Error -Message 'organization is required and must not be empty.' + exit 1 +} + +$groupDisplayName = Get-GroupDisplayName -PermissionLevel $permLevel + +switch ($Operation) { + + 'Get' { + try { + $collectionId = Get-CollectionId -Organization $org -Token $token + $scopeDescriptor = Get-CollectionScopeDescriptor -Organization $org -CollectionId $collectionId -Token $token -ApiVersion $apiVersion + $groupDescriptor = Get-OrgGroupDescriptor -Organization $org -ScopeDescriptor $scopeDescriptor -GroupDisplayName $groupDisplayName -Token $token -ApiVersion $apiVersion + $userDescriptor = Get-UserDescriptorIfExists -Organization $org -Upn $upn -Token $token -ApiVersion $apiVersion + + $isMember = $false + if ($null -ne $userDescriptor) { + $isMember = Test-IsMember -Organization $org -SubjectDescriptor $userDescriptor -ContainerDescriptor $groupDescriptor -Token $token -ApiVersion $apiVersion + } + + $rEnsure = if ($isMember) { 'Present' } else { 'Absent' } + [ordered]@{ + userPrincipalName = $upn + permissionLevel = $permLevel + organization = $org + ensure = $rEnsure + pat = $desired.pat + apiVersion = $apiVersion + _inDesiredState = $null + } | ConvertTo-Json -Compress + } + catch { + Write-DscTrace -Level Error -Message "Get failed: $_" + exit 1 + } + } + + 'Test' { + try { + $collectionId = Get-CollectionId -Organization $org -Token $token + $scopeDescriptor = Get-CollectionScopeDescriptor -Organization $org -CollectionId $collectionId -Token $token -ApiVersion $apiVersion + $groupDescriptor = Get-OrgGroupDescriptor -Organization $org -ScopeDescriptor $scopeDescriptor -GroupDisplayName $groupDisplayName -Token $token -ApiVersion $apiVersion + $userDescriptor = Get-UserDescriptorIfExists -Organization $org -Upn $upn -Token $token -ApiVersion $apiVersion + + $isMember = $false + if ($null -ne $userDescriptor) { + $isMember = Test-IsMember -Organization $org -SubjectDescriptor $userDescriptor -ContainerDescriptor $groupDescriptor -Token $token -ApiVersion $apiVersion + } + + $inDesiredState = if ($ensure -eq 'Present') { $isMember } else { -not $isMember } + $rEnsure = if ($isMember) { 'Present' } else { 'Absent' } + + [ordered]@{ + userPrincipalName = $upn + permissionLevel = $permLevel + organization = $org + ensure = $rEnsure + pat = $desired.pat + apiVersion = $apiVersion + _inDesiredState = $inDesiredState + } | ConvertTo-Json -Compress + } + catch { + Write-DscTrace -Level Error -Message "Test failed: $_" + exit 1 + } + } + + 'Set' { + try { + $collectionId = Get-CollectionId -Organization $org -Token $token + $scopeDescriptor = Get-CollectionScopeDescriptor -Organization $org -CollectionId $collectionId -Token $token -ApiVersion $apiVersion + $groupDescriptor = Get-OrgGroupDescriptor -Organization $org -ScopeDescriptor $scopeDescriptor -GroupDisplayName $groupDisplayName -Token $token -ApiVersion $apiVersion + $userDescriptor = Get-UserDescriptor -Organization $org -Upn $upn -Token $token -ApiVersion $apiVersion + + $encUser = [uri]::EscapeDataString($userDescriptor) + $encGroup = [uri]::EscapeDataString($groupDescriptor) + + if ($ensure -eq 'Present') { + Write-DscTrace -Level Info -Message "Adding '$upn' to '$groupDisplayName' in organization '$org'." + $uri = "https://vssps.dev.azure.com/$org/_apis/graph/memberships/$($encUser)/$($encGroup)?api-version=$apiVersion" + Invoke-AdoApi -Method PUT -Uri $uri -Token $token -Body '' | Out-Null + Write-DscTrace -Level Info -Message "Membership added successfully." + } + else { + Write-DscTrace -Level Info -Message "Removing '$upn' from '$groupDisplayName' in organization '$org'." + $uri = "https://vssps.dev.azure.com/$org/_apis/graph/memberships/$($encUser)/$($encGroup)?api-version=$apiVersion" + Invoke-AdoApi -Method DELETE -Uri $uri -Token $token | Out-Null + Write-DscTrace -Level Info -Message "Membership removed successfully." + } + + $isMember = Test-IsMember -Organization $org -SubjectDescriptor $userDescriptor -ContainerDescriptor $groupDescriptor -Token $token -ApiVersion $apiVersion + $rEnsure = if ($isMember) { 'Present' } else { 'Absent' } + + [ordered]@{ + userPrincipalName = $upn + permissionLevel = $permLevel + organization = $org + ensure = $rEnsure + pat = $desired.pat + apiVersion = $apiVersion + _inDesiredState = $null + } | ConvertTo-Json -Compress + } + catch { + Write-DscTrace -Level Error -Message "Set failed: $_" + exit 1 + } + } +} diff --git a/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 b/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 index 7d4673e..623007c 100644 --- a/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 +++ b/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 @@ -5,7 +5,7 @@ RootModule = 'AzureDevOpsDscv3.psm1' # Version number of this module. -ModuleVersion = '1.0.1' +ModuleVersion = '1.0.2' # Supported PSEditions CompatiblePSEditions = @('Desktop', 'Core') @@ -74,7 +74,7 @@ VariablesToExport = '*' AliasesToExport = '*' # DSC resources to export from this module -DscResourcesToExport = @('ProjectResource', 'OrganizationUserResource', 'OrganizationGroupResource', 'ProjectUserPermissionResource', 'ProjectGroupPermissionResource') +DscResourcesToExport = @('ProjectResource', 'OrganizationUserResource', 'OrganizationGroupResource', 'ProjectUserPermissionResource', 'ProjectGroupPermissionResource', 'OrganizationUserPermissionResource', 'OrganizationGroupPermissionResource') # List of all modules packaged with this module # ModuleList = @() @@ -101,7 +101,7 @@ DscResourcesToExport = @('ProjectResource', 'OrganizationUserResource', 'Organiz v. 1.0.1 - 2026-03-13 - Add class: ProjectUserPermissionResource - Add class: ProjectGroupPermissionResource - v. 1.0.2 - 2026-04-01 + v. 1.0.2 - 2026-06-012 - Add class: OrganizationUserPermissionResource - Add class: OrganizationGroupPermissionResource ' diff --git a/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psm1 b/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psm1 index e5dafbe..07ce548 100644 --- a/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psm1 +++ b/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psm1 @@ -28,6 +28,18 @@ enum ProjectPermissionLevel { ReleaseAdministrators } +enum OrgPermissionLevel { + ProjectCollectionAdministrators + ProjectCollectionBuildAdministrators + ProjectCollectionBuildServiceAccounts + ProjectCollectionProxyServiceAccounts + ProjectCollectionServiceAccounts + ProjectCollectionTestServiceAccounts + ProjectCollectionValidUsers + ProjectScopedUsers + SecurityServiceGroup +} + <# .SYNOPSIS Manages Azure DevOps projects for an organization. @@ -989,10 +1001,16 @@ class ProjectUserPermissionResource { # 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) - $this.CallGraphApi("GET", "/memberships/$encodedUser/$($encodedGroup)?api-version=$($this.apiVersion)", $null) + $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 { @@ -1304,10 +1322,16 @@ class ProjectGroupPermissionResource { # 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) - $this.CallGraphApi("GET", "/memberships/$encodedSourceGroup/$($encodedProjectGroup)?api-version=$($this.apiVersion)", $null) + $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 { @@ -1403,3 +1427,499 @@ class ProjectGroupPermissionResource { } } +<# +.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 + } + } +} From 7915d914545181ed1fd865889801939697f10129 Mon Sep 17 00:00:00 2001 From: Michal Machniak Date: Fri, 12 Jun 2026 22:42:53 +0200 Subject: [PATCH 02/11] Add Organization permissions for user and group --- .../devops.organization-group-permission.ps1 | 35 +++++-------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/dsc-resources/organization-group-permission/devops.organization-group-permission.ps1 b/dsc-resources/organization-group-permission/devops.organization-group-permission.ps1 index 66b82f6..0f5529a 100644 --- a/dsc-resources/organization-group-permission/devops.organization-group-permission.ps1 +++ b/dsc-resources/organization-group-permission/devops.organization-group-permission.ps1 @@ -122,34 +122,19 @@ function Get-GroupDisplayName { return $map[$PermissionLevel] } -function Get-CollectionId { - param([string]$Organization, [string]$Token) - - $uri = "https://dev.azure.com/$Organization/_apis/connectionData?connectOptions=none&lastChangeId=-1&lastChangeId64=-1" - $response = Invoke-AdoApi -Method GET -Uri $uri -Token $Token - return $response.instanceId -} - -function Get-CollectionScopeDescriptor { - param([string]$Organization, [string]$CollectionId, [string]$Token, [string]$ApiVersion) - - $uri = "https://vssps.dev.azure.com/$Organization/_apis/graph/descriptors/$($CollectionId)?api-version=$ApiVersion" - $response = Invoke-AdoApi -Method GET -Uri $uri -Token $Token - return $response.value -} - function Get-OrgGroupDescriptor { - param([string]$Organization, [string]$ScopeDescriptor, [string]$GroupDisplayName, [string]$Token, [string]$ApiVersion) + param([string]$Organization, [string]$GroupDisplayName, [string]$Token, [string]$ApiVersion) - $uri = "https://vssps.dev.azure.com/$Organization/_apis/graph/groups?scopeDescriptor=$ScopeDescriptor&api-version=$ApiVersion" + $uri = "https://vssps.dev.azure.com/$Organization/_apis/graph/groups?api-version=$ApiVersion" $groups = Invoke-AdoApi -Method GET -Uri $uri -Token $Token if ($null -eq $groups -or $null -eq $groups.value) { - throw "No groups found for the organization scope." + throw "No groups found for organization '$Organization'." } - $group = $groups.value | Where-Object { $_.displayName -eq $GroupDisplayName } | Select-Object -First 1 + $escapedOrg = [regex]::Escape($Organization) + $group = $groups.value | Where-Object { $_.displayName -eq $GroupDisplayName -and $_.principalName -match "^\[$escapedOrg\]" } | Select-Object -First 1 if ($null -eq $group) { - throw "Organization group '$GroupDisplayName' not found." + throw "Organization group '$GroupDisplayName' not found in '$Organization'." } return $group.descriptor } @@ -264,9 +249,7 @@ switch ($Operation) { 'Test' { try { - $collectionId = Get-CollectionId -Organization $org -Token $token - $scopeDescriptor = Get-CollectionScopeDescriptor -Organization $org -CollectionId $collectionId -Token $token -ApiVersion $apiVersion - $orgGroupDesc = Get-OrgGroupDescriptor -Organization $org -ScopeDescriptor $scopeDescriptor -GroupDisplayName $groupDisplayName -Token $token -ApiVersion $apiVersion + $orgGroupDesc = Get-OrgGroupDescriptor -Organization $org -GroupDisplayName $groupDisplayName -Token $token -ApiVersion $apiVersion $sourceGroupDesc = Get-SourceGroupDescriptorIfExists -Organization $org -GroupOriginId $originId -Token $token -ApiVersion $apiVersion $isMember = $false @@ -296,9 +279,7 @@ switch ($Operation) { 'Set' { try { - $collectionId = Get-CollectionId -Organization $org -Token $token - $scopeDescriptor = Get-CollectionScopeDescriptor -Organization $org -CollectionId $collectionId -Token $token -ApiVersion $apiVersion - $orgGroupDesc = Get-OrgGroupDescriptor -Organization $org -ScopeDescriptor $scopeDescriptor -GroupDisplayName $groupDisplayName -Token $token -ApiVersion $apiVersion + $orgGroupDesc = Get-OrgGroupDescriptor -Organization $org -GroupDisplayName $groupDisplayName -Token $token -ApiVersion $apiVersion if ($ensure -eq 'Present') { # Ensure/link the Entra group before adding membership From 22d7456b6a1f51b8c07fb3e8ba8b502726a828c1 Mon Sep 17 00:00:00 2001 From: Michal Machniak Date: Fri, 12 Jun 2026 22:43:40 +0200 Subject: [PATCH 03/11] Add Organization permissions for user and group --- .../devops.organization-group-permission.ps1 | 4 +- .../devops.organization-user-permission.ps1 | 39 +++++-------------- 2 files changed, 10 insertions(+), 33 deletions(-) diff --git a/dsc-resources/organization-group-permission/devops.organization-group-permission.ps1 b/dsc-resources/organization-group-permission/devops.organization-group-permission.ps1 index 0f5529a..e749e46 100644 --- a/dsc-resources/organization-group-permission/devops.organization-group-permission.ps1 +++ b/dsc-resources/organization-group-permission/devops.organization-group-permission.ps1 @@ -219,9 +219,7 @@ switch ($Operation) { 'Get' { try { - $collectionId = Get-CollectionId -Organization $org -Token $token - $scopeDescriptor = Get-CollectionScopeDescriptor -Organization $org -CollectionId $collectionId -Token $token -ApiVersion $apiVersion - $orgGroupDesc = Get-OrgGroupDescriptor -Organization $org -ScopeDescriptor $scopeDescriptor -GroupDisplayName $groupDisplayName -Token $token -ApiVersion $apiVersion + $orgGroupDesc = Get-OrgGroupDescriptor -Organization $org -GroupDisplayName $groupDisplayName -Token $token -ApiVersion $apiVersion $sourceGroupDesc = Get-SourceGroupDescriptorIfExists -Organization $org -GroupOriginId $originId -Token $token -ApiVersion $apiVersion $isMember = $false diff --git a/dsc-resources/organization-user-permission/devops.organization-user-permission.ps1 b/dsc-resources/organization-user-permission/devops.organization-user-permission.ps1 index 42ea533..8722575 100644 --- a/dsc-resources/organization-user-permission/devops.organization-user-permission.ps1 +++ b/dsc-resources/organization-user-permission/devops.organization-user-permission.ps1 @@ -120,34 +120,19 @@ function Get-GroupDisplayName { return $map[$PermissionLevel] } -function Get-CollectionId { - param([string]$Organization, [string]$Token) - - $uri = "https://dev.azure.com/$Organization/_apis/connectionData?connectOptions=none&lastChangeId=-1&lastChangeId64=-1" - $response = Invoke-AdoApi -Method GET -Uri $uri -Token $Token - return $response.instanceId -} - -function Get-CollectionScopeDescriptor { - param([string]$Organization, [string]$CollectionId, [string]$Token, [string]$ApiVersion) - - $uri = "https://vssps.dev.azure.com/$Organization/_apis/graph/descriptors/$($CollectionId)?api-version=$ApiVersion" - $response = Invoke-AdoApi -Method GET -Uri $uri -Token $Token - return $response.value -} - function Get-OrgGroupDescriptor { - param([string]$Organization, [string]$ScopeDescriptor, [string]$GroupDisplayName, [string]$Token, [string]$ApiVersion) + param([string]$Organization, [string]$GroupDisplayName, [string]$Token, [string]$ApiVersion) - $uri = "https://vssps.dev.azure.com/$Organization/_apis/graph/groups?scopeDescriptor=$ScopeDescriptor&api-version=$ApiVersion" + $uri = "https://vssps.dev.azure.com/$Organization/_apis/graph/groups?api-version=$ApiVersion" $groups = Invoke-AdoApi -Method GET -Uri $uri -Token $Token if ($null -eq $groups -or $null -eq $groups.value) { - throw "No groups found for the organization scope." + throw "No groups found for organization '$Organization'." } - $group = $groups.value | Where-Object { $_.displayName -eq $GroupDisplayName } | Select-Object -First 1 + $escapedOrg = [regex]::Escape($Organization) + $group = $groups.value | Where-Object { $_.displayName -eq $GroupDisplayName -and $_.principalName -match "^\[$escapedOrg\]" } | Select-Object -First 1 if ($null -eq $group) { - throw "Organization group '$GroupDisplayName' not found." + throw "Organization group '$GroupDisplayName' not found in '$Organization'." } return $group.descriptor } @@ -222,9 +207,7 @@ switch ($Operation) { 'Get' { try { - $collectionId = Get-CollectionId -Organization $org -Token $token - $scopeDescriptor = Get-CollectionScopeDescriptor -Organization $org -CollectionId $collectionId -Token $token -ApiVersion $apiVersion - $groupDescriptor = Get-OrgGroupDescriptor -Organization $org -ScopeDescriptor $scopeDescriptor -GroupDisplayName $groupDisplayName -Token $token -ApiVersion $apiVersion + $groupDescriptor = Get-OrgGroupDescriptor -Organization $org -GroupDisplayName $groupDisplayName -Token $token -ApiVersion $apiVersion $userDescriptor = Get-UserDescriptorIfExists -Organization $org -Upn $upn -Token $token -ApiVersion $apiVersion $isMember = $false @@ -251,9 +234,7 @@ switch ($Operation) { 'Test' { try { - $collectionId = Get-CollectionId -Organization $org -Token $token - $scopeDescriptor = Get-CollectionScopeDescriptor -Organization $org -CollectionId $collectionId -Token $token -ApiVersion $apiVersion - $groupDescriptor = Get-OrgGroupDescriptor -Organization $org -ScopeDescriptor $scopeDescriptor -GroupDisplayName $groupDisplayName -Token $token -ApiVersion $apiVersion + $groupDescriptor = Get-OrgGroupDescriptor -Organization $org -GroupDisplayName $groupDisplayName -Token $token -ApiVersion $apiVersion $userDescriptor = Get-UserDescriptorIfExists -Organization $org -Upn $upn -Token $token -ApiVersion $apiVersion $isMember = $false @@ -282,9 +263,7 @@ switch ($Operation) { 'Set' { try { - $collectionId = Get-CollectionId -Organization $org -Token $token - $scopeDescriptor = Get-CollectionScopeDescriptor -Organization $org -CollectionId $collectionId -Token $token -ApiVersion $apiVersion - $groupDescriptor = Get-OrgGroupDescriptor -Organization $org -ScopeDescriptor $scopeDescriptor -GroupDisplayName $groupDisplayName -Token $token -ApiVersion $apiVersion + $groupDescriptor = Get-OrgGroupDescriptor -Organization $org -GroupDisplayName $groupDisplayName -Token $token -ApiVersion $apiVersion $userDescriptor = Get-UserDescriptor -Organization $org -Upn $upn -Token $token -ApiVersion $apiVersion $encUser = [uri]::EscapeDataString($userDescriptor) From e3a0210aaf3dbaaa8f9d669dddf3c410027135f0 Mon Sep 17 00:00:00 2001 From: Michal Machniak Date: Fri, 12 Jun 2026 22:45:58 +0200 Subject: [PATCH 04/11] Add Organization permissions for user and group - docs --- docs/AzureDevOpsDscv3-native.md | 18 ++++++++--- docs/AzureDevOpsDscv3-parameters.md | 48 ++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/docs/AzureDevOpsDscv3-native.md b/docs/AzureDevOpsDscv3-native.md index b166ade..524518f 100644 --- a/docs/AzureDevOpsDscv3-native.md +++ b/docs/AzureDevOpsDscv3-native.md @@ -20,6 +20,8 @@ This guide covers how to copy the native DSC v3 resource files to a working dire | `AzureDevOps/Project` | `dsc-resources/project/` | Create / update / delete an ADO project | | `AzureDevOps/OrganizationUser` | `dsc-resources/organization-user/` | Manage user entitlement (access level) in an organization | | `AzureDevOps/OrganizationGroup` | `dsc-resources/organization-group/` | Manage Entra group entitlement (license rule) in an organization | +| `AzureDevOps/OrganizationUserPermission` | `dsc-resources/organization-user-permission/` | Add / remove a user from an organization-level (Project Collection) security group | +| `AzureDevOps/OrganizationGroupPermission` | `dsc-resources/organization-group-permission/` | Add / remove an Entra group from an organization-level (Project Collection) security group | | `AzureDevOps/ProjectUserPermission` | `dsc-resources/project-user-permission/` | Add / remove a user from a project-level security group | | `AzureDevOps/ProjectGroupPermission` | `dsc-resources/project-group-permission/` | Add / remove an Entra group from a project-level security group | @@ -39,6 +41,8 @@ $resources = @( "project", "organization-user", "organization-group", + "organization-user-permission", + "organization-group-permission", "project-user-permission", "project-group-permission" ) @@ -65,6 +69,8 @@ $env:DSC_RESOURCE_PATH = @( "$base\project", "$base\organization-user", "$base\organization-group", + "$base\organization-user-permission", + "$base\organization-group-permission", "$base\project-user-permission", "$base\project-group-permission" ) -join ";" @@ -93,11 +99,13 @@ dsc resource list | Select-String "AzureDevOps" Expected output: ``` -AzureDevOps/OrganizationGroup 0.1.0 -AzureDevOps/OrganizationUser 0.1.0 -AzureDevOps/Project 0.1.0 -AzureDevOps/ProjectGroupPermission 0.1.0 -AzureDevOps/ProjectUserPermission 0.1.0 +AzureDevOps/OrganizationGroup 0.1.0 +AzureDevOps/OrganizationGroupPermission 0.1.0 +AzureDevOps/OrganizationUser 0.1.0 +AzureDevOps/OrganizationUserPermission 0.1.0 +AzureDevOps/Project 0.1.0 +AzureDevOps/ProjectGroupPermission 0.1.0 +AzureDevOps/ProjectUserPermission 0.1.0 ``` If a resource is missing, verify: diff --git a/docs/AzureDevOpsDscv3-parameters.md b/docs/AzureDevOpsDscv3-parameters.md index 96a9cb4..ce36bb3 100644 --- a/docs/AzureDevOpsDscv3-parameters.md +++ b/docs/AzureDevOpsDscv3-parameters.md @@ -9,6 +9,10 @@ This document describes the parameters for each DSC resource included in the Azu - [ProjectResource](#projectresource) - [OrganizationUserResource](#organizationuserresource) - [OrganizationGroupResource](#organizationgroupresource) +- [OrganizationUserPermissionResource](#organizationuserpermissionresource) +- [OrganizationGroupPermissionResource](#organizationgrouppermissionresource) +- [ProjectUserPermissionResource](#projectuserpermissionresource) +- [ProjectGroupPermissionResource](#projectgrouppermissionresource) --- @@ -64,10 +68,46 @@ Manages Azure DevOps group entitlements for an organization. | `pat` | Personal access token used for authentication | None | ✅ Yes | | `apiVersion` | Azure DevOps REST API version | `7.1-preview.1` | ❌ No | +--- -## ProjectnGroupResource +## OrganizationUserPermissionResource -Manages Azure DevOps group entitlements for an organization. +Manages a user's membership in an organization-level (Project Collection) Azure DevOps security group. + +### Parameters + +| Parameter | Description | Default Value | Required | +|-----------|-------------|---------------|----------| +| `UserPrincipalName` | User principal name (email) to add or remove | None | ✅ Yes | +| `Organization` | Azure DevOps organization name | None | ✅ Yes | +| `PermissionLevel` | Organization-level group: `ProjectCollectionAdministrators`, `ProjectCollectionBuildAdministrators`, `ProjectCollectionBuildServiceAccounts`, `ProjectCollectionProxyServiceAccounts`, `ProjectCollectionServiceAccounts`, `ProjectCollectionTestServiceAccounts`, `ProjectCollectionValidUsers`, `ProjectScopedUsers`, or `SecurityServiceGroup` | None | ✅ Yes | +| `Ensure` | Desired state: `Present` or `Absent` | `Present` | ❌ No | +| `pat` | Personal access token used for authentication | None | ✅ Yes | +| `apiVersion` | Azure DevOps REST API version | `7.1-preview.1` | ❌ No | + +--- + +## OrganizationGroupPermissionResource + +Manages an Entra (Azure AD) security group's membership in an organization-level (Project Collection) Azure DevOps security group. If the Entra group is not yet linked to the organization it is added automatically. + +### Parameters + +| Parameter | Description | Default Value | Required | +|-----------|-------------|---------------|----------| +| `GroupOriginId` | Origin ID (Object ID) of the Entra security group | None | ✅ Yes | +| `GroupDisplayName` | Display name of the Entra security group (for reference) | None | ❌ No | +| `Organization` | Azure DevOps organization name | None | ✅ Yes | +| `PermissionLevel` | Organization-level group: `ProjectCollectionAdministrators`, `ProjectCollectionBuildAdministrators`, `ProjectCollectionBuildServiceAccounts`, `ProjectCollectionProxyServiceAccounts`, `ProjectCollectionServiceAccounts`, `ProjectCollectionTestServiceAccounts`, `ProjectCollectionValidUsers`, `ProjectScopedUsers`, or `SecurityServiceGroup` | None | ✅ Yes | +| `Ensure` | Desired state: `Present` or `Absent` | `Present` | ❌ No | +| `pat` | Personal access token used for authentication | None | ✅ Yes | +| `apiVersion` | Azure DevOps REST API version | `7.1-preview.1` | ❌ No | + +--- + +## ProjectGroupPermissionResource + +Manages Azure DevOps project-level security group membership for an Entra (Azure AD) security group. ### Parameters @@ -83,9 +123,9 @@ Manages Azure DevOps group entitlements for an organization. | `apiVersion` | Azure DevOps REST API version | `7.1-preview.1` | ❌ No | -## ProjectUserResource +## ProjectUserPermissionResource -Manages Azure DevOps user entitlements for an organization. +Manages Azure DevOps project-level security group membership for a user. ### Parameters From ed9fced7b117fa310b5b6d63dcf011ad92405de3 Mon Sep 17 00:00:00 2001 From: Michal Machniak <40869985+mimachniak@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:49:59 +0200 Subject: [PATCH 05/11] Update CHANGELOG for version 1.0.3 Added DSCv3 resources for OrganizationPermissions and updated documentation. --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef5223a..53773f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,4 +27,13 @@ - DSCV3 resources for: Project, Organization Permissions, Project Permissions +## [1.0.3] - 2026-06-12 + +### Added + +- DSCv3 - native resources: OrganisationPermissions User nad group +- PowerShell DSC resources - OrganisationPermissions User nad group +- Update documentation + + From 649f66f705561922234971bbb04061f79f157267 Mon Sep 17 00:00:00 2001 From: Michal Machniak <40869985+mimachniak@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:52:16 +0200 Subject: [PATCH 06/11] Update AzureDevOpsDscv3.psd1 --- module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 b/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 index 623007c..c71c994 100644 --- a/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 +++ b/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 @@ -5,7 +5,7 @@ RootModule = 'AzureDevOpsDscv3.psm1' # Version number of this module. -ModuleVersion = '1.0.2' +ModuleVersion = '1.0.3' # Supported PSEditions CompatiblePSEditions = @('Desktop', 'Core') @@ -101,7 +101,9 @@ DscResourcesToExport = @('ProjectResource', 'OrganizationUserResource', 'Organiz v. 1.0.1 - 2026-03-13 - Add class: ProjectUserPermissionResource - Add class: ProjectGroupPermissionResource - v. 1.0.2 - 2026-06-012 + v. 1.0.2 - 2026-06-01 + - Fix issue and docs and native DSC resources + v. 1.0.3 - 2026-06-12 - Add class: OrganizationUserPermissionResource - Add class: OrganizationGroupPermissionResource ' From 4f4ae5c051c44714531d5613b8cf42f7b7eb55c4 Mon Sep 17 00:00:00 2001 From: Michal Machniak Date: Sat, 13 Jun 2026 20:44:16 +0200 Subject: [PATCH 07/11] Add Organization permissions for user and group - docs --- module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 b/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 index 623007c..2695b28 100644 --- a/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 +++ b/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 @@ -101,7 +101,7 @@ DscResourcesToExport = @('ProjectResource', 'OrganizationUserResource', 'Organiz v. 1.0.1 - 2026-03-13 - Add class: ProjectUserPermissionResource - Add class: ProjectGroupPermissionResource - v. 1.0.2 - 2026-06-012 + v. 1.0.2 - 2026-06-12 - Add class: OrganizationUserPermissionResource - Add class: OrganizationGroupPermissionResource ' From f0a2d0e767dc3224ee0ac0e41f065bc90ad35ea2 Mon Sep 17 00:00:00 2001 From: Michal Machniak Date: Sat, 13 Jun 2026 21:00:25 +0200 Subject: [PATCH 08/11] Add Organization permissions for user and group - docs --- ...devops.organization.dsc-native.config.yaml | 27 ++++++++++++++++ ...ps.organization.dsc-powershell.config.yaml | 31 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 dsc-config/devops.organization.dsc-native.config.yaml create mode 100644 dsc-config/devops.organization.dsc-powershell.config.yaml diff --git a/dsc-config/devops.organization.dsc-native.config.yaml b/dsc-config/devops.organization.dsc-native.config.yaml new file mode 100644 index 0000000..770ad3a --- /dev/null +++ b/dsc-config/devops.organization.dsc-native.config.yaml @@ -0,0 +1,27 @@ +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + Organization: + type: string + defaultValue: YourOrganization + Token: + type: string + defaultValue: "**********" + +resources: + - name: OrgznationUserPermission + type: AzureDevOpsDscv3/OrganizationUserPermissionResource + properties: + UserPrincipalName: dev@contoso.com + Organization: "[parameters('Organization')]" + PermissionLevel: ProjectCollectionAdministrators + Ensure: Present + pat: "[parameters('Token')]" + - name: OrganizationGroupPermission + type: AzureDevOpsDscv3/OrganizationGroupPermissionResource + properties: + GroupOriginId: "00000000-0000-0000-0000-000000000000" # group descriptor + GroupDisplayName: GBA-AAD-ADO-Organization-permissions-DSC + Organization: "[parameters('Organization')]" + PermissionLevel: ProjectCollectionAdministrators + Ensure: Present + pat: "[parameters('Token')]" diff --git a/dsc-config/devops.organization.dsc-powershell.config.yaml b/dsc-config/devops.organization.dsc-powershell.config.yaml new file mode 100644 index 0000000..759b717 --- /dev/null +++ b/dsc-config/devops.organization.dsc-powershell.config.yaml @@ -0,0 +1,31 @@ +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + Organization: + type: string + defaultValue: YourOrganization + Token: + type: string + defaultValue: "**********" + +resources: +- name: Working with classic DSC resources for ADO Organization permissions + type: Microsoft.Windows/WindowsPowerShell + properties: + resources: + - name: OrgznationUserPermission + type: AzureDevOpsDscv3/OrganizationUserPermissionResource + properties: + UserPrincipalName: dev@contoso.com + Organization: "[parameters('Organization')]" + PermissionLevel: ProjectCollectionAdministrators + Ensure: Present + pat: "[parameters('Token')]" + - name: OrganizationGroupPermission + type: AzureDevOpsDscv3/OrganizationGroupPermissionResource + properties: + GroupOriginId: "00000000-0000-0000-0000-000000000000" # group descriptor + GroupDisplayName: GBA-AAD-ADO-Organization-permissions-DSC + Organization: "[parameters('Organization')]" + PermissionLevel: ProjectCollectionAdministrators + Ensure: Present + pat: "[parameters('Token')]" From d49038828cfebfad8408a2f9367fce7a681f50d6 Mon Sep 17 00:00:00 2001 From: Michal Machniak Date: Sat, 13 Jun 2026 21:08:51 +0200 Subject: [PATCH 09/11] Add config example for DSCv3 native and PowerShell --- ...devops.organization.dsc-native.config.yaml | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/dsc-config/devops.organization.dsc-native.config.yaml b/dsc-config/devops.organization.dsc-native.config.yaml index 770ad3a..2e78183 100644 --- a/dsc-config/devops.organization.dsc-native.config.yaml +++ b/dsc-config/devops.organization.dsc-native.config.yaml @@ -1,27 +1,41 @@ $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + +# --------------------------------------------------------------------------- +# Example DSC v3 configuration using native AzureDevOps resources +# --------------------------------------------------------------------------- +# Prerequisites: +# - pwsh (PowerShell 7+) installed and on PATH +# - dsc-resources\ folder on DSC_RESOURCE_PATH (or in the same directory) +# - A valid PAT stored in environment variable ADO_PAT +# +# Run: +# $env:DSC_RESOURCE_PATH = "D:\Git\AzureDevOpsDscv3\dsc-resources\project;D:\Git\AzureDevOpsDscv3\dsc-resources\organization-user;D:\Git\AzureDevOpsDscv3\dsc-resources\organization-group;D:\Git\AzureDevOpsDscv3\dsc-resources\project-user-permission;D:\Git\AzureDevOpsDscv3\dsc-resources\project-group-permission" +# dsc config set --document devops.example.dsc.config.yaml --parameters devops.example.dsc.config.parameters.yaml +# --------------------------------------------------------------------------- + parameters: Organization: type: string - defaultValue: YourOrganization + defaultValue: YourOrganizationName Token: type: string - defaultValue: "**********" + defaultValue: "*************************************" resources: - - name: OrgznationUserPermission - type: AzureDevOpsDscv3/OrganizationUserPermissionResource + - name: OrganizationUserPermission + type: AzureDevOps/OrganizationUserPermission properties: - UserPrincipalName: dev@contoso.com - Organization: "[parameters('Organization')]" - PermissionLevel: ProjectCollectionAdministrators - Ensure: Present + userPrincipalName: tomasz.kot@mmachniak.net + organization: "[parameters('Organization')]" + permissionLevel: ProjectCollectionAdministrators + ensure: Present pat: "[parameters('Token')]" - name: OrganizationGroupPermission - type: AzureDevOpsDscv3/OrganizationGroupPermissionResource + type: AzureDevOps/OrganizationGroupPermission properties: - GroupOriginId: "00000000-0000-0000-0000-000000000000" # group descriptor - GroupDisplayName: GBA-AAD-ADO-Organization-permissions-DSC - Organization: "[parameters('Organization')]" - PermissionLevel: ProjectCollectionAdministrators - Ensure: Present - pat: "[parameters('Token')]" + groupOriginId: "00000000-0000-0000-0000-000000000000" + groupDisplayName: Security Group Name + organization: "[parameters('Organization')]" + permissionLevel: ProjectCollectionAdministrators + ensure: Present + pat: "[parameters('Token')]" \ No newline at end of file From 2f441ca41919c096053bb577aa3ee0403b243772 Mon Sep 17 00:00:00 2001 From: Michal Machniak Date: Sat, 13 Jun 2026 21:09:47 +0200 Subject: [PATCH 10/11] Add config example for DSCv3 native and PowerShell --- module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 b/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 index 3cc2c65..d2e5717 100644 --- a/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 +++ b/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 @@ -101,7 +101,7 @@ DscResourcesToExport = @('ProjectResource', 'OrganizationUserResource', 'Organiz v. 1.0.1 - 2026-03-13 - Add class: ProjectUserPermissionResource - Add class: ProjectGroupPermissionResource - v. 1.0.2 - 2026-06-012 + v. 1.0.2 - 2026-06-12 - Add class: OrganizationUserPermissionResource - Add class: OrganizationGroupPermissionResource ' From bf54902694ab6ba0e80a4cc7998660975efba922 Mon Sep 17 00:00:00 2001 From: Michal Machniak Date: Sat, 13 Jun 2026 21:10:22 +0200 Subject: [PATCH 11/11] Add config example for DSCv3 native and PowerShell --- module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 | 1 + 1 file changed, 1 insertion(+) diff --git a/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 b/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 index d2e5717..f6d15a0 100644 --- a/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 +++ b/module/AzureDevOpsDscv3/AzureDevOpsDscv3.psd1 @@ -104,6 +104,7 @@ DscResourcesToExport = @('ProjectResource', 'OrganizationUserResource', 'Organiz v. 1.0.2 - 2026-06-12 - Add class: OrganizationUserPermissionResource - Add class: OrganizationGroupPermissionResource + - Add example configuration for OrganizationUserPermissionResource and OrganizationGroupPermissionResource ' # Prerelease string of this module