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) diff --git a/src/Commands/Microsoft365Groups/GetExpiringMicrosoft365Group.cs b/src/Commands/Microsoft365Groups/GetExpiringMicrosoft365Group.cs new file mode 100644 index 000000000..7ae65379a --- /dev/null +++ b/src/Commands/Microsoft365Groups/GetExpiringMicrosoft365Group.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, "PnPMicrosoft365ExpiringGroup")] + [RequiredMinimalApiPermissions("Group.Read.All")] + public class GetExpiringMicrosoft365Group : 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.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 0cc4ec4e7..f771da21c 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> GetExpiringGroupAsync(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..13de8e561 100644 --- a/src/Commands/Utilities/REST/GraphHelper.cs +++ b/src/Commands/Utilities/REST/GraphHelper.cs @@ -92,17 +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); + request = await GetAsync>(connection, request.NextLink, accessToken, camlCasePolicy, propertyNameCaseInsensitive, additionalHeaders); if (request.Items.Any()) { results.AddRange(request.Items); @@ -123,9 +123,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 };