From 7752febb048d8a41b73509b17c4fcd5fc83a449c Mon Sep 17 00:00:00 2001 From: "Antti K. Koskela" Date: Fri, 14 Oct 2022 13:47:55 +0300 Subject: [PATCH 1/5] Add new commandlet, Get-PnPExpiringMicrosoft365Groups --- .../GetExpiringMicrosoft365Groups.cs | 31 +++++++++++ .../Utilities/Microsoft365GroupsUtility.cs | 52 ++++++++++++++++++- src/Commands/Utilities/REST/GraphHelper.cs | 14 ++--- 3 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 src/Commands/Microsoft365Groups/GetExpiringMicrosoft365Groups.cs diff --git a/src/Commands/Microsoft365Groups/GetExpiringMicrosoft365Groups.cs b/src/Commands/Microsoft365Groups/GetExpiringMicrosoft365Groups.cs new file mode 100644 index 000000000..227af62a1 --- /dev/null +++ b/src/Commands/Microsoft365Groups/GetExpiringMicrosoft365Groups.cs @@ -0,0 +1,31 @@ +using PnP.PowerShell.Commands.Attributes; +using PnP.PowerShell.Commands.Base; +using PnP.PowerShell.Commands.Base.PipeBinds; +using PnP.PowerShell.Commands.Utilities; +using System; +using System.Linq; +using System.Management.Automation; + +namespace PnP.PowerShell.Commands.Microsoft365Groups +{ + [Cmdlet(VerbsCommon.Get, "PnPExpiringMicrosoft365Groups")] + [RequiredMinimalApiPermissions("Group.Read.All")] + public class GetExpiringMicrosoft365Groups : PnPGraphCmdlet + { + [Parameter(Mandatory = false)] + public SwitchParameter IncludeSiteUrl; + + [Parameter(Mandatory = false)] + public SwitchParameter IncludeOwners; + + [Parameter(Mandatory = false)] + public int Limit = 31; + + protected override void ExecuteCmdlet() + { + var expiringGroups = Microsoft365GroupsUtility.GetExpiringGroupsAsync(Connection, AccessToken, Limit, IncludeSiteUrl, IncludeOwners).GetAwaiter().GetResult(); + + WriteObject(expiringGroups.OrderBy(p => p.DisplayName), true); + } + } +} \ No newline at end of file diff --git a/src/Commands/Utilities/Microsoft365GroupsUtility.cs b/src/Commands/Utilities/Microsoft365GroupsUtility.cs index 0cc4ec4e7..4bf6387b0 100644 --- a/src/Commands/Utilities/Microsoft365GroupsUtility.cs +++ b/src/Commands/Utilities/Microsoft365GroupsUtility.cs @@ -50,6 +50,7 @@ internal static async Task> GetGroupsAsync(PnPCon } return items; } + internal static async Task GetGroupAsync(PnPConnection connection, Guid groupId, string accessToken, bool includeSiteUrl, bool includeOwners) { var results = await GraphHelper.GetAsync>(connection, $"v1.0/groups?$filter=groupTypes/any(c:c+eq+'Unified') and id eq '{groupId}'", accessToken); @@ -101,6 +102,7 @@ internal static async Task GetGroupAsync(PnPConnection connec } return null; } + internal static async Task GetGroupAsync(PnPConnection connection, string displayName, string accessToken, bool includeSiteUrl, bool includeOwners) { var results = await GraphHelper.GetAsync>(connection, $"v1.0/groups?$filter=groupTypes/any(c:c+eq+'Unified') and (displayName eq '{displayName}' or mailNickName eq '{displayName}')", accessToken); @@ -125,6 +127,55 @@ internal static async Task GetGroupAsync(PnPConnection connec return null; } + internal static async Task> GetExpiringGroupsAsync(PnPConnection connection, string accessToken, int limit, bool includeSiteUrl, bool includeOwners) + { + var items = new List(); + + var dateLimit = DateTime.UtcNow; + var dateStr = dateLimit.AddDays(limit).ToString("yyyy-MM-ddTHH:mm:ssZ"); + + // This query requires ConsistencyLevel header to be set. + var additionalHeaders = new Dictionary(); + additionalHeaders.Add("ConsistencyLevel", "eventual"); + + // $count=true needs to be here for reasons + // see this for some additional details: https://learn.microsoft.com/en-us/graph/aad-advanced-queries?tabs=http#group-properties + var result = await GraphHelper.GetResultCollectionAsync(connection, $"v1.0/groups?$filter=groupTypes/any(c:c+eq+'Unified') and expirationDateTime le {dateStr}&$count=true", accessToken, additionalHeaders:additionalHeaders); + if (result != null && result.Any()) + { + items.AddRange(result); + } + if (includeSiteUrl || includeOwners) + { + var chunks = BatchUtility.Chunk(items.Select(g => g.Id.ToString()), 20); + if (includeOwners) + { + foreach (var chunk in chunks) + { + var ownerResults = await BatchUtility.GetObjectCollectionBatchedAsync(connection, accessToken, chunk.ToArray(), "/groups/{0}/owners"); + foreach (var ownerResult in ownerResults) + { + items.First(i => i.Id.ToString() == ownerResult.Key).Owners = ownerResult.Value; + } + } + } + + if (includeSiteUrl) + { + foreach (var chunk in chunks) + { + var results = await BatchUtility.GetPropertyBatchedAsync(connection, accessToken, chunk.ToArray(), "/groups/{0}/sites/root", "webUrl"); + //var results = await GetSiteUrlBatchedAsync(connection, accessToken, chunk.ToArray()); + foreach (var batchResult in results) + { + items.First(i => i.Id.ToString() == batchResult.Key).SiteUrl = batchResult.Value; + } + } + } + } + return items; + } + internal static async Task GetDeletedGroupAsync(PnPConnection connection, Guid groupId, string accessToken) { return await GraphHelper.GetAsync(connection, $"v1.0/directory/deleteditems/microsoft.graph.group/{groupId}", accessToken); @@ -168,7 +219,6 @@ internal static async Task AddMembersAsync(PnPConnection connection, Guid groupI internal static string GetUserGraphUrlForUPN(string upn) { - var escapedUpn = upn.Replace("#", "%23"); if (escapedUpn.StartsWith("$")) return $"users('{escapedUpn}')"; diff --git a/src/Commands/Utilities/REST/GraphHelper.cs b/src/Commands/Utilities/REST/GraphHelper.cs index 7cd9f7538..949f284df 100644 --- a/src/Commands/Utilities/REST/GraphHelper.cs +++ b/src/Commands/Utilities/REST/GraphHelper.cs @@ -92,21 +92,17 @@ public static async Task GetResponseAsync(PnPConnection con /// Policy indicating the CamlCase that should be applied when mapping results to typed objects /// Indicates if the response be mapped to the typed object ignoring different casing /// List with objects of type T returned by the request - public static async Task> GetResultCollectionAsync(PnPConnection connection, string url, string accessToken, bool camlCasePolicy = true, bool propertyNameCaseInsensitive = false) + public static async Task> GetResultCollectionAsync(PnPConnection connection, string url, string accessToken, bool camlCasePolicy = true, bool propertyNameCaseInsensitive = false, IDictionary additionalHeaders = null) { var results = new List(); - var request = await GetAsync>(connection, url, accessToken, camlCasePolicy, propertyNameCaseInsensitive); + var request = await GetAsync>(connection, url, accessToken, camlCasePolicy, propertyNameCaseInsensitive, additionalHeaders); if (request.Items.Any()) { results.AddRange(request.Items); while (!string.IsNullOrEmpty(request.NextLink)) { - request = await GetAsync>(connection, request.NextLink, accessToken, camlCasePolicy, propertyNameCaseInsensitive); - if (request.Items.Any()) - { - results.AddRange(request.Items); - } + request = await GetAsync>(connection, request.NextLink, accessToken, camlCasePolicy, propertyNameCaseInsensitive, additionalHeaders); } } @@ -123,9 +119,9 @@ public static async Task> GetResultCollectionAsync(PnPConnecti /// Policy indicating the CamlCase that should be applied when mapping results to typed objects /// Indicates if the response be mapped to the typed object ignoring different casing /// List with objects of type T returned by the request - public static async Task GetAsync(PnPConnection connection, string url, string accessToken, bool camlCasePolicy = true, bool propertyNameCaseInsensitive = false) + public static async Task GetAsync(PnPConnection connection, string url, string accessToken, bool camlCasePolicy = true, bool propertyNameCaseInsensitive = false, IDictionary additionalHeaders = null) { - var stringContent = await GetAsync(connection, url, accessToken); + var stringContent = await GetAsync(connection, url, accessToken, additionalHeaders); if (stringContent != null) { var options = new JsonSerializerOptions { IgnoreNullValues = true }; From ee3580879735304e94d854e75aacb9a11ba45038 Mon Sep 17 00:00:00 2001 From: "Antti K. Koskela" Date: Sat, 15 Oct 2022 19:52:51 +0300 Subject: [PATCH 2/5] Rename command and helper from plural to singular (by convention) --- ...icrosoft365Groups.cs => GetExpiringMicrosoft365Group.cs} | 6 +++--- src/Commands/Utilities/Microsoft365GroupsUtility.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) rename src/Commands/Microsoft365Groups/{GetExpiringMicrosoft365Groups.cs => GetExpiringMicrosoft365Group.cs} (77%) diff --git a/src/Commands/Microsoft365Groups/GetExpiringMicrosoft365Groups.cs b/src/Commands/Microsoft365Groups/GetExpiringMicrosoft365Group.cs similarity index 77% rename from src/Commands/Microsoft365Groups/GetExpiringMicrosoft365Groups.cs rename to src/Commands/Microsoft365Groups/GetExpiringMicrosoft365Group.cs index 227af62a1..d3c9d7e40 100644 --- a/src/Commands/Microsoft365Groups/GetExpiringMicrosoft365Groups.cs +++ b/src/Commands/Microsoft365Groups/GetExpiringMicrosoft365Group.cs @@ -8,9 +8,9 @@ namespace PnP.PowerShell.Commands.Microsoft365Groups { - [Cmdlet(VerbsCommon.Get, "PnPExpiringMicrosoft365Groups")] + [Cmdlet(VerbsCommon.Get, "PnPExpiringMicrosoft365Group")] [RequiredMinimalApiPermissions("Group.Read.All")] - public class GetExpiringMicrosoft365Groups : PnPGraphCmdlet + public class GetExpiringMicrosoft365Group : PnPGraphCmdlet { [Parameter(Mandatory = false)] public SwitchParameter IncludeSiteUrl; @@ -23,7 +23,7 @@ public class GetExpiringMicrosoft365Groups : PnPGraphCmdlet protected override void ExecuteCmdlet() { - var expiringGroups = Microsoft365GroupsUtility.GetExpiringGroupsAsync(Connection, AccessToken, Limit, IncludeSiteUrl, IncludeOwners).GetAwaiter().GetResult(); + var expiringGroups = Microsoft365GroupsUtility.GetExpiringGroupAsync(Connection, AccessToken, Limit, IncludeSiteUrl, IncludeOwners).GetAwaiter().GetResult(); WriteObject(expiringGroups.OrderBy(p => p.DisplayName), true); } diff --git a/src/Commands/Utilities/Microsoft365GroupsUtility.cs b/src/Commands/Utilities/Microsoft365GroupsUtility.cs index 4bf6387b0..f771da21c 100644 --- a/src/Commands/Utilities/Microsoft365GroupsUtility.cs +++ b/src/Commands/Utilities/Microsoft365GroupsUtility.cs @@ -127,7 +127,7 @@ internal static async Task GetGroupAsync(PnPConnection connec return null; } - internal static async Task> GetExpiringGroupsAsync(PnPConnection connection, string accessToken, int limit, bool includeSiteUrl, bool includeOwners) + internal static async Task> GetExpiringGroupAsync(PnPConnection connection, string accessToken, int limit, bool includeSiteUrl, bool includeOwners) { var items = new List(); From c6b52d240e068847f31fb6f2b16f745dbe0d1265 Mon Sep 17 00:00:00 2001 From: "Antti K. Koskela" Date: Sat, 15 Oct 2022 20:06:31 +0300 Subject: [PATCH 3/5] Add documentation for the commandlet --- .../Get-PnPMicrosoft365ExpiringGroup.md | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 documentation/Get-PnPMicrosoft365ExpiringGroup.md diff --git a/documentation/Get-PnPMicrosoft365ExpiringGroup.md b/documentation/Get-PnPMicrosoft365ExpiringGroup.md new file mode 100644 index 000000000..2e476bb53 --- /dev/null +++ b/documentation/Get-PnPMicrosoft365ExpiringGroup.md @@ -0,0 +1,65 @@ +--- +Module Name: PnP.PowerShell +title: Get-PnPMicrosoft365ExpiringGroup +schema: 2.0.0 +applicable: SharePoint Online +external help file: PnP.PowerShell.dll-Help.xml +online version: https://pnp.github.io/powershell/cmdlets/Get-PnPMicrosoft365ExpiringGroup.html +--- + +# Get-PnPMicrosoft365ExpiringGroup + +## SYNOPSIS + +**Required Permissions** + + * Microsoft Graph API : One of Directory.Read.All, Directory.ReadWrite.All, Group.Read.All, Group.ReadWrite.All, GroupMember.Read.All, GroupMember.ReadWrite.All + +Gets all expiring Microsoft 365 Groups. + +## SYNTAX + +```powershell +Get-PnPMicrosoft365ExpiringGroup [-Limit ] [] +``` + +## DESCRIPTION +This command returns all expiring Microsoft 365 Groups within certain time. By default, groups expiring in the next 31 days are return (in accordance with SharePoint/OneDrive's retention period's 31-day months). + +## EXAMPLES + +### EXAMPLE 1 +```powershell +Get-PnPMicrosoft365ExpiringGroup +``` + +Returns all Groups expiring within 31 days (roughly 1 month). + +### EXAMPLE 2 +```powershell +Get-PnPMicrosoft365ExpiringGroup -Limit 93 +``` + +Returns all Microsoft 365 Groups expiring in 93 days (roughly 3 months) + + +## PARAMETERS + +### -Limit + +Limits Groups to be returned to Groups expiring in as many days. + +```yaml +Type: Int32 +Parameter Sets: (All) + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +## RELATED LINKS + +[Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) From b539910bd34ff480aa08276f0ef3eae0a56e634f Mon Sep 17 00:00:00 2001 From: "Antti K. Koskela" Date: Sat, 15 Oct 2022 20:06:40 +0300 Subject: [PATCH 4/5] Return the missing lines :) --- src/Commands/Utilities/REST/GraphHelper.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Commands/Utilities/REST/GraphHelper.cs b/src/Commands/Utilities/REST/GraphHelper.cs index 949f284df..13de8e561 100644 --- a/src/Commands/Utilities/REST/GraphHelper.cs +++ b/src/Commands/Utilities/REST/GraphHelper.cs @@ -103,6 +103,10 @@ public static async Task> GetResultCollectionAsync(PnPConnecti while (!string.IsNullOrEmpty(request.NextLink)) { request = await GetAsync>(connection, request.NextLink, accessToken, camlCasePolicy, propertyNameCaseInsensitive, additionalHeaders); + if (request.Items.Any()) + { + results.AddRange(request.Items); + } } } From b0759afd4a75aa5c5f6cb2a5d517f6c9edbca706 Mon Sep 17 00:00:00 2001 From: Gautam Sheth Date: Sat, 15 Oct 2022 22:12:19 +0300 Subject: [PATCH 5/5] Update GetExpiringMicrosoft365Group.cs Updated the cmdlet name to better reflect its function --- .../Microsoft365Groups/GetExpiringMicrosoft365Group.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Commands/Microsoft365Groups/GetExpiringMicrosoft365Group.cs b/src/Commands/Microsoft365Groups/GetExpiringMicrosoft365Group.cs index d3c9d7e40..7ae65379a 100644 --- a/src/Commands/Microsoft365Groups/GetExpiringMicrosoft365Group.cs +++ b/src/Commands/Microsoft365Groups/GetExpiringMicrosoft365Group.cs @@ -8,7 +8,7 @@ namespace PnP.PowerShell.Commands.Microsoft365Groups { - [Cmdlet(VerbsCommon.Get, "PnPExpiringMicrosoft365Group")] + [Cmdlet(VerbsCommon.Get, "PnPMicrosoft365ExpiringGroup")] [RequiredMinimalApiPermissions("Group.Read.All")] public class GetExpiringMicrosoft365Group : PnPGraphCmdlet { @@ -28,4 +28,4 @@ protected override void ExecuteCmdlet() WriteObject(expiringGroups.OrderBy(p => p.DisplayName), true); } } -} \ No newline at end of file +}