From 3de2e1892a23e495676b0b253574307862293f76 Mon Sep 17 00:00:00 2001 From: georgiaschafer Date: Thu, 5 Jun 2025 14:18:28 -0500 Subject: [PATCH 1/3] Add consolidated organization management script Adapted from scripts found here - https://github.com/bitwarden-labs/admin-scripts/tree/main/Powershell Creates collection for new users, assigns Administrator group to all collections, moves collections created at the root to nest under the first user with permissions Optimizes functionality compared to original scripts by reducing calls to bw for each user --- Powershell/bwUpdateOrganization.ps1 | 182 ++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 Powershell/bwUpdateOrganization.ps1 diff --git a/Powershell/bwUpdateOrganization.ps1 b/Powershell/bwUpdateOrganization.ps1 new file mode 100644 index 0000000..858036e --- /dev/null +++ b/Powershell/bwUpdateOrganization.ps1 @@ -0,0 +1,182 @@ +# Script: bwUpdateOrganization.ps1 +# Date: 20250604 +# Description: Adapted from scripts found here - https://github.com/bitwarden-labs/admin-scripts/tree/main/Powershell +# Creates collection for new users, assigns Administrator group to all collections, moves collections created +# at the root to nest under the first user with permissions +# Optimizes functionality compared to original scripts by reducing calls to bw for each user +# +# Depends on file "secureString.txt" which can be created by first running: +# Read-Host "Enter Master Password" -AsSecureString | ConvertFrom-SecureString | Out-File "secureString.txt" +# Depends on file "secureString_secret.txt" which can be created by first running: +# Read-Host "Enter client_secret" -AsSecureString | ConvertFrom-SecureString | Out-File "secureString_secret.txt" +# jq is required in $PATH https://stedolan.github.io/jq/download/ +# bw is required in $PATH and logged in and unlocked https://bitwarden.com/help/cli/ + +#log the script notices +Start-Transcript "" #set your transcript location + +##################################### +# # +# Script setup section # +# # +##################################### + +# Handle API URLs +$organization_id = "" # Set your Org ID +$cloud_flag = 1 # Self-hosted Bitwarden or Cloud? +if ($cloud_flag -eq 1) { + $api_url = "https://api.bitwarden.com" + $identity_url = "https://identity.bitwarden.com" +} else { + $api_url = "https://YOUR-FQDN/api" # Set your Self-Hosted API URL + $identity_url = "https://YOUR-FQDN/identity" # Set your Self-Hosted Identity URL +} + +# Set up CLI and API auth +$org_client_secret = Get-Content "secureString_secret.txt" | ConvertTo-SecureString +$client_creds = New-Object System.Management.Automation.PSCredential "null", $org_client_secret +$org_client_secret_key = , $client_creds.GetNetworkCredential().password +$org_client_id = "organization." + $organization_id + +# Get Access Token +$headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]" +$headers.Add('Content-Type','application/x-www-form-urlencoded') +$body = "grant_type=client_credentials&scope=api.organization&client_id=$org_client_id&client_secret=$org_client_secret_key" +$bearer_token = (Invoke-RestMethod -Method POST -Uri $identity_url/connect/token -Headers $headers -Body $body).access_token + +if($bearer_token) { Write-Output "`n Bearer Token: Success"} else {Write-Output "Bearer Token: Failure"} + +# update headers to use the bearer token +$headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]" +$headers.Add('Authorization',('Bearer {0}' -f $bearer_token)) +$headers.Add('Accept','application/json') +$headers.Add('Content-Type','application/json') + +# Perform CLI auth +$env:BW_CLIENTID = "user.eb7998e6-ab5f-4027-8d0a-b2f0011b2af9" # service account client id +$password = Get-Content "secureString_UserSecret.txt" | ConvertTo-SecureString +$cred = New-Object System.Management.Automation.PSCredential "null", $password +$env:BW_CLIENTSECRET = , $cred.GetNetworkCredential().password # service account client secret +.\bw login --apikey + +$password = Get-Content "secureString.txt" | ConvertTo-SecureString # service account master password +$cred = New-Object System.Management.Automation.PSCredential "null", $password +$session_key = , $cred.GetNetworkCredential().password | powershell -c '.\bw unlock --raw' + +if($session_key) { Write-Output "`n Session Key: Success"} else {Write-Output "Session Key: Failure"} + +# Fetch the list of Members and collections +$org_members = (Invoke-RestMethod -Method GET -Uri $api_url/public/members -Headers $headers) | Select-Object data +$values = $org_members.psobject.Properties.Value | Select-Object name,id,status,email +$orgCollections = (.\bw --session $session_key list org-collections --organizationid $organization_id) + +##################################### +# # +# Configure new users # +# # +##################################### +# For each Member, create a Collection, and then assign that Member to it + +$groupId = "" #set the Administrators group guid +$t = "^Users/.*$" # regex to use when filtering the collections, I tried renaming this one to $query and jq breaks, so it is staying $t +#filter to the base user collections +$userCollections = $orgCollections | .\jq -c --arg t "$t" '.[] | select(.name|test("$t"))' + +ForEach ($membervalues in $values) { + $membername = ($membervalues.email -split "@")[0] #ignore the name field, standardizing on the first part of the email address + $memberid = $membervalues.id + $memberstatus = $membervalues.status + $memberemail = $membervalues.email + $existingcollection = "" + + # Check if the Collection already exists + $query = "Users/" + $membername + $existingcollection = $userCollections -match $query + + #skip if the user exists or it is the Admin account, or the user is revoked + if ($existingcollection -or ($membername -eq "Admin") -or ($memberstatus -eq -1)) { + + Write-Output "`n $membername already has a Collection, skipping" + + } + else { + + #create the collection and add Administrators group to it + #jq is inserting the values into the template + #Get the template: (.\bw --session $session_key get template org-collection) + #Set the jq variables: .\jq --arg n "$query" --arg c "$organization_id" --arg g "$groupId" --arg u "$memberid" + #Insert into the json string: '.name="$n" | .organizationId="$c" | .groups[0].id="$g" | .groups[0].manage="true" | del(.groups[1]) | .users=[{"id":$u, "readOnly":false, "hidePasswords":false, "manage":true}]' + #Encode the json: | .\bw encode + #Create the collection: | .\bw --session $session_key create org-collection --organizationid $organization_id + #Filter to new collection Id: | .\jq -r '.id' + $collectionid = (.\bw --session $session_key get template org-collection) | .\jq --arg n "$query" --arg c "$organization_id" --arg g "$groupId" --arg u "$memberid" '.name="$n" | .organizationId="$c" | .groups[0].id="$g" | .groups[0].manage="true" | del(.groups[1]) | .users=[{"id":$u, "readOnly":false, "hidePasswords":false, "manage":true}]' | .\bw encode | .\bw --session $session_key create org-collection --organizationid $organization_id | .\jq -r '.id' + Write-Output "`n Created Collection for $membername" + + } + + #Confirm unconfirmed users - note this is not recommended, uncomment to use + #if ($memberstatus -eq 1) { + # + # .\bw --session $session_key confirm org-member $memberid --organizationid $organization_id + # Write-Output "`n Confirmed user: $membername" + #} + +} + +##################################### +# # +# Configure nested collection # +# permissions # +# # +##################################### + +Write-Output "`n Checking nested collection permissions and adding the Administrators group" +$t = "^Users/.*/.*" +$nestedCollections = $orgCollections | .\jq -c --arg t "$t" '.[] | select(.name|test("$t"))' | .\jq -r '.id' +$org_groups = (Invoke-RestMethod -Method GET -Uri $api_url/public/groups -Headers $headers) | Select-Object data +$adminGroupCollections = ($org_groups.data | where {$_.id -eq $groupId}).collections +$adminPermissionsWrong = $adminGroupCollections | where {($_.manage -eq $false) -or ($_.readOnly -eq $true) -or ($_.hidePasswords -eq $true)} + +ForEach ($nestedCollection in $nestedCollections) { + + if ((!($adminGroupCollections -match $nestedCollection)) -or ($adminPermissionsWrong -match $nestedCollection)) { + $updateCollection = (.\bw --session $session_key get org-collection "$nestedCollection" --organizationid $organization_id) | .\jq --arg i $groupId '.groups+=[{"id": $i,"readOnly": "false","hidePasswords": "false","manage": "true"}]' | .\bw encode | .\bw --session $session_key edit org-collection "$nestedCollection" --organizationid $organization_id + } +} + +##################################### +# # +# Move unnested collections # +# # +##################################### + +Write-Output "`n Checking for un-nested collections" +$t = "^(Users|Archived Accounts|Default collection|Unassigned)" +$unnestedCollections = $orgCollections | .\jq -c --arg t "$t" '.[] | select(.name|test("$t")|not)' +$unnestedCollections = $unnestedCollections | convertfrom-json + +ForEach ($collection in $unnestedCollections) { + $colId = $collection.id + $colName = $collection.name + $item = (.\bw --session $session_key get org-collection "$colId" --organizationid $organization_id) + $itemGroups = $item | .\jq -r '.groups' + $itemId = $item | .\jq -r '.id' + $userId = $item | .\jq -r '.users[0].id' + $user = ($values | where {$_.id -eq $userId}).email -split "@" + $newName = "Users/" + $user[0] + "/$colName" + + if (!($itemGroups -match $groupId)) { + $updateCollection = (.\bw --session $session_key get org-collection "$colId" --organizationid $organization_id) | .\jq --arg i $groupId --arg n $newName '.groups+=[{"id": $i,"readOnly": "false","hidePasswords": "false","manage": "true"}] | .name=$n ' | .\bw encode | .\bw --session $session_key edit org-collection $itemId --organizationid $organization_id + } else { + $updateCollection = (.\bw --session $session_key get org-collection "$colId" --organizationid $organization_id) | .\jq --arg n $newName '.name=$n ' | .\bw encode | .\bw --session $session_key edit org-collection $itemId --organizationid $organization_id + } +} + +#clear plaintext secrets +$env:BW_CLIENTID = '' +$env:BW_CLIENTSECRET = '' +$org_client_secret_key = '' +.\bw logout +$session_key = '' + +Stop-Transcript \ No newline at end of file From 19f4a213303101a18473661e90c93bcd60306316 Mon Sep 17 00:00:00 2001 From: georgiaschafer Date: Thu, 5 Jun 2025 14:27:34 -0500 Subject: [PATCH 2/3] Update and rename bwUpdateOrganization.ps1 to bwUpdateOrgCollections.ps1 Rename and fix comments --- ...ization.ps1 => bwUpdateOrgCollections.ps1} | 54 ++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) rename Powershell/{bwUpdateOrganization.ps1 => bwUpdateOrgCollections.ps1} (90%) diff --git a/Powershell/bwUpdateOrganization.ps1 b/Powershell/bwUpdateOrgCollections.ps1 similarity index 90% rename from Powershell/bwUpdateOrganization.ps1 rename to Powershell/bwUpdateOrgCollections.ps1 index 858036e..cc4551e 100644 --- a/Powershell/bwUpdateOrganization.ps1 +++ b/Powershell/bwUpdateOrgCollections.ps1 @@ -12,14 +12,14 @@ # jq is required in $PATH https://stedolan.github.io/jq/download/ # bw is required in $PATH and logged in and unlocked https://bitwarden.com/help/cli/ -#log the script notices -Start-Transcript "" #set your transcript location +######################################### +# # +# Script setup section # +# # +######################################### -##################################### -# # -# Script setup section # -# # -##################################### +#log the script progress +Start-Transcript "" #set your transcript location # Handle API URLs $organization_id = "" # Set your Org ID @@ -70,11 +70,11 @@ $org_members = (Invoke-RestMethod -Method GET -Uri $api_url/public/members -Head $values = $org_members.psobject.Properties.Value | Select-Object name,id,status,email $orgCollections = (.\bw --session $session_key list org-collections --organizationid $organization_id) -##################################### -# # -# Configure new users # -# # -##################################### +######################################### +# # +# Configure new users # +# # +######################################### # For each Member, create a Collection, and then assign that Member to it $groupId = "" #set the Administrators group guid @@ -123,12 +123,12 @@ ForEach ($membervalues in $values) { } -##################################### -# # -# Configure nested collection # -# permissions # -# # -##################################### +######################################### +# # +# Configure nested collection # +# permissions # +# # +######################################### Write-Output "`n Checking nested collection permissions and adding the Administrators group" $t = "^Users/.*/.*" @@ -144,11 +144,11 @@ ForEach ($nestedCollection in $nestedCollections) { } } -##################################### -# # -# Move unnested collections # -# # -##################################### +######################################### +# # +# Move unnested collections # +# # +######################################### Write-Output "`n Checking for un-nested collections" $t = "^(Users|Archived Accounts|Default collection|Unassigned)" @@ -172,6 +172,12 @@ ForEach ($collection in $unnestedCollections) { } } +######################################### +# # +# Cleanup secrets # +# # +######################################### + #clear plaintext secrets $env:BW_CLIENTID = '' $env:BW_CLIENTSECRET = '' @@ -179,4 +185,4 @@ $org_client_secret_key = '' .\bw logout $session_key = '' -Stop-Transcript \ No newline at end of file +Stop-Transcript From aff93296449a78f44b1f67420845b8214012121a Mon Sep 17 00:00:00 2001 From: georgiaschafer Date: Thu, 26 Jun 2025 12:40:24 -0500 Subject: [PATCH 3/3] Update and rename bwUpdateOrgCollections.ps1 to Maintain-BitwardenOrganization.ps1 Addressing comments from fer: 1. Added script header, including .SYNOPSIS, .DESCRIPTION, .PARAMETER, .EXAMPLE, .NOTES. 2. Changed name to format InfiniteActionVerb-FunctionChange.ps1 3. Checks "bw status" and only initiates login if status is "unathenticated" 4. Used ConvertFrom-Json to reduce code length. Changes aside from fer's recommendations: 1. Converted linear code into functions. 2. Added the ConvertFrom-SecureStringPlain used in Apply-NestedPermissions.ps1 and updated all instances where SecureString is converted to use this function. 3. Adapted Authenticate-Bitwarden from Apply-NestedPermissions.ps1 to include API authentication. 4. Separated confirming users into its own function instead of doing the confirm action inside the personal collection creation code. 5. Updated existing code to use parameters. Sending global parameters to function parameters to make functions modular. 6. Changed variable named $t to $query for clarity. --- Powershell/Maintain-BitwardenOrganization.ps1 | 399 ++++++++++++++++++ Powershell/bwUpdateOrgCollections.ps1 | 188 --------- 2 files changed, 399 insertions(+), 188 deletions(-) create mode 100644 Powershell/Maintain-BitwardenOrganization.ps1 delete mode 100644 Powershell/bwUpdateOrgCollections.ps1 diff --git a/Powershell/Maintain-BitwardenOrganization.ps1 b/Powershell/Maintain-BitwardenOrganization.ps1 new file mode 100644 index 0000000..5e260c4 --- /dev/null +++ b/Powershell/Maintain-BitwardenOrganization.ps1 @@ -0,0 +1,399 @@ +<# +.SYNOPSIS +Creates collection for new users, assigns Administrator group to all collections, moves collections created + at the root to nest under the first user with permissions, and archives collections belonging to revoked users + Optimizes functionality compared to original scripts by reducing calls to bw for each user + +.PARAMETER ORG_ID + Required, The UUID format organization ID for Bitwarden (e.g., "9d3210e3-385c-4c76-ad72-b1f5013a8cc2"). + +.PARAMETER CLIENT_ID + Required, The UUID format client ID for the user performing CLI actions (e.g., "9d3210e3-385c-4c76-ad72-b1f5013a8cc2"). + +.PARAMETER ADMIN_GROUP_ID + Optional, The UUID of the Bitwarden Administrator group to apply to all collections. + +.PARAMETER USER_COLLECTION_BASE_PATH + Optional, The collection to nest all user collections within (default: "Users"). + +.PARAMETER ARCHIVE_COLLECTION_BASE_PATH + Optional, The collection to nest all user collections within (default: "Archived Accounts"). + +.PARAMETER USER_SKIP_LIST + Optional, List of users to skip when creating default collections. Used when default collection name does not match the username (default: "Admin"). + +.PARAMETER COLLECTION_SKIP_LIST + Optional, List of collections to skip when evaluating base level collections for nesting (default: "Archived Accounts,Company,Default collection,Unassigned,Users"). + +.PARAMETER LOG_FILE + Optional, Log file for storing script actions. + +.PARAMETER SELF_HOSTED_DOMAIN + Optional, The base URI of the Bitwarden instance. + +.EXAMPLE + .\Maintain-BitwardenOrganization.ps1 -ORG_ID "9d3210e3-385c-4c76-ad72-b1f5013a8cc2" -CLIENT_ID "your-client-id" -ADMIN_GROUP_ID "your-admin-group-uuid" -LOG_FILE "LOG_Maintain-BitwardenOrganization.txt" + Creates new personal collections for members under the "Users" collection, checks that the admin group can manage all collections, moves collections + that aren't in the default list of base collections to skip, and archives personal collections for revoked members. + +.EXAMPLE + .\Maintain-BitwardenOrganization.ps1 -ORG_ID "your-org-uuid" -CLIENT_ID "your-client-id" -ADMIN_GROUP_ID "your-admin-group-uuid" -USER_COLLECTION_BASE_PATH "Members" -ARCHIVE_COLLECTION_BASE_PATH "Boneyard" + Sets the base path for personal collections to "Members" and the archive path to "Boneyard" + +.EXAMPLE + .\Maintain-BitwardenOrganization.ps1 -ORG_ID "your-org-uuid" -CLIENT_ID "your-client-id" -ADMIN_GROUP_ID "your-admin-group-uuid" -ARCHIVE_COLLECTION_BASE_PATH "Boneyard" -USER_SKIP_LIST "Peregrine,Meriadoc" + Sets the archive path to "Boneyard" and skips creating a personal collection for the users "Peregrine" and "Meriadoc" + +.EXAMPLE + .\Maintain-BitwardenOrganization.ps1 -ORG_ID "your-org-uuid" -CLIENT_ID "your-client-id" -ADMIN_GROUP_ID "your-admin-group-uuid" -USER_SKIP_LIST "Peregrine,Meriadoc" -$COLLECTION_SKIP_LIST "Boneyard,Company,Default collection,Unassigned,Members" + Sets the archive path to "Boneyard" and skips creating a personal collection for the users "Peregrine" and "Meriadoc" + +.NOTES + jq is required in $PATH https://stedolan.github.io/jq/download/ + bw is required in $PATH and logged in and unlocked https://bitwarden.com/help/cli/ + + Depends on file "secureString_masterPassword.txt" which can be created by first running: + Read-Host "Enter Master Password" -AsSecureString | ConvertFrom-SecureString | Out-File "secureString_masterPassword.txt" + + Depends on file "secureString_orgSecret.txt" which can be created by first running: + Read-Host "Enter org_client_secret" -AsSecureString | ConvertFrom-SecureString | Out-File "secureString_orgSecret.txt" + + Depends on file "secureString_userSecret.txt" which can be created by first running: + Read-Host "Enter user_client_secret" -AsSecureString | ConvertFrom-SecureString | Out-File "secureString_userSecret.txt" + + If changing USER_COLLECTION_BASE_PATH or ARCHIVE_COLLECTION_BASE_PATH, it may be better to change the script default values +#> + +param( + [Parameter(Mandatory=$true)] + [string]$ORG_ID, + + [Parameter(Mandatory=$true)] + [string]$CLIENT_ID, + + [Parameter(Mandatory=$true)] + [string]$ADMIN_GROUP_ID, + + [string]$USER_COLLECTION_BASE_PATH = "Users", + + [string]$ARCHIVE_COLLECTION_BASE_PATH = "Archived Accounts", + + [string]$USER_SKIP_LIST = "Admin", + + [string]$COLLECTION_SKIP_LIST = "Archived Accounts,Company,Default collection,Unassigned,Users", + + [string]$LOG_FILE, + + [string]$SELF_HOSTED_DOMAIN +) + +# Convert secure strings to plain text +function ConvertFrom-SecureStringPlain { + param ([SecureString]$SecureString) + return [System.Runtime.InteropServices.Marshal]::PtrToStringUni( + [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString) + ) +} + +# API/CLI authentication and session setup +function Authenticate-Bitwarden { + param ( + [string]$OrgId + ) + + Write-Output "`n Authenticating with Bitwarden API..." + + # Set up API auth + $orgClientSecret = Get-Content "secureString_orgSecret.txt" | ConvertTo-SecureString + $orgClientSecretKey = ConvertFrom-SecureStringPlain($orgClientSecret) + + If ($OrgId -like "organization.*") { + $orgClientId = $OrgId + } Else { + $orgClientId = "organization.$OrgId" + } + + # Get API Access Token + $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]" + $headers.Add('Content-Type','application/x-www-form-urlencoded') + $body = "grant_type=client_credentials&scope=api.organization&client_id=$orgClientId&client_secret=$orgClientSecretKey" + + $bearerToken = (Invoke-RestMethod -Method POST -Uri $identity_url/connect/token -Headers $headers -Body $body).access_token + + If ($bearerToken) { + $env:BW_TOKEN = $bearerToken + Write-Output "`n API Bearer Token: Success" + } Else { + Write-Output "`n API Bearer Token: Failure" + exit 1 + } + + Write-Output "`n Authenticating with Bitwarden CLI..." + + # Perform CLI auth + $userClientSecret = Get-Content "secureString_userSecret.txt" | ConvertTo-SecureString + $env:BW_CLIENTSECRET = ConvertFrom-SecureStringPlain($userClientSecret) # service account client secret + $bwMasterPass = Get-Content "secureString_masterPassword.txt" | ConvertTo-SecureString # service account master password + $bwMasterPassPlain = ConvertFrom-SecureStringPlain($bwMasterPass) + $bwStatus = (& .\bw status | ConvertFrom-Json).status + Switch ($bwStatus) { + "unauthenticated" { + Write-Output "`n Logging in and unlocking Bitwarden vault..." + $loginResult = & .\bw login --apikey + $sessionKey = & .\bw unlock $bwMasterPassPlain --raw + } + "locked" { + Write-Output "`n Already logged in, unlocking Bitwarden vault..." + $sessionKey = & .\bw unlock $bwMasterPassPlain --raw + } + } + + # Clear secrets from variables + $env:BW_CLIENTSECRET = '' + $bwMasterPassPlain = '' + $orgClientSecretKey = '' + $orgClientSecret = '' + $userClientSecret = '' + $bwMasterPass = '' + $env:BW_CLIENTID = '' + + If (!$sessionKey) { Write-Output "`n CLI Session Key: Failure"; exit 1 } + + Write-Output "`n Successfully unlocked Bitwarden vault." + $env:BW_SESSION = $sessionKey + + #clear variable + $sessionKey = '' +} + +# For each Member, ensure a personal Collection exists +function Create-DefaultUserCollections { + param ( + [string]$UserPath, + [string]$OrgCollections, + [psobject]$OrgMembers, + [string]$GroupId, + [string]$OrgId, + [string]$UserSkipList + ) + + Write-Output "`n Checking for users without a personal collection..." + + # Regex to use when filtering the collections + $query = "^" + $UserPath + ".*$" + + # Filter orgCollections to those nested under the base user path + $userCollections = $OrgCollections | .\jq -c --arg q "$query" '.[] | select(.name|test("$q"))' + + # Convert the skip list to regex + $memberSkipRegEx = $UserSkipList -replace ",","|" + + ForEach ($member in $OrgMembers) { + $memberName = ($member.email -split "@")[0] #ignore the name field, standardizing on the first part of the email address + $memberId = $member.id + $memberStatus = $member.status + $memberEmail = $member.email + $existingCollection = "" + + # Check If the Collection already exists + $query = $userPath + $memberName + $existingCollection = $userCollections -cmatch $query + + #skip If the user exists or it is the Admin account, or the user is revoked + If ($existingCollection -or ($memberName -cmatch $memberSkipRegEx) -or ($memberStatus -eq -1)) { + Write-Output "`n Skipping: $memberName" + } Else { + + #create the collection and add Administrators group to it + #jq is inserting the values into the template + #Get the template: (.\bw get template org-collection) + #Set the jq variables: .\jq --arg n "$query" --arg c "$OrgId" --arg g "$$GroupId" --arg u "$memberId" + #Insert into the json string: '.name="$n" | .organizationId="$c" | .groups[0].id="$g" | .groups[0].manage="true" | del(.groups[1]) | .users=[{"id":$u, "readOnly":false, "hidePasswords":false, "manage":true}]' + #Encode the json: | .\bw encode + #Create the collection: | .\bw create org-collection --organizationid $OrgId + #Filter to new collection Id: | .\jq -r '.id' + $newCollectionId = (.\bw get template org-collection) | .\jq --arg n "$query" --arg c "$OrgId" --arg g "$GroupId" --arg u "$memberId" '.name="$n" | .organizationId="$c" | .groups[0].id="$g" | .groups[0].manage="true" | del(.groups[1]) | .users=[{"id":$u, "readOnly":false, "hidePasswords":false, "manage":true}]' | .\bw encode | .\bw create org-collection --organizationid $OrgId | .\jq -r '.id' + Write-Output "`n +++ Created Collection: $memberName" + + } + } +} + +# Confirm unconfirmed users - note this is not recommended +function Confirm-Users { + param ( + [psobject]$OrgMembers + ) + + Write-Output "`n Confirming users..." + $pendingMembers = $OrgMembers | where {$_.status -eq 1} + + ForEach ($member in $pendingMembers) { + .\bw confirm org-member $member.id --organizationid $ORG_ID + Write-Output "`n +++ Confirmed user: $membername" + } +} + +# Verify admin group has permissions to all member created collections +function Verify-CollectionGroupPermissions { + param ( + [string]$UserPath, + [string]$OrgCollections, + [string]$GroupId, + [string]$OrgId + ) + + Write-Output "`n Checking nested collection permissions..." + $query = "^" + $UserPath + ".*" + $nestedCollections = $OrgCollections | .\jq -c --arg q "$query" '.[] | select(.name|test("$q"))' | .\jq -r '.id' + $orgGroups = (Invoke-RestMethod -Method GET -Uri $api_url/public/groups -Headers $headers) | Select-Object data + $adminGroupCollections = ($orgGroups.data | where {$_.id -eq $GroupId}).collections + $adminPermissionsWrong = $adminGroupCollections | where {($_.manage -eq $false) -or ($_.readOnly -eq $true) -or ($_.hidePasswords -eq $true)} + + ForEach ($nestedCollection in $nestedCollections) { + If ((!($adminGroupCollections -match $nestedCollection)) -or ($adminPermissionsWrong -match $nestedCollection)) { + $updateCollection = (.\bw get org-collection "$nestedCollection" --organizationid $OrgId) | .\jq --arg i $groupId '.groups+=[{"id": $i,"readOnly": "false","hidePasswords": "false","manage": "true"}]' | .\bw encode | .\bw edit org-collection "$nestedCollection" --organizationid $OrgId | .\jq -r '.name' + Write-Output "`n +++ Updated permissions on Collection: $updateCollection" + } + } +} + +# Move member created base collections to nest under a user collection +function Move-UnauthorizedBaseCollections { + param ( + [string]$UserPath, + [string]$OrgCollections, + [psobject]$OrgMembers, + [string]$GroupId, + [string]$OrgId, + [string]$CollectionSkipList + ) + + Write-Output "`n Checking for unauthorized base collections..." + $query = "^($CollectionSkipList)" + $unnestedCollections = $orgCollections | .\jq -c --arg t "$query" '.[] | select(.name|test("$t")|not)' | ConvertFrom-Json + + ForEach ($collection in $unnestedCollections) { + $colId = $collection.id + $colName = $collection.name + $item = (.\bw get org-collection "$colId" --organizationid $OrgId) | ConvertFrom-Json + $itemGroups = $item.groups + $itemId = $item.id + $userId = $item.users[0].id + $user = (($OrgMembers | where {$_.id -eq $userId}).email -split "@")[0] + $newName = $UserPath + $user + "/$colName" + + If (!($itemGroups -match $groupId)) { + $updateCollection = (.\bw get org-collection "$colId" --organizationid $OrgId) | .\jq --arg i $groupId --arg n $newName '.groups+=[{"id": $i,"readOnly": "false","hidePasswords": "false","manage": "true"}] | .name=$n ' | .\bw encode | .\bw edit org-collection "$colId" --organizationid $OrgId + Write-Output "`n +++ Moved $colName to $newName and added Admin group." + } Else { + $updateCollection = (.\bw get org-collection "$colId" --organizationid $OrgId) | .\jq --arg n $newName '.name=$n ' | .\bw encode | .\bw edit org-collection $itemId --organizationid $OrgId + Write-Output "`n +++ Moved $colName to $newName." + } + } +} + +# Archive the personal collection and all collections nested under it when member is revoked +function Archive-RevokedUserCollections { + param ( + [string]$UserPath, + [string]$OrgCollections, + [psobject]$OrgMembers, + [string]$OrgId, + [string]$ArchivePath + ) + + Write-Output "`n Checking for revoked member collections to archive..." + $revokedUsers = $OrgMembers | where {$_.status -eq -1} + + ForEach ($user in $revokedUsers) { + $username = ($user.email -split "@")[0] + $t = $UserPath + $username + ".*$" + $userCollections = $orgCollections | .\jq -c --arg t "$t" '.[] | select(.name|test("$t"))' | convertfrom-json + + ForEach ($collection in $userCollections) { + $colName = $collection.name + $newName = $colName.Replace($UserPath,$ArchivePath) + $itemId = $collection.id + $updateCollection = (.\bw get org-collection "$itemId" --organizationid $OrgId) | .\jq --arg n $newName '.name=$n | .users=[]' | .\bw encode | .\bw edit org-collection "$itemId" --organizationid $OrgId + Write-Output "`n +++ Archived $colName as $newName" + } + } +} + +# Setup parameters +If ($CLIENT_ID -like "user.*") { + $env:BW_CLIENTID = $CLIENT_ID # service account client id +} Else { + $env:BW_CLIENTID = "user.$CLIENT_ID" # service account client id +} +$user_path = ($USER_COLLECTION_BASE_PATH + "/") +$archive_path = ($ARCHIVE_COLLECTION_BASE_PATH + "/") +# Ensure the member and archive paths are in the skip list +$collections_to_skip = $COLLECTION_SKIP_LIST +If ($COLLECTION_SKIP_LIST -notlike "*$USER_COLLECTION_BASE_PATH*") { + $collections_to_skip += ",$USER_COLLECTION_BASE_PATH" +} +If ($COLLECTION_SKIP_LIST -notlike "*$ARCHIVE_COLLECTION_BASE_PATH*") { + $collections_to_skip += ",$ARCHIVE_COLLECTION_BASE_PATH" +} +# Convert to regex +$collections_to_skip = $collections_to_skip -Replace ",","|" + +# Handle API URLs +If (!$SELF_HOSTED_DOMAIN) { + $api_url = "https://api.bitwarden.com" + $identity_url = "https://identity.bitwarden.com" +} Else { + $api_url = "https://$SELF_HOSTED_DOMAIN/api" # Set your Self-Hosted API URL + $identity_url = "https://$SELF_HOSTED_DOMAIN/identity" # Set your Self-Hosted Identity URL + # configure bw client + & .\bw config server "https://vault.$SELF_HOSTED_DOMAIN" +} + +# Log the script progress +If ($LOG_FILE) { + Start-Transcript $LOG_FILE #set your transcript location +} + +# Setup the sessions +Authenticate-Bitwarden -OrgId $ORG_ID + +Write-Output "`n Fetching all members for organization ID: $ORG_ID" + +# Set headers to use the bearer token from environment +$headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]" +$headers.Add('Authorization',('Bearer {0}' -f $env:BW_TOKEN)) +$headers.Add('Accept','application/json') +$headers.Add('Content-Type','application/json') + +# Fetch the list of Members and collections to use throughout +$org_api_members = (Invoke-RestMethod -Method GET -Uri $api_url/public/members -Headers $headers) | Select-Object data +$org_members = $org_api_members.psobject.Properties.Value | Select-Object name,id,status,email + +Write-Output "`n Fetching all collections for organization ID: $ORG_ID" +$org_collections = (.\bw list org-collections --organizationid $ORG_ID) + +# For each Member, ensure a personal Collection exists +Create-DefaultUserCollections -UserPath $user_path -OrgCollections $org_collections -OrgMembers $org_members -GroupId $ADMIN_GROUP_ID -OrgId $ORG_ID -UserSkipList $USER_SKIP_LIST + +# Confirm unconfirmed users - note this is not recommended, uncomment to use +#Confirm-Users -OrgMembers $org_members + +# Check that the admin group has permissions to all collections +Verify-CollectionGroupPermissions -UserPath $user_path -OrgCollections $org_collections -GroupId $ADMIN_GROUP_ID -OrgId $ORG_ID + +# Move unauthorized base collections to nest under the first user with permissions +Move-UnauthorizedBaseCollections -UserPath $user_path -OrgCollections $org_collections -OrgMembers $org_members -GroupId $ADMIN_GROUP_ID -OrgId $ORG_ID -CollectionSkipList $collections_to_skip + +# Archive the personal collection and all collections nested under it when member is revoked +Archive-RevokedUserCollections -UserPath $user_path -OrgCollections $org_collections -OrgMembers $org_members -OrgId $ORG_ID -ArchivePath $archive_path + +# Clear plaintext secrets +$env:BW_TOKEN = '' +$env:BW_SESSION = '' + +# Logout session +.\bw logout + +If ($LOG_FILE) { Stop-Transcript } diff --git a/Powershell/bwUpdateOrgCollections.ps1 b/Powershell/bwUpdateOrgCollections.ps1 deleted file mode 100644 index cc4551e..0000000 --- a/Powershell/bwUpdateOrgCollections.ps1 +++ /dev/null @@ -1,188 +0,0 @@ -# Script: bwUpdateOrganization.ps1 -# Date: 20250604 -# Description: Adapted from scripts found here - https://github.com/bitwarden-labs/admin-scripts/tree/main/Powershell -# Creates collection for new users, assigns Administrator group to all collections, moves collections created -# at the root to nest under the first user with permissions -# Optimizes functionality compared to original scripts by reducing calls to bw for each user -# -# Depends on file "secureString.txt" which can be created by first running: -# Read-Host "Enter Master Password" -AsSecureString | ConvertFrom-SecureString | Out-File "secureString.txt" -# Depends on file "secureString_secret.txt" which can be created by first running: -# Read-Host "Enter client_secret" -AsSecureString | ConvertFrom-SecureString | Out-File "secureString_secret.txt" -# jq is required in $PATH https://stedolan.github.io/jq/download/ -# bw is required in $PATH and logged in and unlocked https://bitwarden.com/help/cli/ - -######################################### -# # -# Script setup section # -# # -######################################### - -#log the script progress -Start-Transcript "" #set your transcript location - -# Handle API URLs -$organization_id = "" # Set your Org ID -$cloud_flag = 1 # Self-hosted Bitwarden or Cloud? -if ($cloud_flag -eq 1) { - $api_url = "https://api.bitwarden.com" - $identity_url = "https://identity.bitwarden.com" -} else { - $api_url = "https://YOUR-FQDN/api" # Set your Self-Hosted API URL - $identity_url = "https://YOUR-FQDN/identity" # Set your Self-Hosted Identity URL -} - -# Set up CLI and API auth -$org_client_secret = Get-Content "secureString_secret.txt" | ConvertTo-SecureString -$client_creds = New-Object System.Management.Automation.PSCredential "null", $org_client_secret -$org_client_secret_key = , $client_creds.GetNetworkCredential().password -$org_client_id = "organization." + $organization_id - -# Get Access Token -$headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]" -$headers.Add('Content-Type','application/x-www-form-urlencoded') -$body = "grant_type=client_credentials&scope=api.organization&client_id=$org_client_id&client_secret=$org_client_secret_key" -$bearer_token = (Invoke-RestMethod -Method POST -Uri $identity_url/connect/token -Headers $headers -Body $body).access_token - -if($bearer_token) { Write-Output "`n Bearer Token: Success"} else {Write-Output "Bearer Token: Failure"} - -# update headers to use the bearer token -$headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]" -$headers.Add('Authorization',('Bearer {0}' -f $bearer_token)) -$headers.Add('Accept','application/json') -$headers.Add('Content-Type','application/json') - -# Perform CLI auth -$env:BW_CLIENTID = "user.eb7998e6-ab5f-4027-8d0a-b2f0011b2af9" # service account client id -$password = Get-Content "secureString_UserSecret.txt" | ConvertTo-SecureString -$cred = New-Object System.Management.Automation.PSCredential "null", $password -$env:BW_CLIENTSECRET = , $cred.GetNetworkCredential().password # service account client secret -.\bw login --apikey - -$password = Get-Content "secureString.txt" | ConvertTo-SecureString # service account master password -$cred = New-Object System.Management.Automation.PSCredential "null", $password -$session_key = , $cred.GetNetworkCredential().password | powershell -c '.\bw unlock --raw' - -if($session_key) { Write-Output "`n Session Key: Success"} else {Write-Output "Session Key: Failure"} - -# Fetch the list of Members and collections -$org_members = (Invoke-RestMethod -Method GET -Uri $api_url/public/members -Headers $headers) | Select-Object data -$values = $org_members.psobject.Properties.Value | Select-Object name,id,status,email -$orgCollections = (.\bw --session $session_key list org-collections --organizationid $organization_id) - -######################################### -# # -# Configure new users # -# # -######################################### -# For each Member, create a Collection, and then assign that Member to it - -$groupId = "" #set the Administrators group guid -$t = "^Users/.*$" # regex to use when filtering the collections, I tried renaming this one to $query and jq breaks, so it is staying $t -#filter to the base user collections -$userCollections = $orgCollections | .\jq -c --arg t "$t" '.[] | select(.name|test("$t"))' - -ForEach ($membervalues in $values) { - $membername = ($membervalues.email -split "@")[0] #ignore the name field, standardizing on the first part of the email address - $memberid = $membervalues.id - $memberstatus = $membervalues.status - $memberemail = $membervalues.email - $existingcollection = "" - - # Check if the Collection already exists - $query = "Users/" + $membername - $existingcollection = $userCollections -match $query - - #skip if the user exists or it is the Admin account, or the user is revoked - if ($existingcollection -or ($membername -eq "Admin") -or ($memberstatus -eq -1)) { - - Write-Output "`n $membername already has a Collection, skipping" - - } - else { - - #create the collection and add Administrators group to it - #jq is inserting the values into the template - #Get the template: (.\bw --session $session_key get template org-collection) - #Set the jq variables: .\jq --arg n "$query" --arg c "$organization_id" --arg g "$groupId" --arg u "$memberid" - #Insert into the json string: '.name="$n" | .organizationId="$c" | .groups[0].id="$g" | .groups[0].manage="true" | del(.groups[1]) | .users=[{"id":$u, "readOnly":false, "hidePasswords":false, "manage":true}]' - #Encode the json: | .\bw encode - #Create the collection: | .\bw --session $session_key create org-collection --organizationid $organization_id - #Filter to new collection Id: | .\jq -r '.id' - $collectionid = (.\bw --session $session_key get template org-collection) | .\jq --arg n "$query" --arg c "$organization_id" --arg g "$groupId" --arg u "$memberid" '.name="$n" | .organizationId="$c" | .groups[0].id="$g" | .groups[0].manage="true" | del(.groups[1]) | .users=[{"id":$u, "readOnly":false, "hidePasswords":false, "manage":true}]' | .\bw encode | .\bw --session $session_key create org-collection --organizationid $organization_id | .\jq -r '.id' - Write-Output "`n Created Collection for $membername" - - } - - #Confirm unconfirmed users - note this is not recommended, uncomment to use - #if ($memberstatus -eq 1) { - # - # .\bw --session $session_key confirm org-member $memberid --organizationid $organization_id - # Write-Output "`n Confirmed user: $membername" - #} - -} - -######################################### -# # -# Configure nested collection # -# permissions # -# # -######################################### - -Write-Output "`n Checking nested collection permissions and adding the Administrators group" -$t = "^Users/.*/.*" -$nestedCollections = $orgCollections | .\jq -c --arg t "$t" '.[] | select(.name|test("$t"))' | .\jq -r '.id' -$org_groups = (Invoke-RestMethod -Method GET -Uri $api_url/public/groups -Headers $headers) | Select-Object data -$adminGroupCollections = ($org_groups.data | where {$_.id -eq $groupId}).collections -$adminPermissionsWrong = $adminGroupCollections | where {($_.manage -eq $false) -or ($_.readOnly -eq $true) -or ($_.hidePasswords -eq $true)} - -ForEach ($nestedCollection in $nestedCollections) { - - if ((!($adminGroupCollections -match $nestedCollection)) -or ($adminPermissionsWrong -match $nestedCollection)) { - $updateCollection = (.\bw --session $session_key get org-collection "$nestedCollection" --organizationid $organization_id) | .\jq --arg i $groupId '.groups+=[{"id": $i,"readOnly": "false","hidePasswords": "false","manage": "true"}]' | .\bw encode | .\bw --session $session_key edit org-collection "$nestedCollection" --organizationid $organization_id - } -} - -######################################### -# # -# Move unnested collections # -# # -######################################### - -Write-Output "`n Checking for un-nested collections" -$t = "^(Users|Archived Accounts|Default collection|Unassigned)" -$unnestedCollections = $orgCollections | .\jq -c --arg t "$t" '.[] | select(.name|test("$t")|not)' -$unnestedCollections = $unnestedCollections | convertfrom-json - -ForEach ($collection in $unnestedCollections) { - $colId = $collection.id - $colName = $collection.name - $item = (.\bw --session $session_key get org-collection "$colId" --organizationid $organization_id) - $itemGroups = $item | .\jq -r '.groups' - $itemId = $item | .\jq -r '.id' - $userId = $item | .\jq -r '.users[0].id' - $user = ($values | where {$_.id -eq $userId}).email -split "@" - $newName = "Users/" + $user[0] + "/$colName" - - if (!($itemGroups -match $groupId)) { - $updateCollection = (.\bw --session $session_key get org-collection "$colId" --organizationid $organization_id) | .\jq --arg i $groupId --arg n $newName '.groups+=[{"id": $i,"readOnly": "false","hidePasswords": "false","manage": "true"}] | .name=$n ' | .\bw encode | .\bw --session $session_key edit org-collection $itemId --organizationid $organization_id - } else { - $updateCollection = (.\bw --session $session_key get org-collection "$colId" --organizationid $organization_id) | .\jq --arg n $newName '.name=$n ' | .\bw encode | .\bw --session $session_key edit org-collection $itemId --organizationid $organization_id - } -} - -######################################### -# # -# Cleanup secrets # -# # -######################################### - -#clear plaintext secrets -$env:BW_CLIENTID = '' -$env:BW_CLIENTSECRET = '' -$org_client_secret_key = '' -.\bw logout -$session_key = '' - -Stop-Transcript