From 8427e9174c833ce4840d2a38058e53457ec57930 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 6 Sep 2025 15:51:19 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=A9=B9=20[Patch]:=20Add=20function=20to?= =?UTF-8?q?=20remove=20app=20installation=20as=20a=20GitHub=20App=20(#483)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description This pull request introduces improvements to the GitHub App uninstallation workflow, adding more flexible and robust ways to uninstall apps both as an authenticated app and as an enterprise installation. The changes include new internal functions, enhanced parameter handling, improved context validation, and expanded test coverage to ensure reliability and clarity in uninstall scenarios. **Enhancements to GitHub App Uninstallation:** * Added new function `Uninstall-GitHubAppAsApp` to support uninstalling app installations as the authenticated app, with support for confirmation and verbose output. * Updated `Uninstall-GitHubAppOnEnterpriseOrganization` to clarify its purpose, improve parameter validation, add confirmation support, and provide better feedback on successful uninstalls. **Expanded and Flexible Public API:** * Refactored `Uninstall-GitHubApp` to support multiple uninstallation modes (by target, by object, by installation ID, by app slug), improved parameter sets, and added context and authentication checks for safer operation. **Documentation and Examples:** * Added a comprehensive example script `examples/Apps/UninstallingApps.ps1` showing various uninstallation scenarios for both app and enterprise contexts. **Testing and Reliability:** * Improved organization tests to clean up existing app installations before and after tests, and added a test to verify app uninstall behavior after organization deletion. **Other Improvements:** * Increased robustness in token revocation error handling in `Disconnect-GitHubAccount`. * Fixed a minor property default in `GitHubContext.Types.ps1xml` for better handling of missing token expiration. * Updated `.github/PSModule.yml` to enable and skip specific test and build steps as appropriate. ## Type of change - [ ] 📖 [Docs] - [ ] 🪲 [Fix] - [x] 🩹 [Patch] - [ ] ⚠️ [Security fix] - [ ] 🚀 [Feature] - [ ] 🌟 [Breaking change] ## Checklist - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas --- examples/Apps/UninstallingApps.ps1 | 77 +++++++++ .../GitHub Apps/Uninstall-GitHubAppAsApp.ps1 | 59 +++++++ ...tall-GitHubAppOnEnterpriseOrganization.ps1 | 30 +++- .../Apps/GitHub App/Uninstall-GitHubApp.ps1 | 150 ++++++++++++++---- .../public/Auth/Disconnect-GitHubAccount.ps1 | 7 +- src/types/GitHubContext.Types.ps1xml | 2 +- tests/Organizations.Tests.ps1 | 23 +++ 7 files changed, 306 insertions(+), 42 deletions(-) create mode 100644 examples/Apps/UninstallingApps.ps1 create mode 100644 src/functions/private/Apps/GitHub Apps/Uninstall-GitHubAppAsApp.ps1 diff --git a/examples/Apps/UninstallingApps.ps1 b/examples/Apps/UninstallingApps.ps1 new file mode 100644 index 000000000..5b0c9995d --- /dev/null +++ b/examples/Apps/UninstallingApps.ps1 @@ -0,0 +1,77 @@ +## The uninstall function is context aware and will use the context to determine the type of uninstallation. +## We can uninstall either as a GitHub App or as an Enterprise installation. +## Examples assume that the GitHub App that is performing the uninstalls is the current context. +## See 'Connection' examples to find how to connect as a GitHub App. + + +## +## As a GitHub App you can uninstall the app from any target where it is currently installed. +## + +# First get info about the app installations for the authenticated app. +$installations = Get-GitHubAppInstallation +$installations + +# Uninstall the app installation by name. This will string match with the target of the installation. +Uninstall-GitHubApp -Target 'msx' # Enterprise +Uninstall-GitHubApp -Target 'org-123' # Organization +Uninstall-GitHubApp -Target 'octocat' # User + +# Uninstall the app installation by ID. This will do an exact match with the installation ID. +Uninstall-GitHubApp -Target 12345 + +# Uninstall the app using the installation objects from the pipeline. +Get-GitHubAppInstallation | Uninstall-GitHubApp + +# Uninstall the app from all Users using an installation object array. +$installations = Get-GitHubAppInstallation | Where-Object Type -EQ 'User' +$installations | Uninstall-GitHubApp + +### +### Full example, uninstalling an app from deleted organizations in an enterprise. +### +# Get the installations for all organizations where the app is installed. +$orgInstallations = Get-GitHubAppInstallation | Where-Object Type -EQ 'Organization' + +# Connect to the enterprise using a management app that can manage installations to get the available organizations in the enterprise. +$enterpriseContext = Connect-GitHubApp -Enterprise 'msx' -PassThru +$orgs = Get-GitHubOrganization -Enterprise 'msx' -Context $enterpriseContext + +# Uninstall the app from all organizations that are not in the list of available organizations. +$orgInstallations | Where-Object { $_.Target -notin $orgs.Name } | Uninstall-GitHubApp + +## +## As an enterprise installation, you can uninstall any app that is installed on an organization in the enterprise. +## + +# Get the installations for the organizations in the enterprise that we can manage. +$orgInstallations = Get-GitHubAppInstallation | Where-Object Type -EQ 'Organization' + +# Connect to the enterprise using a management app that can manage installations and store it in a variable. +$enterpriseContext = Connect-GitHubApp -Enterprise 'msx' -PassThru + +# Get the available organizations in the enterprise. +$orgs = Get-GitHubOrganization -Enterprise 'msx' -Context $enterpriseContext + +# Lets say we want to uninstall a specific app from all organizations in the enterprise. +# We can do this by iterating over the installations that we manage and uninstall the app. +$appToUninstall = 'psmodule-enterprise-app' +foreach ($managedOrg in $orgInstallations) { + Uninstall-GitHubApp -Target $managedOrg.Target -AppSlug $appToUninstall -Context $enterpriseContext +} + +# Uninstall an app installation by name. +Uninstall-GitHubApp -Target 'msx' -AppName 'my-app' + +$installations | Uninstall-GitHubApp -Target 'enterprise-name' + +# Uninstall an app installation by object. +$installations | Uninstall-GitHubApp + + +# Uninstall an app installation by ID. +Uninstall-GitHubApp -Target 12345 + + +Uninstall-GitHubApp -Target 'fnxsd' -ID 1234567890 +Uninstall-GitHubApp -Target 'fnxsd' -Slug 'my-app-slug' diff --git a/src/functions/private/Apps/GitHub Apps/Uninstall-GitHubAppAsApp.ps1 b/src/functions/private/Apps/GitHub Apps/Uninstall-GitHubAppAsApp.ps1 new file mode 100644 index 000000000..2b6defff8 --- /dev/null +++ b/src/functions/private/Apps/GitHub Apps/Uninstall-GitHubAppAsApp.ps1 @@ -0,0 +1,59 @@ +function Uninstall-GitHubAppAsApp { + <# + .SYNOPSIS + Delete an installation for the authenticated app. + + .DESCRIPTION + Deletes a GitHub App installation using the authenticated App context. + + .EXAMPLE + Uninstall-GitHubAppAsApp -ID 123456 -Context $appContext + + Deletes the installation with ID 123456 for the authenticated app. + + .NOTES + [Delete an installation for the authenticated app](https://docs.github.com/rest/apps/installations#delete-an-installation-for-the-authenticated-app) + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidLongLines', '', + Justification = 'Contains a long link.' + )] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] + param( + # The installation ID to remove. + [Parameter(Mandatory)] + [Alias('InstallationID')] + [ValidateRange(1, [UInt64]::MaxValue)] + [UInt64] $ID, + + # The context to run the command in. + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context + ) + + begin { + $stackPath = Get-PSCallStackPath + Write-Debug "[$stackPath] - Start" + Assert-GitHubContext -Context $Context -AuthType APP + } + + process { + Write-Verbose "Uninstalling GitHub App Installation: $ID" + + $apiParams = @{ + Method = 'DELETE' + APIEndpoint = "/app/installations/$ID" + Context = $Context + } + + if ($PSCmdlet.ShouldProcess("GitHub App Installation: $ID", 'Uninstall')) { + $null = Invoke-GitHubAPI @apiParams + Write-Verbose "Successfully removed installation: $ID" + } + } + + end { + Write-Debug "[$stackPath] - End" + } +} diff --git a/src/functions/private/Apps/GitHub Apps/Uninstall-GitHubAppOnEnterpriseOrganization.ps1 b/src/functions/private/Apps/GitHub Apps/Uninstall-GitHubAppOnEnterpriseOrganization.ps1 index cfea6ff04..60dab447f 100644 --- a/src/functions/private/Apps/GitHub Apps/Uninstall-GitHubAppOnEnterpriseOrganization.ps1 +++ b/src/functions/private/Apps/GitHub Apps/Uninstall-GitHubAppOnEnterpriseOrganization.ps1 @@ -1,34 +1,46 @@ function Uninstall-GitHubAppOnEnterpriseOrganization { <# .SYNOPSIS - Uninstall a GitHub App from an organization. + Uninstall a GitHub App from an enterprise-owned organization. .DESCRIPTION - Uninstall a GitHub App from an organization. + Uninstall a GitHub App from an enterprise-owned organization. The authenticated GitHub App must be installed on the enterprise and be granted the Enterprise/organization_installations (write) permission. .EXAMPLE - Uninstall-GitHubAppOnEnterpriseOrganization -Enterprise 'github' -Organization 'octokit' -InstallationID '123456' + Uninstall-GitHubAppOnEnterpriseOrganization -Enterprise 'github' -Organization 'octokit' -ID '123456' Uninstall the GitHub App with the installation ID `123456` from the organization `octokit` in the enterprise `github`. + + .NOTES + [Uninstall a GitHub App from an enterprise-owned organization](https://docs.github.com/enterprise-cloud@latest/rest/enterprise-admin/organization-installations#uninstall-a-github-app-from-an-enterprise-owned-organization) #> - [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidLongLines', '', + Justification = 'Contains a long link.' + )] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] param( # The enterprise slug or ID. [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [string] $Enterprise, # The organization name. The name is not case sensitive. [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [string] $Organization, - # The client ID of the GitHub App to install. + # The ID of the GitHub App installation to uninstall. [Parameter(Mandatory)] - [string] $ID, + [Alias('InstallationID')] + [ValidateRange(1, [UInt64]::MaxValue)] + [UInt64] $ID, # The context to run the command in. Used to get the details for the API call. [Parameter(Mandatory)] + [ValidateNotNull()] [object] $Context ) @@ -40,14 +52,16 @@ } process { + Write-Verbose "Uninstalling GitHub App [$Enterprise/$Organization/$ID]" $apiParams = @{ Method = 'DELETE' APIEndpoint = "/enterprises/$Enterprise/apps/organizations/$Organization/installations/$ID" Context = $Context } - Invoke-GitHubAPI @apiParams | ForEach-Object { - Write-Output $_.Response + if ($PSCmdlet.ShouldProcess("GitHub App Installation: $Enterprise/$Organization/$ID", 'Uninstall')) { + $null = Invoke-GitHubAPI @apiParams + Write-Verbose "Successfully removed installation: $Enterprise/$Organization/$ID" } } diff --git a/src/functions/public/Apps/GitHub App/Uninstall-GitHubApp.ps1 b/src/functions/public/Apps/GitHub App/Uninstall-GitHubApp.ps1 index 5e0dda333..656230a74 100644 --- a/src/functions/public/Apps/GitHub App/Uninstall-GitHubApp.ps1 +++ b/src/functions/public/Apps/GitHub App/Uninstall-GitHubApp.ps1 @@ -4,44 +4,72 @@ Uninstall a GitHub App. .DESCRIPTION - Uninstalls the provided GitHub App on the specified target. + Uninstalls a GitHub App installation. Works in two modes: + - As the authenticated App (APP context): remove installations by target name, ID, or pipeline objects. + - As an enterprise installation (IAT/UAT context with Enterprise): remove an app from an organization by InstallationID or AppSlug. .EXAMPLE - Uninstall-GitHubApp -Enterprise 'msx' -Organization 'org' -InstallationID '123456' + Uninstall-GitHubApp -Target 'octocat' + Uninstall-GitHubApp -Target 12345 - Uninstall the GitHub App with the installation ID '123456' from the organization 'org' in the enterprise 'msx'. + As an App: uninstall by target name (enterprise/org/user) or by exact installation ID + + .EXAMPLE + Get-GitHubAppInstallation | Uninstall-GitHubApp + + As an App: uninstall using pipeline objects + + .EXAMPLE + Uninstall-GitHubApp -Organization 'org' -InstallationID 123456 -Context (Connect-GitHubApp -Enterprise 'msx' -PassThru) + + As an enterprise installation: uninstall by installation ID in an org + + .EXAMPLE + Uninstall-GitHubApp -Organization 'org' -AppSlug 'my-app' -Context (Connect-GitHubApp -Enterprise 'msx' -PassThru) + + As an enterprise installation: uninstall by app slug in an org .LINK https://psmodule.io/GitHub/Functions/Apps/GitHub%20App/Uninstall-GitHubApp #> - [CmdletBinding(DefaultParameterSetName = '__AllParameterSets')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidLongLines', '', Justification = 'Contains a long link.')] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'App-ByTarget')] param( - # The enterprise slug or ID. - [Parameter( - Mandatory, - ParameterSetName = 'EnterpriseOrganization', - ValueFromPipelineByPropertyName - )] - [string] $Enterprise, + # As APP: target to uninstall. Accepts a name (enterprise/org/user) or an installation ID. + [Parameter(Mandatory, ParameterSetName = 'App-ByTarget', ValueFromPipelineByPropertyName)] + [Parameter(ParameterSetName = 'App-ByObject')] + [Alias('Name')] + [object] $Target, + + # As APP via pipeline: installation objects. + [Parameter(Mandatory, ParameterSetName = 'App-ByObject', ValueFromPipeline)] + [GitHubAppInstallation[]] $InstallationObject, - # The organization name. The name is not case sensitive. - [Parameter( - Mandatory, - ParameterSetName = 'EnterpriseOrganization', - ValueFromPipelineByPropertyName - )] + # As Enterprise (IAT/UAT): organization where the app is installed. + [Parameter(Mandatory, ParameterSetName = 'Enterprise-ByID')] + [Parameter(Mandatory, ParameterSetName = 'Enterprise-BySlug')] + [ValidateNotNullOrEmpty()] [string] $Organization, - # The client ID of the GitHub App to install. - [Parameter( - Mandatory, - ValueFromPipelineByPropertyName - )] - [Alias('installation_id', 'InstallationID')] - [string] $ID, + # As Enterprise (IAT/UAT): enterprise slug or ID. Optional if the context already has Enterprise set. + [Parameter(ParameterSetName = 'Enterprise-ByID')] + [Parameter(ParameterSetName = 'Enterprise-BySlug')] + [ValidateNotNullOrEmpty()] + [string] $Enterprise, + + # As Enterprise (IAT/UAT): installation ID to remove. + [Parameter(Mandatory, ParameterSetName = 'Enterprise-ByID')] + [Alias('ID')] + [ValidateRange(1, [UInt64]::MaxValue)] + [UInt64] $InstallationID, + + # As Enterprise (IAT/UAT): app slug to uninstall (when the installation ID is unknown). + [Parameter(Mandatory, ParameterSetName = 'Enterprise-BySlug')] + [Alias('Slug', 'AppName')] + [ValidateNotNullOrEmpty()] + [string] $AppSlug, - # The context to run the command in. Used to get the details for the API call. - # Can be either a string or a GitHubContext object. + # Common: explicit context (APP for app mode; IAT/UAT with Enterprise for enterprise mode) [Parameter()] [object] $Context ) @@ -50,18 +78,78 @@ $stackPath = Get-PSCallStackPath Write-Debug "[$stackPath] - Start" $Context = Resolve-GitHubContext -Context $Context + Assert-GitHubContext -Context $Context -AuthType APP, IAT, UAT } process { switch ($PSCmdlet.ParameterSetName) { - 'EnterpriseOrganization' { + 'App-ByTarget' { + if ($Context.AuthType -ne 'APP') { + throw 'App-ByTarget requires APP authentication. Provide an App context or connect as an App.' + } + + $id = $null + if ($Target -is [int] -or $Target -is [long] -or $Target -is [uint64]) { $id = [uint64]$Target } + elseif ($Target -is [string] -and ($Target -as [uint64])) { $id = [uint64]$Target } + + if ($id) { + if ($PSCmdlet.ShouldProcess("GitHub App Installation: $id", 'Uninstall')) { + Uninstall-GitHubAppAsApp -ID $id -Context $Context -Confirm:$false + } + return + } + + $installations = Get-GitHubAppInstallation -Context $Context + $instMatches = $installations | Where-Object { $_.Target.Name -like "*$Target*" } + if (-not $instMatches) { throw "No installations found matching target '$Target'." } + foreach ($inst in $instMatches) { + if ($PSCmdlet.ShouldProcess("GitHub App Installation: $($inst.ID) [$($inst.Target.Type)/$($inst.Target.Name)]", 'Uninstall')) { + Uninstall-GitHubAppAsApp -ID $inst.ID -Context $Context -Confirm:$false + } + } + } + + 'App-ByObject' { + if ($Context.AuthType -ne 'APP') { + throw 'App-ByObject requires APP authentication. Provide an App context or connect as an App.' + } + foreach ($inst in $InstallationObject) { + if (-not $inst.ID) { continue } + if ($PSCmdlet.ShouldProcess("GitHub App Installation: $($inst.ID) [$($inst.Target.Type)/$($inst.Target.Name)]", 'Uninstall')) { + Uninstall-GitHubAppAsApp -ID $inst.ID -Context $Context -Confirm:$false + } + } + } + + 'Enterprise-ByID' { + $effectiveEnterprise = if ($Enterprise) { $Enterprise } else { $Context.Enterprise } + if (-not $effectiveEnterprise) { throw 'Enterprise-ByID requires an enterprise to be specified (via -Enterprise or Context.Enterprise).' } $params = @{ - Enterprise = $Enterprise + Enterprise = $effectiveEnterprise Organization = $Organization - ID = $ID + ID = $InstallationID Context = $Context } - Uninstall-GitHubAppOnEnterpriseOrganization @params + if ($PSCmdlet.ShouldProcess("GitHub App Installation: $effectiveEnterprise/$Organization/$InstallationID", 'Uninstall')) { + Uninstall-GitHubAppOnEnterpriseOrganization @params -Confirm:$false + } + } + + 'Enterprise-BySlug' { + $effectiveEnterprise = if ($Enterprise) { $Enterprise } else { $Context.Enterprise } + if (-not $effectiveEnterprise) { throw 'Enterprise-BySlug requires an enterprise to be specified (via -Enterprise or Context.Enterprise).' } + $inst = Get-GitHubEnterpriseOrganizationAppInstallation -Enterprise $effectiveEnterprise -Organization $Organization -Context $Context | + Where-Object { $_.App.Slug -eq $AppSlug } | Select-Object -First 1 + if (-not $inst) { throw "No installation found for app slug '$AppSlug' in org '$Organization'." } + $params = @{ + Enterprise = $effectiveEnterprise + Organization = $Organization + ID = $inst.ID + Context = $Context + } + if ($PSCmdlet.ShouldProcess("GitHub App Installation: $effectiveEnterprise/$Organization/$($inst.ID) (app '$AppSlug')", 'Uninstall')) { + Uninstall-GitHubAppOnEnterpriseOrganization @params -Confirm:$false + } } } } @@ -70,5 +158,3 @@ Write-Debug "[$stackPath] - End" } } - -#SkipTest:FunctionTest:Will add a test for this function in a future PR diff --git a/src/functions/public/Auth/Disconnect-GitHubAccount.ps1 b/src/functions/public/Auth/Disconnect-GitHubAccount.ps1 index 0007e1958..32ce7a61a 100644 --- a/src/functions/public/Auth/Disconnect-GitHubAccount.ps1 +++ b/src/functions/public/Auth/Disconnect-GitHubAccount.ps1 @@ -66,7 +66,12 @@ Write-Debug "isIATAuthType: $isIATAuthType" Write-Debug "isNotExpired: $isNotExpired" if ($isNotGitHubToken -and $isIATAuthType -and $isNotExpired) { - Revoke-GitHubAppInstallationAccessToken -Context $contextItem + try { + Revoke-GitHubAppInstallationAccessToken -Context $contextItem + } catch { + Write-Debug "[$stackPath] - Failed to revoke token:" + Write-Debug $_ + } } Remove-GitHubContext -Context $contextItem.ID diff --git a/src/types/GitHubContext.Types.ps1xml b/src/types/GitHubContext.Types.ps1xml index 6ea11368c..d30c41a11 100644 --- a/src/types/GitHubContext.Types.ps1xml +++ b/src/types/GitHubContext.Types.ps1xml @@ -37,7 +37,7 @@ TokenExpiresIn - if ($null -eq $this.TokenExpiresAt) { return } + if ($null -eq $this.TokenExpiresAt) { return [TimeSpan]::Zero } $this.TokenExpiresAt - [DateTime]::Now diff --git a/tests/Organizations.Tests.ps1 b/tests/Organizations.Tests.ps1 index 7d1740e5c..de4931aa3 100644 --- a/tests/Organizations.Tests.ps1 +++ b/tests/Organizations.Tests.ps1 @@ -38,12 +38,18 @@ Describe 'Organizations' { $orgName = "$orgPrefix$number" if ($AuthType -eq 'APP') { + LogGroup 'Pre-test Cleanup - App Installations' { + Get-GitHubAppInstallation -Context $context | Where-Object { $_.Target.Name -like "$orgPrefix*" } | + Uninstall-GitHubApp -Confirm:$false + } + $installationContext = Connect-GitHubApp @connectAppParams -PassThru -Default -Silent LogGroup 'Context - Installation' { Write-Host ($installationContext | Select-Object * | Out-String) } } } + AfterAll { Get-GitHubContext -ListAvailable | Disconnect-GitHubAccount -Silent Write-Host ('-' * 60) @@ -147,6 +153,23 @@ Describe 'Organizations' { Remove-GitHubOrganization -Name $orgName -Confirm:$false -Context $orgContext } + It 'Uninstall-GitHubApp - Removes app installation after organization deletion' -Skip:($OwnerType -ne 'enterprise') { + LogGroup 'Post-deletion Cleanup - App Installations' { + try { + $installations = Get-GitHubAppInstallation -Context $context | Where-Object { $_.Target.Name -eq $orgName } + foreach ($installation in $installations) { + Write-Host "Removing app installation ID: $($installation.ID) for deleted organization: $($installation.Target.Name)" + Uninstall-GitHubApp -Target $orgName -Context $context -Confirm:$false + } + $remainingInstallations = Get-GitHubAppInstallation -Context $context | Where-Object { $_.Target.Name -eq $orgName } + $remainingInstallations | Should -BeNullOrEmpty + } catch { + Write-Host "Failed to clean up app installations after organization deletion: $($_.Exception.Message)" + throw + } + } + } + Context 'Invitations' -Skip:($Owner -notin 'psmodule-test-org', 'psmodule-test-org2') { It 'New-GitHubOrganizationInvitation - Invites a user to an organization' { {