From a8b5ec4eceef547153af420c029fe525c441144a Mon Sep 17 00:00:00 2001 From: Gautam Sheth Date: Fri, 3 Jul 2026 23:40:53 +0300 Subject: [PATCH 1/2] Add Remove-PnPGeoAdministrator cmdlet Add a PnP cmdlet for removing SharePoint Online geo administrators through the existing MultiGeo REST client. Match the SPO management shell request shape for user, group, and object ID removal. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + documentation/Remove-PnPGeoAdministrator.md | 118 ++++++++++++++++++ src/Commands/Admin/RemoveGeoAdministrator.cs | 53 ++++++++ .../MultiGeo/MultiGeoRestApiClient.cs | 74 +++++++++++ 4 files changed, 246 insertions(+) create mode 100644 documentation/Remove-PnPGeoAdministrator.md create mode 100644 src/Commands/Admin/RemoveGeoAdministrator.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 662b65f95..4acf33fa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ## [Current nightly] ### Added +- Added `Remove-PnPGeoAdministrator` cmdlet to remove SharePoint Online geo administrators. - Added `Get-PnPMultiGeoExperience` cmdlet to retrieve the SharePoint Online multi-geo experience mode. [#5372](https://github.com/pnp/powershell/pull/5372) - Added `Set-PnPMultiGeoExperience` cmdlet to upgrade the tenant multi-geo experience to include SharePoint Online Multi-Geo. [#5369](https://github.com/pnp/powershell/pull/5369) - Added `Set-PnPMultiGeoCompanyAllowedDataLocation` cmdlet to start setting up a SharePoint Online multi-geo allowed data location. [#5368](https://github.com/pnp/powershell/pull/5368) diff --git a/documentation/Remove-PnPGeoAdministrator.md b/documentation/Remove-PnPGeoAdministrator.md new file mode 100644 index 000000000..0a7885bc3 --- /dev/null +++ b/documentation/Remove-PnPGeoAdministrator.md @@ -0,0 +1,118 @@ +--- +Module Name: PnP.PowerShell +title: Remove-PnPGeoAdministrator +schema: 2.0.0 +applicable: SharePoint Online +external help file: PnP.PowerShell.dll-Help.xml +online version: https://pnp.github.io/powershell/cmdlets/Remove-PnPGeoAdministrator.html +--- + +# Remove-PnPGeoAdministrator + +## SYNOPSIS +Removes a SharePoint Online geo administrator. + +## SYNTAX + +```powershell +Remove-PnPGeoAdministrator [-UserPrincipalName] [-Connection ] + +Remove-PnPGeoAdministrator [-GroupAlias] [-Connection ] + +Remove-PnPGeoAdministrator [-ObjectId] [-Connection ] +``` + +## DESCRIPTION +Removes a user or group as a SharePoint Online geo administrator. + +## EXAMPLES + +### EXAMPLE 1 + +```powershell +Remove-PnPGeoAdministrator -UserPrincipalName user@contoso.onmicrosoft.com +``` + +Removes the specified user as a geo administrator. + +### EXAMPLE 2 + +```powershell +Remove-PnPGeoAdministrator -GroupAlias spo-geo-admins +``` + +Removes the specified group as a geo administrator. + +### EXAMPLE 3 + +```powershell +Remove-PnPGeoAdministrator -ObjectId 11111111-1111-1111-1111-111111111111 +``` + +Removes the geo administrator with the specified object ID. + +## PARAMETERS + +### -Connection +Optional connection to be used by the cmdlet. Retrieve the value for this parameter by specifying `-ReturnConnection` on `Connect-PnPOnline` or by executing `Get-PnPConnection`. + +```yaml +Type: PnPConnection +Parameter Sets: (All) + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -GroupAlias +Specifies the alias of the group to remove as a geo administrator. + +```yaml +Type: String +Parameter Sets: Group + +Required: True +Position: 0 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ObjectId +Specifies the object ID of the user or group to remove as a geo administrator. + +```yaml +Type: Guid +Parameter Sets: ObjectId + +Required: True +Position: 0 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -UserPrincipalName +Specifies the user principal name of the user to remove as a geo administrator. + +```yaml +Type: String +Parameter Sets: User + +Required: True +Position: 0 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +## OUTPUTS + +### None + +## RELATED LINKS + +[Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) diff --git a/src/Commands/Admin/RemoveGeoAdministrator.cs b/src/Commands/Admin/RemoveGeoAdministrator.cs new file mode 100644 index 000000000..d9545cb5e --- /dev/null +++ b/src/Commands/Admin/RemoveGeoAdministrator.cs @@ -0,0 +1,53 @@ +using PnP.PowerShell.Commands.Attributes; +using PnP.PowerShell.Commands.Base; +using PnP.PowerShell.Commands.Utilities.MultiGeo; +using System; +using System.Management.Automation; + +namespace PnP.PowerShell.Commands.Admin +{ + [Cmdlet(VerbsCommon.Remove, "PnPGeoAdministrator", DefaultParameterSetName = ParameterSetUser)] + [RequiredApiApplicationPermissions("sharepoint/Sites.FullControl.All")] + [RequiredApiDelegatedPermissions("sharepoint/AllSites.FullControl")] + public class RemoveGeoAdministrator : PnPSharePointOnlineAdminCmdlet + { + private const string ParameterSetUser = "User"; + private const string ParameterSetGroup = "Group"; + private const string ParameterSetObjectId = "ObjectId"; + + [Parameter(Mandatory = true, Position = 0, ParameterSetName = ParameterSetUser)] + [ValidateNotNullOrEmpty] + public string UserPrincipalName { get; set; } + + [Parameter(Mandatory = true, Position = 0, ParameterSetName = ParameterSetGroup)] + [ValidateNotNullOrEmpty] + public string GroupAlias { get; set; } + + [Parameter(Mandatory = true, Position = 0, ParameterSetName = ParameterSetObjectId)] + [ValidateNotNullOrEmpty] + public Guid ObjectId { get; set; } + + protected override void ExecuteCmdlet() + { + var multiGeoRestApiClient = new MultiGeoRestApiClient(AdminContext); + if (!string.IsNullOrEmpty(GroupAlias)) + { + multiGeoRestApiClient.RemoveGeoAdministrator(GroupAlias, isGroup: true); + return; + } + + if (!string.IsNullOrEmpty(UserPrincipalName)) + { + multiGeoRestApiClient.RemoveGeoAdministrator(UserPrincipalName, isGroup: false); + return; + } + + if (ObjectId == Guid.Empty) + { + throw new InvalidOperationException(); + } + + multiGeoRestApiClient.RemoveGeoAdministrator(ObjectId); + } + } +} diff --git a/src/Commands/Utilities/MultiGeo/MultiGeoRestApiClient.cs b/src/Commands/Utilities/MultiGeo/MultiGeoRestApiClient.cs index c703e7fea..2dd28db02 100644 --- a/src/Commands/Utilities/MultiGeo/MultiGeoRestApiClient.cs +++ b/src/Commands/Utilities/MultiGeo/MultiGeoRestApiClient.cs @@ -37,10 +37,17 @@ internal class MultiGeoRestApiClient private const string UpdateAllInstancesExperienceModePath = "GeoExperience/UpgradeAllInstancesToSPOMode"; private const string AllowedDataLocationsApiVersion = "1.3.11"; private const string AllowedDataLocationsPath = "AllowedDataLocations"; + private const string GeoAdministratorsMinimumApiVersion = "1.2-beta"; + private const string GeoAdministratorsByLoginNameMaximumApiVersion = "1.3.8"; + private const string GeoAdministratorsByPrincipalMinimumApiVersion = "1.3.9"; + private const string GeoAdministratorsByLoginNamePath = "GeoAdministrators(loginName='{0}')"; + private const string GeoAdministratorsByLoginNameAndTypePath = "GeoAdministrators/GetByLoginNameAndType(loginName='{0}', memberType={1:D})"; + private const string GeoAdministratorsByObjectIdPath = "GeoAdministrators/GetByObjectId(guid'{0:D}')"; private const string StorageQuotasMinimumApiVersion = "1.3.1"; private const string StorageQuotasPath = "StorageQuotas"; private const string StorageQuotaByLocationPath = "StorageQuotas(geoLocation='{0}')"; private const string MultiGeoApiVersionsPath = "MultiGeoApiVersions"; + private const string DeleteVerbString = "DELETE"; private const string PatchVerbString = "PATCH"; private const string UserMoveJobsMinimumApiVersion = "1.0"; private const string UserMoveJobsByMoveIdMinimumApiVersion = "1.2.2"; @@ -64,6 +71,8 @@ internal class MultiGeoRestApiClient private const int SiteMoveRunsWorkflow2013ErrorCode = -113; private const int SiteMoveRequiresForceErrorCode = -116; private const int SiteMoveContainsBcsErrorCode = -139; + private const int GeoAdministratorUserMemberType = 1; + private const int GeoAdministratorGroupMemberType = 2; private const int MaximumPagination = 10; private const int ApiVersionCacheValidTimeInHours = 1; private static readonly TimeSpan CreateTenantRenameJobTimeout = TimeSpan.FromSeconds(300); @@ -333,6 +342,23 @@ internal void PartialUpdateStorageQuota(StorageQuotaEntityData quota) PostWithMethodOverride(path, quota, PatchVerbString, apiVersion); } + internal void RemoveGeoAdministrator(string loginName, bool isGroup) + { + var apiVersion = GetGeoAdministratorsApiVersion(); + var path = IsSupportedApiVersionRange(apiVersion, GeoAdministratorsMinimumApiVersion, GeoAdministratorsByLoginNameMaximumApiVersion) + ? string.Format(CultureInfo.InvariantCulture, GeoAdministratorsByLoginNamePath, ProcessSpecialChars(loginName)) + : string.Format(CultureInfo.InvariantCulture, GeoAdministratorsByLoginNameAndTypePath, ProcessSpecialChars(loginName), isGroup ? GeoAdministratorGroupMemberType : GeoAdministratorUserMemberType); + + PostWithMethodOverrideEmptyBody(path, DeleteVerbString, apiVersion); + } + + internal void RemoveGeoAdministrator(Guid objectId) + { + var apiVersion = GetGeoAdministratorsByPrincipalApiVersion(); + var path = string.Format(CultureInfo.InvariantCulture, GeoAdministratorsByObjectIdPath, objectId); + PostWithMethodOverrideEmptyBody(path, DeleteVerbString, apiVersion); + } + internal void CancelUserMoveJob(string userPrincipalName) { var apiVersion = GetCurrentApiVersion(UserMoveJobsMinimumApiVersion); @@ -435,6 +461,21 @@ private void PostWithMethodOverride(string path, object payload, string methodOv }, timeout: null, allowRetries: false); } + private void PostWithMethodOverrideEmptyBody(string path, string methodOverride, string apiVersion) + { + Send(() => + { + var request = CreateRequest(HttpMethod.Post, path, apiVersion, string.Empty); + request.Headers.TryAddWithoutValidation("X-HTTP-Method", methodOverride); + if (request.Content != null) + { + request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json;charset=UTF-8"); + } + + return request; + }, timeout: null, allowRetries: false); + } + private HttpRequestMessage CreateRequest(HttpMethod method, string path, string apiVersion, string jsonPayload = null) { return CreateRequest(method, CreateApiUri(path, apiVersion), jsonPayload); @@ -528,6 +569,16 @@ private static bool IsSupportedApiVersion(string apiVersion, string minimumApiVe return apiVersionIndex >= 0 && minimumApiVersionIndex >= 0 && apiVersionIndex <= minimumApiVersionIndex; } + private static bool IsSupportedApiVersionRange(string apiVersion, string minimumApiVersion, string maximumApiVersion) + { + var apiVersionIndex = Array.IndexOf(ClientSupportedApiVersions, apiVersion); + var minimumApiVersionIndex = Array.IndexOf(ClientSupportedApiVersions, minimumApiVersion); + var maximumApiVersionIndex = Array.IndexOf(ClientSupportedApiVersions, maximumApiVersion); + return apiVersionIndex >= 0 && minimumApiVersionIndex >= 0 && maximumApiVersionIndex >= 0 + && apiVersionIndex <= minimumApiVersionIndex + && apiVersionIndex >= maximumApiVersionIndex; + } + private string GetStorageQuotasApiVersion() { var apiVersion = GetCurrentApiVersion(); @@ -539,6 +590,29 @@ private string GetStorageQuotasApiVersion() return apiVersion; } + private string GetGeoAdministratorsApiVersion() + { + var apiVersion = GetCurrentApiVersion(); + if (!IsSupportedApiVersionRange(apiVersion, GeoAdministratorsMinimumApiVersion, GeoAdministratorsByLoginNameMaximumApiVersion) + && !IsSupportedApiVersion(apiVersion, GeoAdministratorsByPrincipalMinimumApiVersion)) + { + throw new NotSupportedException(string.Format(CultureInfo.InvariantCulture, CommandResources.CrossGeoInvalidVersion, GetApplicationVersion())); + } + + return apiVersion; + } + + private string GetGeoAdministratorsByPrincipalApiVersion() + { + var apiVersion = GetCurrentApiVersion(); + if (!IsSupportedApiVersion(apiVersion, GeoAdministratorsByPrincipalMinimumApiVersion)) + { + throw new NotSupportedException(string.Format(CultureInfo.InvariantCulture, CommandResources.CrossGeoInvalidVersion, GetApplicationVersion())); + } + + return apiVersion; + } + private string GetGeoExperienceApiVersion() { var apiVersion = GetCurrentApiVersion(); From 669830e5c2d70d9d505e5a2801f683213e25054b Mon Sep 17 00:00:00 2001 From: Gautam Sheth Date: Sat, 4 Jul 2026 00:05:40 +0300 Subject: [PATCH 2/2] Use parameter set dispatch for geo admin removal Update Remove-PnPGeoAdministrator to dispatch by ParameterSetName and remove the generic empty GUID fallback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Commands/Admin/RemoveGeoAdministrator.cs | 27 ++++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/Commands/Admin/RemoveGeoAdministrator.cs b/src/Commands/Admin/RemoveGeoAdministrator.cs index d9545cb5e..cc46fea07 100644 --- a/src/Commands/Admin/RemoveGeoAdministrator.cs +++ b/src/Commands/Admin/RemoveGeoAdministrator.cs @@ -30,24 +30,23 @@ public class RemoveGeoAdministrator : PnPSharePointOnlineAdminCmdlet protected override void ExecuteCmdlet() { var multiGeoRestApiClient = new MultiGeoRestApiClient(AdminContext); - if (!string.IsNullOrEmpty(GroupAlias)) + switch (ParameterSetName) { - multiGeoRestApiClient.RemoveGeoAdministrator(GroupAlias, isGroup: true); - return; - } + case ParameterSetGroup: + multiGeoRestApiClient.RemoveGeoAdministrator(GroupAlias, isGroup: true); + break; - if (!string.IsNullOrEmpty(UserPrincipalName)) - { - multiGeoRestApiClient.RemoveGeoAdministrator(UserPrincipalName, isGroup: false); - return; - } + case ParameterSetUser: + multiGeoRestApiClient.RemoveGeoAdministrator(UserPrincipalName, isGroup: false); + break; - if (ObjectId == Guid.Empty) - { - throw new InvalidOperationException(); - } + case ParameterSetObjectId: + multiGeoRestApiClient.RemoveGeoAdministrator(ObjectId); + break; - multiGeoRestApiClient.RemoveGeoAdministrator(ObjectId); + default: + throw new ArgumentException("Parameter set cannot be resolved using the specified named parameters."); + } } } }