Powershell library for Bitbucket Cloud and Server
Here is a small library I wrote to help my Bitbucket Cloud to Bitbucket Server migration. I have no sharable notes about what order to use these functions in because I wrote them as I needed them or needed to alter them. I was also building on my credlib.ps1 which is not shared yet, but will come someday. You can also see this script in my gitlab: https://gitlab.com/bgstack15/former- gists/-/blob/master/bitbucketlib.ps1/bitbucketlib.ps1
# Startdate: 2020-05-18
# Title: Library for Bitbucket functions
# References:
# https://developer.atlassian.com/bitbucket/api/2/reference/meta/authentication
# https://developer.atlassian.com/server/bitbucket/how-tos/command-line-rest/
# https://github.com/AtlassianPS/BitbucketPS
# https://stackoverflow.com/questions/9825060/powershell-filtering-for-unique-values/9825218#9825218
# Useful starting values in procedural function at end.
# call: $CloudHeaders = Get-Bitbucket-Auth-Headers
Function Get-Bitbucket-Auth-Headers {
<# .Synopsis Either username and password, or credential are required. #>
param(
[Parameter(Mandatory = $false)][string]$Username,
[Parameter(Mandatory = $false)][string]$Password,
[Parameter(Mandatory = $false)][System.Management.Automation.PSCredential]$Credential
)
. '\\rdputils1\e$\scripts\Functions\credlib.ps1'
$internal_continue=$True
$Headers = @{}
if ($Username -ne $null -And $Password -ne $Null -And $Username -ne "" -And $Password -ne "") {
$secret = $password | ConvertTo-SecureString -AsPlainText -Force
$Credential = New-Object -TypeName "System.Management.Automation.PSCredential" -ArgumentList ${username}, ${secret}
} elseif ($Credential -ne $null) {
# pass through safely
} else {
if ($Username.tolower() -eq "prompt") {
$Credential = Get-Credential
} else {
Write-Error "Provide -Username and -Password, or -Credential."
$internal_continue = $False
break
}
}
if ($internal_continue) {
$Headers += Get-Basic-Base64-Auth-Headers -Credential $credential
$Headers
}
}
Function _Iterate-Values-From-BB-API {
[CmdletBinding()]
param(
[Parameter(Mandatory = $False)][Hashtable]$AuthHeaders = ${global:CloudHeaders},
[Parameter(Mandatory = $True )][string]$StartUri,
[Parameter(Mandatory = $False)][string]$Location = "cloud", # "cloud" or "local" which sets page defaults
[Parameter(Mandatory = $False)][string]$Descriptor = "item",
[Parameter(Mandatory = $False)][boolean]$SkipCount = $False
)
# StartUri could be any of these, for example:
# https://api.bitbucket.org/2.0/repositories/${org}
# https://api.bitbucket.org/2.0/workspaces/${org}/projects
# https://git.example.com/rest/api/1.0/repos
# https://git.example.com/rest/api/1.0/projects
# https://api.bitbucket.org/2.0/teams/${org}/permissions/repositories?q=permission%3D%22write%22
Begin {
$done = $False
$output = @()
$size = -1
$count = 0
$Baseuri = $StartUri
$keepcounting = $false
# All this just to get object count for the local Bitbucket, because its API does not list total object count on each return page.
if (!($SkipCount)) {
if ($Location -like "*local*" ) {
$pageSize = 250
$CountUri = "$($Baseuri)?limit=$($pageSize)"
$localcount = 0
While (!$done) {
Write-Progress -Activity "Counting objects" -Status "$($localcount)"
$interim = Invoke-RestMethod $CountUri -Method "GET" -Headers $AuthHeaders
if ( !($interim.isLastPage) ) {
$localcount += $pageSize
$CountUri = "$($Baseuri)?limit=$($pageSize)&start=$($localcount)"
} else {
$done = $True
$localcount += $interim.size
}
}
$done = $False
$size = $localcount
} Elseif ($Location -like "*cloud*" ) {
$CountUri = "$($Baseuri)"
$cloudcount = 0
While (!$done) {
Write-Progress -Activity "Counting $($Descriptor)s" -Status "$($cloudcount)"
$interim = Invoke-RestMethod $CountUri -Method "GET" -Headers $AuthHeaders
# short circuit if size is provided
If ($interim.size -ne $null) {
$cloudcount = $interim.size
$done = $True
} Elseif ($interim.next -eq $null) {
$cloudcount += $interim.pagelen
$done = $True
} Else {
$cloudcount += $interim.pagelen
$Counturi = $interim.next
}
}
$done = $False
$size = $cloudcount
}
} Else {
# skip the count!
$size = 10
}
}
Process {
Write-Verbose "Will look for $($size) $($descriptor)s"
$Uri = $StartUri
While (!$done) {
$interim = Invoke-RestMethod -Uri $Uri -Method "GET" -Headers $AuthHeaders
if (!($SkipCount) -And $size -eq -1) { # only run once because it will always be the same
if ($interim.size -ne $null) { $size = $interim.size }
Else { $keepcounting = $True; $size += $interim.values.count }
}
if ($keepcounting) { $size += $interim.values.count }
$interim.values | % {
$output += $_ ;
$count += 1 ;
[int]$percent = ($count/$size)*100
$percent = (@($percent, 100) | Measure -Minimum).Minimum
$percent = (@($percent, 0) | Measure -Maximum).Maximum
Write-Progress -Activity "Listing $($descriptor)" -Status "$count/$size" -PercentComplete $percent
}
# Bitbucket Cloud uses property "next" but on-prem server uses "nextPageStart"
If ( $interim.next -ne $Null ) {
$Uri = $interim.next
} Elseif ( $interim.nextPageStart -ne $Null -And (!($interim.isLastPage)) ) {
$Uri = "$($Baseuri)?start=$($interim.nextPageStart)"
} else { $done = $True }
}
}
End { $output }
}
Function List-All-Cloud-Projects {
[CmdletBinding()]
param(
[Parameter(Mandatory = $True )][Hashtable]$CloudHeaders,
[Parameter(Mandatory = $False)][string]$Server = "api.bitbucket.org",
[Parameter(Mandatory = $False)][string]$Instance = "example"
)
_Iterate-Values-From-BB-API -AuthHeaders $CloudHeaders -StartUri "https://${Server}/2.0/workspaces/${Instance}/projects" -Descriptor "project"
}
Function List-All-Cloud-Repos {
[CmdletBinding()]
param(
[Parameter(Mandatory = $True )][Hashtable]$CloudHeaders,
[Parameter(Mandatory = $False)][string]$Server = "api.bitbucket.org",
[Parameter(Mandatory = $False)][string]$Instance = "example"
)
_Iterate-Values-From-BB-API -AuthHeaders $CloudHeaders -StartUri "https://${Server}/2.0/repositories/${Instance}" -Descriptor "repo"
}
Function List-All-Local-Projects {
[CmdletBinding()]
param(
[Parameter(Mandatory = $True )][Hashtable]$LocalHeaders,
[Parameter(Mandatory = $False)][string]$Server = "gittest.example.com"
)
_Iterate-Values-From-BB-API -AuthHeaders $LocalHeaders -StartUri "https://${Server}/rest/api/1.0/projects" -Descriptor "project" -Location "local"
}
Function List-All-Local-Repos {
[CmdletBinding()]
param(
[Parameter(Mandatory = $True )][Hashtable]$LocalHeaders,
[Parameter(Mandatory = $False)][string]$Server = "gittest.example.com",
[Parameter(Mandatory = $False)][string]$Instance = "example"
)
_Iterate-Values-From-BB-API -AuthHeaders $LocalHeaders -StartUri "https://${Server}/rest/api/1.0/repos" -Descriptor "repo" -Location "local"
}
# idea: $foo = List-All-Cloud-Repos -CloudHeaders $CloudHeaders
# $out1 = $foo | Transform-Cloud-Repos-To-Useful
# MapFile is CSV with columns Old,New to transform project names
# Good mapfile is "U:\2020\05\project-cloud-to-onprem.csv"
Function Transform-Cloud-Repos-To-Useful {
<# .Synopsis add relevant columns #>
[CmdletBinding()]
param(
[Parameter(Mandatory = $False,ValueFromPipeLine = $true,ValueFromPipelineByPropertyName = $true)]$repo,
[Parameter(Mandatory = $True)]$MapFile
)
Begin {
$output = @() ;
If (!(Test-Path $MapFile)) {
Write-Error "MapFile must be a valid CSV file with columns named Old and New."
break
}
$newDict = @{} ; Import-Csv $MapFile | % { $newDict[$_.Old]=$_.New }
}
Process {
# a shallow copy is good enough for this object type. We do not have links to other objects in here, that we will care about or change or use.
# https://stackoverflow.com/questions/9581568/how-to-create-new-clone-instance-of-psobject-object/13275775#13275775
$newitem = $_.PsObject.Copy()
$newitem | Add-Member -NotePropertyName projectkey -NotePropertyValue $_.project.key
$newitem | Add-Member -NotePropertyName projectname -NotePropertyValue $_.project.name
$newitem | Add-Member -NotePropertyName projecttype -NotePropertyValue $_.project.type
$newitem | Add-Member -NotePropertyName NewProjectKey `
-NotePropertyValue $newDict[$newitem.projectkey]
$output += $newitem
}
End { $output ; }
}
# Useful for pulling columns from cloud repo definitions. Use after Transform-Repos-To-Useful
Function Select-Useful-Cloud-Repo-Columns {
[CmdletBinding()]
param(
[Parameter(Mandatory = $False,ValueFromPipeLine = $true,ValueFromPipelineByPropertyName = $true)]$repo
)
Process { $repo | Select-Object updated_on,size,slug,description, `
projectkey,projectname,NewProjectKey,fork_policy,uuid,full_name, `
name,language,created_on,has_issues }
}
Function Select-Useful-Local-Repo-Columns {
[CmdletBinding()]
param(
[Parameter(Mandatory = $False,ValueFromPipeLine = $true,ValueFromPipelineByPropertyName = $true)]$repo
)
Process { $repo | Select-Object slug,name,forkable,
@{L='ProjectKey';E={($_.Project.key)};},
@{L='ProjectName';E={($_.project.name)};},
@{L='fullName';E={("$($_.project.key)/$($_.slug)")};}
}
}
# low-level function for making a single repo with values
Function Act-Repo {
[CmdletBinding()]
param(
[Parameter(Mandatory = $False)][string]$Server = "gittest.example.com",
[Parameter(Mandatory = $True )][string]$Name,
[Parameter(Mandatory = $True )][string]$ProjectSlug,
[Parameter(Mandatory = $True )][string]$Action, # either Create or Delete
[Parameter(Mandatory = $False)][Hashtable]$LocalHeaders = ${global:LocalHeaders}
)
if (!($Action -in @("Create","Delete"))) {
Throw "Action must be either Create or Delete"
$null
}
if ($Action -eq "Create") {
$data = @{
name = $Name
scmID = "git"
} | ConvertTo-Json -Depth 3
$Uri = "https://$($Server)/rest/api/1.0/projects/$($ProjectSlug)/repos"
$Method = "POST"
} elseif ($Action -eq "Delete") {
$Uri = "https://$($Server)/rest/api/1.0/projects/$($ProjectSlug)/repos/$($Name)"
$Method = "DELETE"
}
try {
Write-Host "Trying to $Action repo $ProjectSlug/$Name"
$RestParams = @{
Uri = $Uri
Method = $Method
Headers = $LocalHeaders
ContentType = "application/json"
}
if ($data -ne $Null) { $RestParams += @{ Body = $Data } }
$response = Invoke-RestMethod @RestParams
}
<# catch [System.InvalidOperationException] { # we will get an invalid operation if the project already exists! Write-Warning "Repo $($ProjectSlug)/$($Name) already exists. Resuming..." Continue } #>
catch {
Write-Error $PSItem
}
$response
}
# Goal: create repositories based on CSV fields
# Act-All-Repos -MapFile "U:\2020\05\repos-testfull.csv" -Action "Create"
Function Act-All-Repos {
[CmdletBinding()]
param(
[Parameter(Mandatory = $False)][string]$Server = "gittest.example.com",
[Parameter(Mandatory = $True )][string]$MapFile,
[Parameter(Mandatory = $True )][string]$Action, # either Create or Delete
[Parameter(Mandatory = $False)][Hashtable]$LocalHeaders = ${global:LocalHeaders}
)
# DELETE is not implemented yet.
Begin {
If (!(Test-Path $MapFile)) {
Write-Error "MapFile must be a valid CSV file with columns named slug,NewProjectKey."
break
}
if (!($Action -in @("Create","Delete"))) {
Throw "Action must be either Create or Delete"
}
$map = Import-Csv $MapFile
}
Process {
# for every item in $map
$map | % {
Write-Host "Act-Repo -Name $($_.slug) -ProjectSlug $($_.NewProjectKey) -Action $Action -LocalHeaders LocalHeaders"
Write-Progress "Trying to $Action repo $($_.NewProjectKey)/$($_.slug)"
Act-Repo -Name "$($_.slug)" -ProjectSlug "$($_.NewProjectKey)" -Action $Action -LocalHeaders $LocalHeaders
}
}
End {
}
}
Function Act-Group {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory = $False)][string]$Server = "gittest.example.com",
[Parameter(Mandatory = $True )][string]$Name,
[Parameter(Mandatory = $True )][string]$Action, # either Create or Delete
[Parameter(Mandatory = $False)][Hashtable]$LocalHeaders = ${global:LocalHeaders}
)
if (!($Action -in @("Create","Delete"))) {
Throw "Action must be either Create or Delete"
}
$Uri = "https://${Server}/rest/api/1.0/admin/groups?name=${Name}"
$Method = "POST" ; if ($Action -eq "Delete") { $Method = "DELETE" }
Try {
If ($PsCmdlet.ShouldProcess("$($Action) group $($Name)")) {
$response = Invoke-RestMethod -Uri $Uri -Method $Method -Headers $LocalHeaders -ContentType "application/json" -Verbose:$False
}
} Catch {
if ($_.Exception.Response.StatusCode.value__ -eq 409) {
# just be silent when trying to create an extant group. 409: conflict.
#Write-Warning "Group ""${name}"" already exists, continuing."
} else {
Write-Error $_
}
}
# Bitbucket will return .name and .deletable if the object was successfully created.
}
Function Act-User-In-Group {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory = $False)][string]$Server = "gittest.example.com",
[Parameter(Mandatory = $False)][Hashtable]$AuthHeaders = ${global:CloudHeaders},
[Parameter(Mandatory = $True )][string]$GroupName,
[Parameter(Mandatory = $True )][string]$UserName, # safer to pass in uuid when location=cloud
[Parameter(Mandatory = $False)][string]$Instance = "example",
[Parameter(Mandatory = $True )][string]$Action, # either Add or Remove
[Parameter(Mandatory = $False)][string]$Location = "local" # "cloud" or "local"
)
Begin {
If (!($Action -in @("Add","Remove"))) {
Write-Error "Action must be in set @(""Add"",""Remove""). Aborted."
break
}
If (!($Location -in @("local","cloud"))) {
Write-Error "Location must be in set @(""local"",""cloud""). Aborted."
break
}
}
Process {
If ($Action -eq "Add") {
If ($Location -eq "local") {
$data = @{
user = $UserName
groups = @( $GroupName )
} | ConvertTo-Json -Depth 5
$Restparams = @{
Uri = "https://${Server}/rest/api/1.0/admin/users/add-groups"
Method = "POST"
}
} ElseIf ($Location -eq "cloud") {
# Reference: https://confluence.atlassian.com/bitbucket/groups-endpoint-296093143.html
# WORKS: Invoke-RestMethod -ContentType "application/json" -Uri "https://api.bitbucket.org/1.0/groups/example/devops/members/exampleteam/" -Body "{}" -method "PUT" -Headers $CloudHeaders
$data = "{}"
$Restparams = @{
Uri = "https://${Server}/1.0/groups/${Instance}/${GroupName}/members/${UserName}/"
Method = "PUT"
}
}
} Elseif ($Action -eq "Remove") {
If ($Location -eq "local") {
$data = @{
context = $UserName
itemName = $GroupName
} | ConvertTo-Json -Depth 5
$Restparams = @{
Uri = "https://${Server}/rest/api/1.0/admin/users/remove-group"
Method = "POST"
}
} ElseIf ($Location -eq "cloud") {
$data = "{}"
$Restparams = @{
Uri = "https://${Server}/1.0/groups/${Instance}/${GroupName}/members/${UserName}/"
Method = "DELETE"
}
}
}
# Generic headers, and the invocation
$Restparams += @{ ContentType = "application/json" }
$Restparams += @{ Body = $data }
$Restparams += @{ Headers = $AuthHeaders }
#$Restparams
#$Restparams.body
#$response = Invoke-RestMethod -Method "POST" "https://${Server}/rest/api/1.0/admin/users/add-groups" -Body $data -ContentType "application/json" -Headers $AuthHeaders
Try {
#Write-Verbose "$($Action) user $($Username) to group $($GroupName)"
#$Restparams.Uri
# WORKHERE
If ($PsCmdlet.ShouldProcess("$($Action) user $($UserName) to group $($GroupName)")) {
$response = Invoke-RestMethod @Restparams -Verbose:$False
}
}
Catch {
If ($Action -eq "Remove" -And $_.Exception.Response.StatusCode.value__ -eq 404) {
# just be silent when trying to delete a non-existent group.
#Write-Warning "Group ""${name}"" already exists, continuing."
} ElseIf ($Action -eq "Add" -And $_.Exception.Response.StatusCode.value__ -eq 409) {
# be silent when trying to add a user to a group he is already in
} Else {
Write-Error $_
Write-Error $data
}
}
}
}
Function WritePermissionsCsv_Message {
Write-Error "WritePermissionsCsv must be a valid CSV file with columns named Repo, Name. Probably use useful-write-permissions.csv from Build-Useful-Write-Permissions-List."
}
Function ReposCsv_Message {
Write-Error "ReposCsv must be a valid CSV with columns slug, NewProjectKey. Probably use bbcloudrepos.csv for real migration, or gittest-importtest-repos.csv."
}
# This is the individual step for making a $REPONAME-write group
Function Act-RepoPermission-Group {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory = $False)][string]$Server = "gittest.example.com",
[Parameter(Mandatory = $True )][string]$Name,
[Parameter(Mandatory = $True )][string]$ProjectSlug,
[Parameter(Mandatory = $True )][string]$Action, # either Grant or Revoke
[Parameter(Mandatory = $False)][Hashtable]$LocalHeaders = ${global:LocalHeaders},
[Parameter(Mandatory = $False)][boolean]$Populate = $false, # if True, then add users based on PermissionsCsv
[Parameter(Mandatory = $False)][string]$WritePermissionsCsv #
)
# assumptions: repo already exists.
Begin {
If ($Populate) {
Try {
If (!(Test-Path $WritePermissionsCsv)) { WritePermissionsCsv_Message ; break ; }
$WritePermissions = Import-Csv $WritePermissionsCsv
$ColumnNames = $WritePermissions[0].PsObject.Properties.Name
If (!("Repo" -in $ColumnNames) -Or !("Name" -In $ColumnNames)) { WritePermissionsCsv_Message ; break ; }
}
Catch { WritePermissionsCsv_Message ; break ; }
}
If (!($Action -in @("Grant","Revoke"))) {
Throw "Action must be either Grant or Revoke"
}
}
Process {
$Uri = "https://$($Server)/rest/api/1.0/projects/$($ProjectSlug)/repos/$($Name)/permissions/groups"
$WriteGroup = "$($Name)-write"
$WritePermissionName = "REPO_WRITE"
If ($Action -eq "Grant") {
# flow: create group and grant it permission
#If ($PsCmdlet.ShouldProcess("Create group $($WriteGroup)")) { Act-Group -Action "Create" -Name "$($WriteGroup)" -LocalHeaders $LocalHeaders -Server $Server ; }
Act-Group -Action "Create" -Name "$($WriteGroup)" -LocalHeaders $LocalHeaders -Server $Server
$Restparams = @{
Uri = "$($Uri)?permission=$($WritePermissionName)&name=$($WriteGroup)"
Method = "PUT"
Headers = $LocalHeaders
ContentType = "application/json"
}
#$RestParams.Uri
If ($PsCmdlet.ShouldProcess("$($Action) group $($WriteGroup) write perms to repo $($Name)")) {
Invoke-RestMethod @RestParams -Verbose:$False
}
# And now do the populate step.
If ($Populate -And $PsCmdlet.ShouldProcess("Populate group $($WriteGroup)")) {
# For every permission entry where Repo column = $Name parameter for this function,
$countUsersAdded = 0
$WritePermissions | ? { $_.Repo -eq "$($Name)" } | % {
# add user to the group
#Write-Host "Please add user $($_.name) to $WriteGroup"
If ( "$($_.name)" -ne "") {
Try {
Act-User-In-Group -AuthHeaders $LocalHeaders -Server $Server -GroupName $WriteGroup -UserName $_.name -Action "Add" -Location "local" -WhatIf:$False -Confirm:$False
$countUsersAdded += 1
}
Catch {
Write-Error $PSItem
$countUsersAdded -= 1
}
} Else { # samaccountname is blank
Write-Warning "Unable to add user $_"
}
}
Write-Verbose "Added $countUsersAdded to group ""$WriteGroup"""
}
} elseif ($Action -eq "Revoke") {
# flow: remove permission to group and delete it
$Restparams = @{
Uri = "$($Uri)?name=$($WriteGroup)"
Method = "DELETE"
Headers = $LocalHeaders
ContentType = "application/json"
}
If ($PsCmdlet.ShouldProcess("$($Action) group $($WriteGroup) write perms to repo $($Name)")) { Invoke-RestMethod @RestParams -Verbose:$False ; }
Act-Group -Action "Delete" -Name "$($WriteGroup)" -LocalHeaders $LocalHeaders -Server $Server
}
}
}
# goal: given the input file $WritePermissionsCsv, call Act-RepoPermission-Group for each repo.
Function Act-All-RepoPermission-Groups {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory = $False)][string]$Server = "gittest.example.com",
[Parameter(Mandatory = $True )][string]$Action, # either Grant or Revoke
[Parameter(Mandatory = $False)][Hashtable]$LocalHeaders = ${global:LocalHeaders},
[Parameter(Mandatory = $False)][boolean]$Populate = $false, # if True, then add users based on PermissionsCsv
[Parameter(Mandatory = $False)][string]$WritePermissionsCsv,
[Parameter(Mandatory = $True )][string]$ReposCsv # columns slug, NewProjectKey, probably gittest-importest-repos.csv or bbcloudrepos.csv
)
Begin {
Try {
If (!(Test-Path $WritePermissionsCsv)) { WritePermissionsCsv_Message ; break ; }
$WritePermissions = Import-Csv $WritePermissionsCsv
$ColumnNames = $WritePermissions[0].PsObject.Properties.Name
If (!("Repo" -in $ColumnNames) -Or !("Name" -In $ColumnNames)) { WritePermissionsCsv_Message ; break ; }
}
Catch { WritePermissionsCsv_Message ; break ; }
Try {
If (!(Test-Path $ReposCsv)) { ReposCsv_Message ; break ; }
$Repos = Import-Csv $ReposCsv
$ColumnNames = $Repos[0].PsObject.Properties.Name
If (!("slug" -in $ColumnNames) -Or !("NewProjectKey" -In $ColumnNames)) { ReposCsv_Message ; break ; }
}
Catch { ReposCsv_Message ; break ; }
If (!($Action -in @("Grant","Revoke"))) {
Throw "Action must be either Grant or Revoke"
}
$count = 0
$size = ($Repos | Group-Object { $_.Repo }).count
}
Process {
$Repos | % {
# for each repository listing
Try {
Act-RepoPermission-Group -Name $_.slug -ProjectSlug $_.NewProjectKey -Action $Action -LocalHeaders $Localheaders -Server $Server -Populate $Populate -WritePermissionsCsv $WritePermissionsCsv
$count += 1 ;
[int]$percent = ($count/$size)*100
$percent = (@($percent, 100) | Measure -Minimum).Minimum
$percent = (@($percent, 0) | Measure -Maximum).Maximum
Write-Progress -Activity "$($Action) for repo" -Status "$count/$size" -PercentComplete $percent
}
Catch {
$RepoCount -= 1
}
}
}
}
# List users who have write permission on this one repo.
Function List-Write-Users-For-Cloud-Repo {
[CmdletBinding()]
param(
[Parameter(Mandatory = $True )][Hashtable]$CloudHeaders,
[Parameter(Mandatory = $False)][string]$Server = "api.bitbucket.org",
[Parameter(Mandatory = $False)][string]$Instance = "example",
[Parameter(Mandatory = $True )][string]$Name,
[Parameter(Mandatory = $False)][bool]$SkipCount = $False,
[Parameter(Mandatory = $False)][string]$Property = "uuid"
)
$interim = _Iterate-Values-From-BB-API -StartUri "https://${Server}/2.0/teams/${Instance}/permissions/repositories/${Name}?q=permission%3D%22write%22" -Location "cloud" -SkipCount $SkipCount -Descriptor "for repo ""$Name"" write permission"
$output = @()
$interim | % {
<# # In case I want to have a multi-field object in the array. $output += @{ username = $_.user.display_name repo = $_.repository.full_name }#>
#$output += $_.user.display_name
$output += $_.user.$($Property)
}
$output
}
# Return array of @{ Repo = "example/foobar", User = "bgstack15"} objects
# Usage:
# $cloudRepos = List-All-Cloud-Repos -CloudHeaders $CloudHeaders -Verbose
# $allWritePermissions = $cloudRepos | List-Write-Users-For-All-Cloud-Repos -CloudHeaders $CloudHeaders
# $allWritePermissions | Export-Csv U:\2002\05\all-write-permissions.csv -NoTypeInformation
# Next steps:
# use a function that makes ${Repo}-write group and adds the users
Function List-Write-Users-For-All-Cloud-Repos {
[CmdletBinding()]
param(
[Parameter(Mandatory = $True )][Hashtable]$CloudHeaders,
[Parameter(Mandatory = $False)][string]$Server = "api.bitbucket.org",
[Parameter(Mandatory = $False)][string]$Instance = "example",
[Parameter(Mandatory = $False,ValueFromPipeLine = $true,ValueFromPipelineByPropertyName = $true)]$Repos
)
Begin {
# list all repos
#$Repos.count
$repoCount = 0
#if ($Repos -eq $Null) { $Repos = List-All-Cloud-Repos -CloudHeaders $CloudHeaders }
}
Process {
# iterate through all repos
#$repoCount = 0
$_ | % {
$repoCount += 1
#$_.name
$thisRepo = $_.name
$interim = List-Write-Users-For-Cloud-Repo -CloudHeaders $CloudHeaders -Server $Server -Instance $Instance -Name $_.name -SkipCount $True
Write-Host "Working on repo #$repoCount ""$thisRepo"""
#$interim[3]
$new = @()
ForEach ($item in $interim) {
#[psCustomObject]$newitem = $item.PsObject.Copy() # shallow copy is good enough
$newItem = New-Object -TypeName psobject
$newItem | Add-Member -MemberType NoteProperty -Name "Repo" -Value $thisRepo
$newItem | Add-Member -MemberType NoteProperty -Name "User" -Value $item
$new += $newitem
}
$new
}
}
}
# take a list of user ids from pipeline and convert to usable property
# I don't know where I was going with this.
Function Convert-User-To-Property-STUB {
[CmdletBinding()]
param(
[Parameter(Mandatory = $True )][Hashtable]$CloudHeaders,
[Parameter(Mandatory = $False)][string]$Server = "api.bitbucket.org",
[Parameter(Mandatory = $False)][string]$Instance = "example",
[Parameter(Mandatory = $False,ValueFromPipeLine = $true,ValueFromPipelineByPropertyName = $true)]$UserIds
)
Begin {
$userCount = 0
Write-Error "STUB! I cannot remember where I was going with this but it looks cool."
}
Process {
$_ | %{
$userCount += 1
}
}
}
Function List-Groups {
[CmdletBinding()]
param(
[Parameter(Mandatory = $False)][string]$Server = "gittest.example.com",
[Parameter(Mandatory = $False)][Hashtable]$AuthHeaders = ${global:CloudHeaders},
[Parameter(Mandatory = $False)][string]$Instance = "example",
[Parameter(Mandatory = $False)][string]$Location = "local", # "cloud" or "local"
[Parameter(Mandatory = $False)][boolean]$SkipCount = $False
)
Begin {
If (!($Location -in @("local","cloud"))) {
Write-Error "Location must be in set @(""local"",""cloud""). Aborted."
break
}
}
Process {
If ($Location -eq "cloud") {
# we can cheat here because I know there are only 18 groups defined in bitbucket cloud, so no concerns about pagination. Plus this api endpoint is very poorly documented because it's a deprecated one despite the fact the 2.0 groups endpoint does not exist; so pagination methodology is undetermined.
$Uri = "https://${Server}/1.0/groups/${Instance}/"
Invoke-RestMethod -ContentType "application/json" -Uri $Uri -method "GET" -Headers $AuthHeaders
} ElseIf ($Location -eq "local") {
$Uri = "https://${Server}/rest/api/1.0/admin/groups"
_Iterate-Values-From-BB-API -AuthHeaders $AuthHeaders -StartUri $Uri -Location $Location -Descriptor $Group -SkipCount $SkipCount
}
}
}
# Usage:
# $cloudgroups = List-Groups -Location "cloud" -Server "api.bitbucket.org"
# $cloudmemberships = $cloudgroups | List-Memberships-From-Cloud-Groups
# $cloudmemberships | Export-Csv -NoTypeInformation "U:\2020\05\cloud-memberships.csv"
Function List-Memberships-From-Cloud-Groups {
# $cloudgroups = List-Groups -Location "cloud" -Server "api.bitbucket.org"
# $cloudmemberships = $cloudgroups | Select-Object name,members,slug | Select-Object -Property slug,name -ExpandProperty members | Select-Object -Property display_name,account_id,uuid,nickname,@{l="groupname";e={$_.name}},@{l="groupslug";e={$_.slug}}
[CmdletBinding()]
Param(
[Parameter(Mandatory = $False,ValueFromPipeLine = $true,ValueFromPipelineByPropertyName = $true)]$groups
)
Begin {
}
Process {
$_ | Select-Object name,members,slug | Select-Object -Property name,slug -ExpandProperty members -Erroraction "silentlycontinue" | Select-Object -Property display_name,account_id,uuid,nickname,@{l="groupname";e={$_.name}},@{l="groupslug";e={$_.slug}}
}
}
# goal: take $cloudmemberships (with attributes uuid, groupname) and take $Action on them
Function Act-All-Cloud-Memberships-From-List {
[CmdletBinding()]
param(
[Parameter(Mandatory = $False)][string]$Server = "api.bitbucket.org",
[Parameter(Mandatory = $False)][Hashtable]$AuthHeaders = ${global:CloudHeaders},
[Parameter(Mandatory = $False)][string]$Instance = "example",
[Parameter(Mandatory = $True )][string]$Action, # either Add or Remove
[Parameter(Mandatory = $False)][string]$Location = "cloud", # "cloud" or "local"
[Parameter(Mandatory = $False,ValueFromPipeLine = $true)]$MembershipsPipeLine,
[Parameter(Mandatory = $False)][string]$MembershipsCsv,
[Parameter(Mandatory = $False)][PSObject]$Memberships,
[Parameter(Mandatory = $False)][string]$GroupMatchProperty = "groupname"
# WORKHERE
)
Begin {
If (!($Action -in @("Add","Remove"))) {
Write-Error "Action must be in set @(""Add"",""Remove""). Aborted."
break
}
If (!($Location -in @("local","cloud"))) {
Write-Error "Location must be in set @(""local"",""cloud""). Aborted."
break
}
If (!($GroupMatchProperty -in @("groupname","groupslug"))) {
Write-Error "GroupMatchProperty must be in set @(""groupname"",""groupslug""). Aborted."
break
}
}
Process {
If ($MembershipsPipeLine -eq $Null) {
If ($Memberships -eq $Null) {
If ("$($MembershipsCsv)" -eq "" -Or !(Test-Path $MembershipsCsv)) {
Write-Error "Either pipe in or provide -Memberships with uuid,display_name,groupname values or provide MembershipsCsv file with such-named columns. Aborted."
break
}
Write-Warning "using csv"
$Memberships = Import-Csv $MembershipsCsv
} Else {
Write-Warning "using memberships var directly"
}
} Else {
#Write-Warning "using pipeline"
$Memberships = $MembershipsPipeLine
}
$Memberships | ? { $_.groupname -ne "Administrators" } | % {
Write-Verbose "$($Action) user $($_.display_name) ( uuid $($_.uuid) ) to group $($_.$($GroupMatchProperty))"
Act-User-In-Group -Server $Server -AuthHeaders $AuthHeaders -GroupName "$($_.$($GroupMatchProperty))" -UserName $_.uuid -Instance $Instance -Action $Action -Location $Location
}
}
}
# Goal: take Repo,User list and the display_name,email list and output a repo,samaccountname,user,email table.
# The NamesToEmailsCsv is a manually curated list that is boiled down from all the Users listed from the WritePermissionsCsv ( | sort | uniq ) with email address for each user.
# Usage:
# $list = Build-Useful-Write-Permissions-List -WritePermissionsCsv "U:\2020\05\all-write-permissions.csv" -NamesToEmailsCsv "U:\2020\05\bitbucket-emails.csv"
# $list | Export-Csv -NoTypeInformation "U:\2020\05\useful-write-permissions.csv"
Function Build-Useful-Write-Permissions-List {
[CmdletBinding()]
param(
[Parameter(Mandatory = $True )][string]$WritePermissionsCsv,
[Parameter(Mandatory = $True )][string]$NamesToEmailsCsv,
[Parameter(Mandatory = $False)][string]$DefaultDomain = "example.com"
)
Begin {
If (!(Test-Path $WritePermissionsCsv)) {
Write-Error "WritePermissionsCsv must be a valid CSV file with columns named Repo, User."
break
}
If (!(Test-Path $NamesToEmailsCsv)) {
Write-Error "NamesToEmailsCsv must be a valid CSV file with columns named display_name,email."
break
}
$Output = @()
$count = 0
$WritePermissions = Import-Csv $WritePermissionsCsv
$NamesToEmails = Import-Csv $NamesToEmailsCsv
$size = $WritePermissions.count
}
Process {
ForEach ($wpitem in $WritePermissions) {
if (!($wpitem.User -In $NamesToEmails.display_name)) {
# just be silent
#Write-Error "Cannot find $($wpitem.User) in NamesToEmails"
} Else {
$count += 1 ;
[int]$percent = ($count/$size)*100
$percent = (@($percent, 100) | Measure -Minimum).Minimum
$percent = (@($percent, 0) | Measure -Maximum).Maximum
Write-Progress -Activity "Listing perm" -Status "$count/$size" -PercentComplete $percent
$NamesToEmails | % {
$NameToEmailitem = $_
if ($NameToEmailItem.display_name -eq $wpitem.User) {
If ("$($NameToEmailItem.email)" -ne "") {
$thisADUser = Get-ADUser -Filter "mail -eq '$($NameToEmailItem.email)'" -Properties mail
if (!($thisADUser -ne $null)) {
# if it is still empty, treat the stored email as a upn
$thisADUser = Get-ADUser -Filter "userprincipalname -eq '$($NameToEmailItem.email)'" -Properties mail
}
if (!($thisADUser -ne $null)) {
# if still empty, try it as sammaccountname@domain
$matchString = $($NameToEmailItem.email.split("@")[0])
$thisADUser = Get-ADUser -Filter "samaccountname -eq '$matchString'" -Properties mail
}
if (!($thisADUser -ne $null)) {
Write-Error "Unable to match $($NameToEmailItem.email) to an AD user!"
} else {
$newItem = New-Object -TypeName psobject
$newItem | Add-Member -MemberType NoteProperty -Name "Repo" -Value $wpitem.Repo
$newItem | Add-Member -MemberType NoteProperty -Name "Displayname" -Value $wpitem.User
$newItem | Add-Member -MemberType NoteProperty -Name "Mail" -Value $thisADUser.mail
$newItem | Add-Member -MemberType NoteProperty -Name "Name" -Value $thisADUser.samaccountname
$Output += $newItem
Write-Verbose $newItem
}
} Else { # so if there is no email address listed for this user
# well apparently it lists the non-email-mapped ones a lot, so just be quiet.
#Write-Error "No email for user $($NameToEmailItem.display_name)"
}
}
}
}
}
}
End { $Output ; }
}
# Procedural section
if ($LoadVars) {
. '\\rdputils1\e$\scripts\Functions\credlib.ps1'
$cloudcred = Get-Shared-Credential -User "exampleteam" -PasswordCategory exampleteam
$CloudHeaders = Get-Bitbucket-Auth-Headers -Credential $cloudcred
$bbprodcred = Get-Shared-Credential -User "serviceaccount"-PasswordCategory "serviceaccount"
$bbprodHeaders = Get-Bitbucket-Auth-Headers -Credential $bbprodcred
$localcred = $bbprodcred
$localHeaders = Get-Bitbucket-Auth-headers -Credential $localcred
Remove-Variable LoadVars
}
Some thoughts that might guide users
The auth-headers functions depend on the aforementioned credlib library which is not here, but the Get-Basic-Base64-Auth-Headers function basically returns a hashtable of Authorization: Basic aabbbxx== which is just the base64 encoding of the "username:password" syntax. I should document it better, but I'll save it for the credlib post. Some of the best work is in the "private" function _Iterate-Values-From-BB-API. It attempts to count the number of objects being counted, and display the percentage, unless -SkipCount $True. This one function is currently written for Bitbucket Cloud as well as Bitbucket Server 5.16.0 and above. The pagination across API versions is a little different, so the function is a little complex in how it collects the values. If a function uses -LocalHeaders, then I tested it only against the Bitbucket Server, and the same for -CloudHeaders for Bitbucket Cloud. I used -AuthHeaders when a function was tested against both. The functions tend to get fancier towards the bottom, including even from-pipeline values or by parameter, or from -CsvFile options!
Comments