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' { {