From e56a06fe9db9854f3c08577e4cf9b27c297a4ee9 Mon Sep 17 00:00:00 2001 From: Fabian Hutzli Date: Sat, 18 Apr 2026 23:53:03 +0200 Subject: [PATCH] Add Get, Grant, Set and Revoke cmdlets for EntraID app list permissions Implements ListItems.SelectedOperations.Selected support via the Microsoft Graph beta API. Lists can be addressed by GUID or display name. Permission roles use the correct list-level values (Read, Write, Owner). Display names are enriched via service principal lookup since the Graph beta API omits them on GET responses. Documentation included. --- .../Get-PnPEntraIDAppListPermission.md | 161 ++++++++++++++ .../Grant-PnPEntraIDAppListPermission.md | 144 +++++++++++++ .../Revoke-PnPEntraIDAppListPermission.md | 129 +++++++++++ .../Set-PnPEntraIDAppListPermission.md | 123 +++++++++++ .../Apps/GetEntraIDAppListPermission.cs | 202 ++++++++++++++++++ .../Apps/GrantEntraIDAppListPermission.cs | 140 ++++++++++++ .../Apps/RevokeEntraIDAppListPermission.cs | 112 ++++++++++ .../Apps/SetEntraIDAppListPermission.cs | 169 +++++++++++++++ .../Enums/AzureADNewListPermissionRole.cs | 29 +++ .../Enums/AzureADUpdateListPermissionRole.cs | 29 +++ .../Model/EntraIDAppListPermissionInternal.cs | 70 ++++++ 11 files changed, 1308 insertions(+) create mode 100644 documentation/Get-PnPEntraIDAppListPermission.md create mode 100644 documentation/Grant-PnPEntraIDAppListPermission.md create mode 100644 documentation/Revoke-PnPEntraIDAppListPermission.md create mode 100644 documentation/Set-PnPEntraIDAppListPermission.md create mode 100644 src/Commands/Apps/GetEntraIDAppListPermission.cs create mode 100644 src/Commands/Apps/GrantEntraIDAppListPermission.cs create mode 100644 src/Commands/Apps/RevokeEntraIDAppListPermission.cs create mode 100644 src/Commands/Apps/SetEntraIDAppListPermission.cs create mode 100644 src/Commands/Enums/AzureADNewListPermissionRole.cs create mode 100644 src/Commands/Enums/AzureADUpdateListPermissionRole.cs create mode 100644 src/Commands/Model/EntraIDAppListPermissionInternal.cs diff --git a/documentation/Get-PnPEntraIDAppListPermission.md b/documentation/Get-PnPEntraIDAppListPermission.md new file mode 100644 index 000000000..beaceac57 --- /dev/null +++ b/documentation/Get-PnPEntraIDAppListPermission.md @@ -0,0 +1,161 @@ +--- +Module Name: PnP.PowerShell +schema: 2.0.0 +applicable: SharePoint Online +online version: https://pnp.github.io/powershell/cmdlets/Get-PnPEntraIDAppListPermission.html +external help file: PnP.PowerShell.dll-Help.xml +title: Get-PnPEntraIDAppListPermission +--- + +# Get-PnPEntraIDAppListPermission + +## SYNOPSIS + +**Required Permissions** + + * Microsoft Graph API: Sites.ReadWrite.All + +Returns Entra ID App permissions for a list. + +## SYNTAX + +### All Permissions +```powershell +Get-PnPEntraIDAppListPermission -List [-Site ] [-Connection ] +``` + +### By Permission Id +```powershell +Get-PnPEntraIDAppListPermission -PermissionId -List [-Site ] [-Connection ] +``` + +### By App Display Name or App Id +```powershell +Get-PnPEntraIDAppListPermission -AppIdentity -List [-Site ] [-Connection ] +``` + +## DESCRIPTION + +This cmdlet returns app permissions for a list in either the current or a given site. + +The list can be identified by its GUID or display name. + +## EXAMPLES + +### EXAMPLE 1 +```powershell +Get-PnPEntraIDAppListPermission -List "Documents" +``` + +Returns all app permissions set on the Documents library of the currently connected site. + +### EXAMPLE 2 +```powershell +Get-PnPEntraIDAppListPermission -List "Documents" -Site https://contoso.sharepoint.com/sites/projects +``` + +Returns all app permissions set on the Documents library of the specified site collection. + +### EXAMPLE 3 +```powershell +Get-PnPEntraIDAppListPermission -List "12345678-1234-1234-1234-123456789012" +``` + +Returns all app permissions set on the list identified by its GUID. + +### EXAMPLE 4 +```powershell +Get-PnPEntraIDAppListPermission -List "Documents" -PermissionId aTowaS50fG1zLnNwLmV4dHxlMzhjZmIzMS00 +``` + +Returns the specific permission details for the given permission id on the Documents library. + +### EXAMPLE 5 +```powershell +Get-PnPEntraIDAppListPermission -List "Documents" -AppIdentity "My App" +``` + +Returns the specific permission details for the app with the provided display name on the Documents library. + +### EXAMPLE 6 +```powershell +Get-PnPEntraIDAppListPermission -List "Documents" -AppIdentity "89ea5c94-7736-4e25-95ad-3fa95f62b66e" +``` + +Returns the specific permission details for the app with the provided app id on the Documents library. + +## PARAMETERS + +### -AppIdentity +Specify either the display name or the app id (client id) to filter the returned permissions to a specific app. + +```yaml +Type: String +Parameter Sets: By App Display Name or App Id + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Connection +Optional connection to be used by the cmdlet. Retrieve the value for this parameter by either 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 +``` + +### -List +The list to retrieve permissions for. Accepts a list GUID or display name. + +```yaml +Type: String +Parameter Sets: (All) + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -PermissionId +If specified, the permission with that id will be retrieved. + +```yaml +Type: String +Parameter Sets: By Permission Id + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Site +Optional url of a site to retrieve the permissions for. Defaults to the currently connected site. + +```yaml +Type: SitePipeBind +Parameter Sets: (All) + +Required: False +Position: Named +Default value: Currently connected site +Accept pipeline input: False +Accept wildcard characters: False +``` + +## RELATED LINKS + +[Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) diff --git a/documentation/Grant-PnPEntraIDAppListPermission.md b/documentation/Grant-PnPEntraIDAppListPermission.md new file mode 100644 index 000000000..dd321a0a0 --- /dev/null +++ b/documentation/Grant-PnPEntraIDAppListPermission.md @@ -0,0 +1,144 @@ +--- +Module Name: PnP.PowerShell +schema: 2.0.0 +applicable: SharePoint Online +online version: https://pnp.github.io/powershell/cmdlets/Grant-PnPEntraIDAppListPermission.html +external help file: PnP.PowerShell.dll-Help.xml +title: Grant-PnPEntraIDAppListPermission +--- + +# Grant-PnPEntraIDAppListPermission + +## SYNOPSIS + +**Required Permissions** + + * Microsoft Graph API: Sites.ReadWrite.All + +Adds permissions for a given Entra ID application registration on a list. + +## SYNTAX + +```powershell +Grant-PnPEntraIDAppListPermission -AppId -DisplayName -Permissions -List [-Site ] [-Connection ] +``` + +## DESCRIPTION + +This cmdlet adds permissions for a given Entra ID application registration on a list. + +The list can be identified by its GUID or display name. + +## EXAMPLES + +### EXAMPLE 1 +```powershell +Grant-PnPEntraIDAppListPermission -AppId "aa37b89e-75a7-47e3-bdb6-b763851c61b6" -DisplayName "TestApp" -Permissions Read -List "Documents" +``` + +Grants the Entra ID application registration Read access on the Documents library of the currently connected site. + +### EXAMPLE 2 +```powershell +Grant-PnPEntraIDAppListPermission -AppId "aa37b89e-75a7-47e3-bdb6-b763851c61b6" -DisplayName "TestApp" -Permissions Write -List "12345678-1234-1234-1234-123456789012" +``` + +Grants Write access on the list identified by its GUID in the currently connected site. + +### EXAMPLE 3 +```powershell +Grant-PnPEntraIDAppListPermission -AppId "aa37b89e-75a7-47e3-bdb6-b763851c61b6" -DisplayName "TestApp" -Permissions Owner -List "Documents" -Site https://contoso.sharepoint.com/sites/projects +``` + +Grants Owner access on the Documents library of the specified site collection. + +## PARAMETERS + +### -AppId +The app id (client id) of the Entra ID application registration to grant permission for. + +```yaml +Type: Guid +Parameter Sets: (All) + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Connection +Optional connection to be used by the cmdlet. Retrieve the value for this parameter by either 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 +``` + +### -DisplayName +The display name to associate with the permission. Used for visual reference only; does not need to match the application name in Entra ID. + +```yaml +Type: String +Parameter Sets: (All) + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -List +The list to grant permissions on. Accepts a list GUID or display name. + +```yaml +Type: String +Parameter Sets: (All) + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Permissions +The permissions to grant for the Entra ID application registration. Can be Read, Write, Owner, or FullControl. + +```yaml +Type: String +Parameter Sets: (All) + +Required: True +Accepted values: Read, Write, Owner, FullControl +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Site +Optional url of a site to grant the permissions on. Defaults to the currently connected site. + +```yaml +Type: SitePipeBind +Parameter Sets: (All) + +Required: False +Position: Named +Default value: Currently connected site +Accept pipeline input: False +Accept wildcard characters: False +``` + +## RELATED LINKS + +[Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) diff --git a/documentation/Revoke-PnPEntraIDAppListPermission.md b/documentation/Revoke-PnPEntraIDAppListPermission.md new file mode 100644 index 000000000..5fa7c7a68 --- /dev/null +++ b/documentation/Revoke-PnPEntraIDAppListPermission.md @@ -0,0 +1,129 @@ +--- +Module Name: PnP.PowerShell +schema: 2.0.0 +applicable: SharePoint Online +online version: https://pnp.github.io/powershell/cmdlets/Revoke-PnPEntraIDAppListPermission.html +external help file: PnP.PowerShell.dll-Help.xml +title: Revoke-PnPEntraIDAppListPermission +--- + +# Revoke-PnPEntraIDAppListPermission + +## SYNOPSIS + +**Required Permissions** + + * Microsoft Graph API: Sites.ReadWrite.All + +Revokes permissions for a given Entra ID application registration on a list. + +## SYNTAX + +```powershell +Revoke-PnPEntraIDAppListPermission -PermissionId -List [-Site ] [-Force] [-Connection ] +``` + +## DESCRIPTION + +This cmdlet revokes an existing permission for an Entra ID application registration on a list. + +Use [Get-PnPEntraIDAppListPermission](Get-PnPEntraIDAppListPermission.md) to retrieve the `PermissionId` required by this cmdlet. + +## EXAMPLES + +### EXAMPLE 1 +```powershell +Revoke-PnPEntraIDAppListPermission -PermissionId aTowaS50fG1zLnNwLmV4dHxlMzhjZmIzMS00 -List "Documents" +``` + +Revokes the permission with the specified id on the Documents library of the currently connected site. A confirmation prompt will be shown before the permission is removed. + +### EXAMPLE 2 +```powershell +Revoke-PnPEntraIDAppListPermission -PermissionId aTowaS50fG1zLnNwLmV4dHxlMzhjZmIzMS00 -List "Documents" -Force +``` + +Revokes the permission on the Documents library without prompting for confirmation. + +### EXAMPLE 3 +```powershell +Revoke-PnPEntraIDAppListPermission -PermissionId aTowaS50fG1zLnNwLmV4dHxlMzhjZmIzMS00 -List "Documents" -Site https://contoso.sharepoint.com/sites/projects -Force +``` + +Revokes the permission on the Documents library of the specified site collection without prompting for confirmation. + +## PARAMETERS + +### -Connection +Optional connection to be used by the cmdlet. Retrieve the value for this parameter by either 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 +``` + +### -Force +When specified, no confirmation prompt will be shown before revoking the permission. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -List +The list to revoke permissions on. Accepts a list GUID or display name. + +```yaml +Type: String +Parameter Sets: (All) + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -PermissionId +The id of the permission to revoke. Use [Get-PnPEntraIDAppListPermission](Get-PnPEntraIDAppListPermission.md) to retrieve the id. + +```yaml +Type: String +Parameter Sets: (All) + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Site +Optional url of a site to revoke the permissions on. Defaults to the currently connected site. + +```yaml +Type: SitePipeBind +Parameter Sets: (All) + +Required: False +Position: Named +Default value: Currently connected site +Accept pipeline input: False +Accept wildcard characters: False +``` + +## RELATED LINKS + +[Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) diff --git a/documentation/Set-PnPEntraIDAppListPermission.md b/documentation/Set-PnPEntraIDAppListPermission.md new file mode 100644 index 000000000..a2054f5bd --- /dev/null +++ b/documentation/Set-PnPEntraIDAppListPermission.md @@ -0,0 +1,123 @@ +--- +Module Name: PnP.PowerShell +schema: 2.0.0 +applicable: SharePoint Online +online version: https://pnp.github.io/powershell/cmdlets/Set-PnPEntraIDAppListPermission.html +external help file: PnP.PowerShell.dll-Help.xml +title: Set-PnPEntraIDAppListPermission +--- + +# Set-PnPEntraIDAppListPermission + +## SYNOPSIS + +**Required Permissions** + + * Microsoft Graph API: Sites.ReadWrite.All + +Updates permissions for a given Entra ID application registration on a list. + +## SYNTAX + +```powershell +Set-PnPEntraIDAppListPermission -PermissionId -Permissions -List [-Site ] [-Connection ] +``` + +## DESCRIPTION + +This cmdlet updates an existing permission for an Entra ID application registration on a list. + +Use [Get-PnPEntraIDAppListPermission](Get-PnPEntraIDAppListPermission.md) to retrieve the `PermissionId` required by this cmdlet. + +## EXAMPLES + +### EXAMPLE 1 +```powershell +Set-PnPEntraIDAppListPermission -PermissionId aTowaS50fG1zLnNwLmV4dHxlMzhjZmIzMS00 -Permissions Read -List "Documents" +``` + +Updates the permission to Read access on the Documents library of the currently connected site. + +### EXAMPLE 2 +```powershell +Set-PnPEntraIDAppListPermission -PermissionId aTowaS50fG1zLnNwLmV4dHxlMzhjZmIzMS00 -Permissions Owner -List "Documents" -Site https://contoso.sharepoint.com/sites/projects +``` + +Updates the permission to Owner access on the Documents library of the specified site collection. + +## PARAMETERS + +### -Connection +Optional connection to be used by the cmdlet. Retrieve the value for this parameter by either 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 +``` + +### -List +The list to update permissions on. Accepts a list GUID or display name. + +```yaml +Type: String +Parameter Sets: (All) + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -PermissionId +The id of the permission to update. Use [Get-PnPEntraIDAppListPermission](Get-PnPEntraIDAppListPermission.md) to retrieve the id. + +```yaml +Type: String +Parameter Sets: (All) + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Permissions +The updated permissions for the Entra ID application registration. Can be Read, Write, Owner, or FullControl. + +```yaml +Type: String +Parameter Sets: (All) + +Required: True +Accepted values: Read, Write, Owner, FullControl +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Site +Optional url of a site to update the permissions on. Defaults to the currently connected site. + +```yaml +Type: SitePipeBind +Parameter Sets: (All) + +Required: False +Position: Named +Default value: Currently connected site +Accept pipeline input: False +Accept wildcard characters: False +``` + +## RELATED LINKS + +[Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) diff --git a/src/Commands/Apps/GetEntraIDAppListPermission.cs b/src/Commands/Apps/GetEntraIDAppListPermission.cs new file mode 100644 index 000000000..27c555179 --- /dev/null +++ b/src/Commands/Apps/GetEntraIDAppListPermission.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using System.Text.Json; +using PnP.PowerShell.Commands.Attributes; +using PnP.PowerShell.Commands.Base; +using PnP.PowerShell.Commands.Base.PipeBinds; +using PnP.PowerShell.Commands.Model; + +namespace PnP.PowerShell.Commands.Apps +{ + [Cmdlet(VerbsCommon.Get, "PnPEntraIDAppListPermission", DefaultParameterSetName = ParameterSet_ALL)] + [RequiredApiDelegatedOrApplicationPermissions("graph/Sites.FullControl.All")] + [OutputType(typeof(AzureADAppPermission))] + public class GetPnPEntraIDAppListPermission : PnPGraphCmdlet + { + private const string ParameterSet_ALL = "All Permissions"; + private const string ParameterSet_PERMISSIONID = "By Permission Id"; + private const string ParameterSet_APPIDENTITY = "By App Display Name or App Id"; + + [Parameter(Mandatory = true, ParameterSetName = ParameterSet_PERMISSIONID)] + [ValidateNotNullOrEmpty] + public string PermissionId; + + [Parameter(Mandatory = true, ParameterSetName = ParameterSet_APPIDENTITY)] + [ValidateNotNullOrEmpty] + public string AppIdentity; + + /// + /// The list to retrieve permissions for. Accepts a list GUID or display name. + /// + [Parameter(Mandatory = true, ParameterSetName = ParameterSet_ALL)] + [Parameter(Mandatory = true, ParameterSetName = ParameterSet_PERMISSIONID)] + [Parameter(Mandatory = true, ParameterSetName = ParameterSet_APPIDENTITY)] + [ValidateNotNullOrEmpty] + public string List; + + [Parameter(Mandatory = false, ParameterSetName = ParameterSet_ALL)] + [Parameter(Mandatory = false, ParameterSetName = ParameterSet_PERMISSIONID)] + [Parameter(Mandatory = false, ParameterSetName = ParameterSet_APPIDENTITY)] + public SitePipeBind Site; + + protected override void ExecuteCmdlet() + { + Guid siteId; + if (ParameterSpecified(nameof(Site))) + { + LogDebug($"Using Microsoft Graph to look up site Id for -{nameof(Site)}"); + siteId = Site.GetSiteIdThroughGraph(Connection, AccessToken); + } + else + { + LogDebug($"No -{nameof(Site)} specified, using currently connected site"); + siteId = new SitePipeBind(Connection.Url).GetSiteIdThroughGraph(Connection, AccessToken); + } + + if (siteId == Guid.Empty) + { + LogWarning("Unable to resolve the site Id. Ensure you pass a valid site via -Site or are connected to a site."); + return; + } + + var listId = ResolveListId(siteId, List); + if (listId == Guid.Empty) + { + LogWarning($"Unable to resolve list '{List}' on site {siteId}. Ensure the list exists and you have access."); + return; + } + + if (ParameterSpecified(nameof(PermissionId))) + { + // Strip any whitespace inadvertently included when copying a line-wrapped terminal value. + var cleanPermissionId = Uri.EscapeDataString(PermissionId.Trim().Replace(" ", "").Replace("\t", "").Replace("\r", "").Replace("\n", "")); + var result = GraphRequestHelper.Get($"beta/sites/{siteId}/lists/{listId}/permissions/{cleanPermissionId}"); + if (result != null) + { + var converted = result.Convert(); + EnrichWithDisplayNames(converted); + WriteObject(converted); + } + } + else + { + // Fetch all permission IDs first (collection response may omit roles), then fetch each individually + var permissions = GraphRequestHelper.GetResultCollection($"beta/sites/{siteId}/lists/{listId}/permissions?$select=id"); + if (permissions != null && permissions.Any()) + { + var results = new List(permissions.Count()); + foreach (var permission in permissions) + { + var detailed = GraphRequestHelper.Get($"beta/sites/{siteId}/lists/{listId}/permissions/{permission.Id}"); + if (detailed != null) + { + var converted = detailed.Convert(); + EnrichWithDisplayNames(converted); + results.Add(converted); + } + } + + if (ParameterSpecified(nameof(AppIdentity))) + { + var filtered = results.Where(p => p.Apps.Any(a => a.DisplayName == AppIdentity || a.Id == AppIdentity)); + WriteObject(filtered, true); + } + else + { + WriteObject(results, true); + } + } + } + } + + /// + /// The Graph beta API for list permissions does not return displayName in the + /// grantedToV2.application object on GET responses — only the app's client ID is present. + /// This method performs a best-effort lookup against Entra ID service principals to fill in + /// missing display names. It silently skips the enrichment if the caller lacks the necessary + /// Application.Read.All / Directory.Read.All permissions. + /// + private void EnrichWithDisplayNames(AzureADAppPermission permission) + { + if (permission?.Apps == null) return; + + foreach (var app in permission.Apps) + { + if (!string.IsNullOrEmpty(app.DisplayName) || string.IsNullOrEmpty(app.Id)) + continue; + + try + { + // The id stored in the permission is the application's client ID (appId). + // Query the matching service principal to get its display name. + var raw = Utilities.REST.RestHelper.Get( + Connection.HttpClient, + $"https://{Connection.GraphEndPoint}/v1.0/servicePrincipals?$filter=appId eq '{Uri.EscapeDataString(app.Id)}'&$select=displayName,appId", + AccessToken); + + if (string.IsNullOrEmpty(raw)) continue; + + var doc = JsonSerializer.Deserialize(raw); + if (doc.TryGetProperty("value", out JsonElement valueEl)) + { + var first = valueEl.EnumerateArray().FirstOrDefault(); + if (first.ValueKind == JsonValueKind.Object && + first.TryGetProperty("displayName", out JsonElement nameEl)) + { + app.DisplayName = nameEl.GetString(); + LogDebug($"Resolved display name '{app.DisplayName}' for app {app.Id}"); + } + } + } + catch (Exception ex) + { + // Best-effort: if caller lacks Directory.Read.All / Application.Read.All, skip silently + LogDebug($"Could not resolve display name for app {app.Id}: {ex.Message}"); + } + } + } + + /// + /// Resolves the list identifier (GUID or display name) to a list GUID via the Graph API. + /// + private Guid ResolveListId(Guid siteId, string listIdentifier) + { + if (Guid.TryParse(listIdentifier, out Guid parsedId)) + { + return parsedId; + } + + LogDebug($"List identifier '{listIdentifier}' is not a GUID; querying Graph to resolve list by display name"); + + var raw = Utilities.REST.RestHelper.Get( + Connection.HttpClient, + $"https://{Connection.GraphEndPoint}/beta/sites/{siteId}/lists?$select=id,displayName", + AccessToken); + + if (string.IsNullOrEmpty(raw)) + { + return Guid.Empty; + } + + var doc = JsonSerializer.Deserialize(raw); + if (doc.TryGetProperty("value", out JsonElement valueElement)) + { + foreach (var item in valueElement.EnumerateArray()) + { + if (item.TryGetProperty("displayName", out JsonElement displayNameEl) && + displayNameEl.GetString().Equals(listIdentifier, StringComparison.OrdinalIgnoreCase)) + { + if (item.TryGetProperty("id", out JsonElement idEl)) + { + return Guid.Parse(idEl.GetString()); + } + } + } + } + + return Guid.Empty; + } + } +} diff --git a/src/Commands/Apps/GrantEntraIDAppListPermission.cs b/src/Commands/Apps/GrantEntraIDAppListPermission.cs new file mode 100644 index 000000000..7fb664b67 --- /dev/null +++ b/src/Commands/Apps/GrantEntraIDAppListPermission.cs @@ -0,0 +1,140 @@ +using System; +using System.Linq; +using System.Management.Automation; +using System.Text.Json; +using PnP.PowerShell.Commands.Attributes; +using PnP.PowerShell.Commands.Base; +using PnP.PowerShell.Commands.Base.PipeBinds; +using PnP.PowerShell.Commands.Enums; +using PnP.PowerShell.Commands.Model; +using PnP.PowerShell.Commands.Utilities; + +namespace PnP.PowerShell.Commands.Apps +{ + [Cmdlet(VerbsSecurity.Grant, "PnPEntraIDAppListPermission")] + [RequiredApiDelegatedPermissions("graph/Sites.FullControl.All")] + [OutputType(typeof(AzureADAppPermission))] + public class GrantPnPEntraIDAppListPermission : PnPGraphCmdlet + { + [Parameter(Mandatory = true)] + [ValidateNotNullOrEmpty] + public Guid AppId; + + [Parameter(Mandatory = true)] + [ValidateNotNullOrEmpty] + public string DisplayName; + + /// + /// The list to grant permissions on. Accepts a list GUID or display name. + /// + [Parameter(Mandatory = true)] + [ValidateNotNullOrEmpty] + public string List; + + [Parameter(Mandatory = false)] + public SitePipeBind Site; + + [Parameter(Mandatory = true)] + [ArgumentCompleter(typeof(EnumAsStringArgumentCompleter))] + public string[] Permissions; + + protected override void ExecuteCmdlet() + { + Guid siteId; + if (ParameterSpecified(nameof(Site))) + { + LogDebug($"Using Microsoft Graph to look up site Id for -{nameof(Site)}"); + siteId = Site.GetSiteIdThroughGraph(Connection, AccessToken); + LogDebug($"Site resolved to Id {siteId}"); + } + else + { + LogDebug($"No -{nameof(Site)} specified, using currently connected site"); + siteId = new SitePipeBind(Connection.Url).GetSiteIdThroughGraph(Connection, AccessToken); + LogDebug($"Currently connected site has Id {siteId}"); + } + + if (siteId == Guid.Empty) + { + LogWarning("Unable to resolve the site Id. Ensure you pass a valid site via -Site or are connected to a site."); + return; + } + + var listId = ResolveListId(siteId, List); + if (listId == Guid.Empty) + { + LogWarning($"Unable to resolve list '{List}' on site {siteId}. Ensure the list exists and you have access."); + return; + } + + // Apply multi-geo fix (same approach as Grant-PnPEntraIDAppSitePermission) + Utilities.REST.RestHelper.Get(Connection.HttpClient, $"https://{Connection.GraphEndPoint}/beta/sites/{siteId}", AccessToken); + + var roles = Permissions.Select(p => p.ToString().ToLowerInvariant()).ToArray(); + + var payload = new + { + grantedToV2 = new + { + application = new + { + id = AppId.ToString(), + displayName = DisplayName + } + }, + roles + }; + + LogDebug($"Granting App {AppId} the permission{(roles.Length != 1 ? "s" : "")} {string.Join(", ", roles)} on list {listId}"); + + var result = Utilities.REST.RestHelper.Post( + Connection.HttpClient, + $"https://{Connection.GraphEndPoint}/beta/sites/{siteId}/lists/{listId}/permissions", + AccessToken, + payload); + + WriteObject(result?.Convert()); + } + + /// + /// Resolves the list identifier (GUID or display name) to a list GUID via the Graph API. + /// + private Guid ResolveListId(Guid siteId, string listIdentifier) + { + if (Guid.TryParse(listIdentifier, out Guid parsedId)) + { + return parsedId; + } + + LogDebug($"List identifier '{listIdentifier}' is not a GUID; querying Graph to resolve list by display name"); + + var raw = Utilities.REST.RestHelper.Get( + Connection.HttpClient, + $"https://{Connection.GraphEndPoint}/beta/sites/{siteId}/lists?$select=id,displayName", + AccessToken); + + if (string.IsNullOrEmpty(raw)) + { + return Guid.Empty; + } + + var doc = JsonSerializer.Deserialize(raw); + if (doc.TryGetProperty("value", out JsonElement valueElement)) + { + foreach (var item in valueElement.EnumerateArray()) + { + if (item.TryGetProperty("displayName", out JsonElement displayNameEl) && + displayNameEl.GetString().Equals(listIdentifier, StringComparison.OrdinalIgnoreCase)) + { + if (item.TryGetProperty("id", out JsonElement idEl)) + { + return Guid.Parse(idEl.GetString()); + } + } + } + } + + return Guid.Empty; + } + } +} diff --git a/src/Commands/Apps/RevokeEntraIDAppListPermission.cs b/src/Commands/Apps/RevokeEntraIDAppListPermission.cs new file mode 100644 index 000000000..6862d40a8 --- /dev/null +++ b/src/Commands/Apps/RevokeEntraIDAppListPermission.cs @@ -0,0 +1,112 @@ +using System; +using System.Management.Automation; +using System.Text.Json; +using PnP.PowerShell.Commands.Attributes; +using PnP.PowerShell.Commands.Base; +using PnP.PowerShell.Commands.Base.PipeBinds; + +namespace PnP.PowerShell.Commands.Apps +{ + [Cmdlet(VerbsSecurity.Revoke, "PnPEntraIDAppListPermission")] + [RequiredApiDelegatedOrApplicationPermissions("graph/Sites.FullControl.All")] + public class RevokePnPEntraIDAppListPermission : PnPGraphCmdlet + { + [Parameter(Mandatory = true)] + [ValidateNotNullOrEmpty] + public string PermissionId; + + /// + /// The list from which the permission should be revoked. Accepts a list GUID or display name. + /// + [Parameter(Mandatory = true)] + [ValidateNotNullOrEmpty] + public string List; + + [Parameter(Mandatory = false)] + public SitePipeBind Site; + + [Parameter(Mandatory = false)] + public SwitchParameter Force; + + protected override void ExecuteCmdlet() + { + Guid siteId; + if (ParameterSpecified(nameof(Site))) + { + LogDebug($"Using Microsoft Graph to look up site Id for -{nameof(Site)}"); + siteId = Site.GetSiteIdThroughGraph(Connection, AccessToken); + } + else + { + LogDebug($"No -{nameof(Site)} specified, using currently connected site"); + siteId = new SitePipeBind(Connection.Url).GetSiteIdThroughGraph(Connection, AccessToken); + } + + if (siteId == Guid.Empty) + { + LogWarning("Unable to resolve the site Id. Ensure you pass a valid site via -Site or are connected to a site."); + return; + } + + var listId = ResolveListId(siteId, List); + if (listId == Guid.Empty) + { + LogWarning($"Unable to resolve list '{List}' on site {siteId}. Ensure the list exists and you have access."); + return; + } + + if (Force || ShouldContinue("Are you sure you want to revoke the list permission?", string.Empty)) + { + // PermissionId is a long base64 string; strip any whitespace the user may have + // inadvertently included when copying a line-wrapped terminal value. + var cleanPermissionId = Uri.EscapeDataString(PermissionId.Trim().Replace(" ", "").Replace("\t", "").Replace("\r", "").Replace("\n", "")); + LogDebug($"Revoking permission {cleanPermissionId} from list {listId} on site {siteId}"); + Utilities.REST.RestHelper.Delete( + Connection.HttpClient, + $"https://{Connection.GraphEndPoint}/beta/sites/{siteId}/lists/{listId}/permissions/{cleanPermissionId}", + AccessToken); + } + } + + /// + /// Resolves the list identifier (GUID or display name) to a list GUID via the Graph API. + /// + private Guid ResolveListId(Guid siteId, string listIdentifier) + { + if (Guid.TryParse(listIdentifier, out Guid parsedId)) + { + return parsedId; + } + + LogDebug($"List identifier '{listIdentifier}' is not a GUID; querying Graph to resolve list by display name"); + + var raw = Utilities.REST.RestHelper.Get( + Connection.HttpClient, + $"https://{Connection.GraphEndPoint}/beta/sites/{siteId}/lists?$select=id,displayName", + AccessToken); + + if (string.IsNullOrEmpty(raw)) + { + return Guid.Empty; + } + + var doc = JsonSerializer.Deserialize(raw); + if (doc.TryGetProperty("value", out JsonElement valueElement)) + { + foreach (var item in valueElement.EnumerateArray()) + { + if (item.TryGetProperty("displayName", out JsonElement displayNameEl) && + displayNameEl.GetString().Equals(listIdentifier, StringComparison.OrdinalIgnoreCase)) + { + if (item.TryGetProperty("id", out JsonElement idEl)) + { + return Guid.Parse(idEl.GetString()); + } + } + } + } + + return Guid.Empty; + } + } +} diff --git a/src/Commands/Apps/SetEntraIDAppListPermission.cs b/src/Commands/Apps/SetEntraIDAppListPermission.cs new file mode 100644 index 000000000..dea0686f2 --- /dev/null +++ b/src/Commands/Apps/SetEntraIDAppListPermission.cs @@ -0,0 +1,169 @@ +using System; +using System.Linq; +using System.Management.Automation; +using System.Text.Json; +using PnP.PowerShell.Commands.Attributes; +using PnP.PowerShell.Commands.Base; +using PnP.PowerShell.Commands.Base.PipeBinds; +using PnP.PowerShell.Commands.Enums; +using PnP.PowerShell.Commands.Model; +using PnP.PowerShell.Commands.Utilities; + +namespace PnP.PowerShell.Commands.Apps +{ + [Cmdlet(VerbsCommon.Set, "PnPEntraIDAppListPermission")] + [RequiredApiDelegatedOrApplicationPermissions("graph/Sites.FullControl.All")] + [OutputType(typeof(AzureADAppPermission))] + public class SetPnPEntraIDAppListPermission : PnPGraphCmdlet + { + [Parameter(Mandatory = true)] + [ValidateNotNullOrEmpty] + public string PermissionId; + + /// + /// The list whose permission should be updated. Accepts a list GUID or display name. + /// + [Parameter(Mandatory = true)] + [ValidateNotNullOrEmpty] + public string List; + + [Parameter(Mandatory = false)] + public SitePipeBind Site; + + [Parameter(Mandatory = true)] + [ArgumentCompleter(typeof(EnumAsStringArgumentCompleter))] + public string[] Permissions; + + protected override void ExecuteCmdlet() + { + Guid siteId; + if (ParameterSpecified(nameof(Site))) + { + LogDebug($"Using Microsoft Graph to look up site Id for -{nameof(Site)}"); + siteId = Site.GetSiteIdThroughGraph(Connection, AccessToken); + } + else + { + LogDebug($"No -{nameof(Site)} specified, using currently connected site"); + siteId = new SitePipeBind(Connection.Url).GetSiteIdThroughGraph(Connection, AccessToken); + } + + if (siteId == Guid.Empty) + { + LogWarning("Unable to resolve the site Id. Ensure you pass a valid site via -Site or are connected to a site."); + return; + } + + var listId = ResolveListId(siteId, List); + if (listId == Guid.Empty) + { + LogWarning($"Unable to resolve list '{List}' on site {siteId}. Ensure the list exists and you have access."); + return; + } + + var payload = new + { + roles = Permissions.Select(p => p.ToLowerInvariant()).ToArray() + }; + + // Strip any whitespace inadvertently included when copying a line-wrapped terminal value. + var cleanPermissionId = Uri.EscapeDataString(PermissionId.Trim().Replace(" ", "").Replace("\t", "").Replace("\r", "").Replace("\n", "")); + LogDebug($"Updating permission {cleanPermissionId} on list {listId} to {string.Join(", ", payload.roles)}"); + + var result = Utilities.REST.RestHelper.Patch( + Connection.HttpClient, + $"https://{Connection.GraphEndPoint}/beta/sites/{siteId}/lists/{listId}/permissions/{cleanPermissionId}", + AccessToken, + payload); + + if (result != null) + { + var converted = result.Convert(); + EnrichWithDisplayNames(converted); + WriteObject(converted); + } + } + + /// + /// Best-effort resolution of missing app display names via Entra ID service principals. + /// The Graph beta API does not return displayName in grantedToV2.application on PATCH responses. + /// Silently skips if the caller lacks Application.Read.All / Directory.Read.All. + /// + private void EnrichWithDisplayNames(AzureADAppPermission permission) + { + if (permission?.Apps == null) return; + + foreach (var app in permission.Apps) + { + if (!string.IsNullOrEmpty(app.DisplayName) || string.IsNullOrEmpty(app.Id)) + continue; + + try + { + var raw = Utilities.REST.RestHelper.Get( + Connection.HttpClient, + $"https://{Connection.GraphEndPoint}/v1.0/servicePrincipals?$filter=appId eq '{Uri.EscapeDataString(app.Id)}'&$select=displayName,appId", + AccessToken); + + if (string.IsNullOrEmpty(raw)) continue; + + var doc = System.Text.Json.JsonSerializer.Deserialize(raw); + if (doc.TryGetProperty("value", out var valueEl)) + { + var first = valueEl.EnumerateArray().FirstOrDefault(); + if (first.ValueKind == System.Text.Json.JsonValueKind.Object && + first.TryGetProperty("displayName", out var nameEl)) + { + app.DisplayName = nameEl.GetString(); + } + } + } + catch (Exception ex) + { + LogDebug($"Could not resolve display name for app {app.Id}: {ex.Message}"); + } + } + } + + /// + /// Resolves the list identifier (GUID or display name) to a list GUID via the Graph API. + /// + private Guid ResolveListId(Guid siteId, string listIdentifier) + { + if (Guid.TryParse(listIdentifier, out Guid parsedId)) + { + return parsedId; + } + + LogDebug($"List identifier '{listIdentifier}' is not a GUID; querying Graph to resolve list by display name"); + + var raw = Utilities.REST.RestHelper.Get( + Connection.HttpClient, + $"https://{Connection.GraphEndPoint}/beta/sites/{siteId}/lists?$select=id,displayName", + AccessToken); + + if (string.IsNullOrEmpty(raw)) + { + return Guid.Empty; + } + + var doc = JsonSerializer.Deserialize(raw); + if (doc.TryGetProperty("value", out JsonElement valueElement)) + { + foreach (var item in valueElement.EnumerateArray()) + { + if (item.TryGetProperty("displayName", out JsonElement displayNameEl) && + displayNameEl.GetString().Equals(listIdentifier, StringComparison.OrdinalIgnoreCase)) + { + if (item.TryGetProperty("id", out JsonElement idEl)) + { + return Guid.Parse(idEl.GetString()); + } + } + } + } + + return Guid.Empty; + } + } +} diff --git a/src/Commands/Enums/AzureADNewListPermissionRole.cs b/src/Commands/Enums/AzureADNewListPermissionRole.cs new file mode 100644 index 000000000..2d27b17ae --- /dev/null +++ b/src/Commands/Enums/AzureADNewListPermissionRole.cs @@ -0,0 +1,29 @@ +namespace PnP.PowerShell.Commands.Enums +{ + /// + /// Defines the roles that can be chosen when granting a new list, list item, or file permission + /// See Graph Reference + /// + public enum AzureADNewListPermissionRole + { + /// + /// Provides the ability to read the metadata and contents of the item + /// + Read, + + /// + /// Provides the ability to read and modify the metadata and contents of the item + /// + Write, + + /// + /// Provides owner-level access to the item + /// + Owner, + + /// + /// Provides full control of the resource + /// + FullControl + } +} diff --git a/src/Commands/Enums/AzureADUpdateListPermissionRole.cs b/src/Commands/Enums/AzureADUpdateListPermissionRole.cs new file mode 100644 index 000000000..21bd335bc --- /dev/null +++ b/src/Commands/Enums/AzureADUpdateListPermissionRole.cs @@ -0,0 +1,29 @@ +namespace PnP.PowerShell.Commands.Enums +{ + /// + /// Defines the roles that can be chosen when updating an existing list, list item, or file permission + /// See Graph Reference + /// + public enum AzureADUpdateListPermissionRole + { + /// + /// Provides the ability to read the metadata and contents of the item + /// + Read, + + /// + /// Provides the ability to read and modify the metadata and contents of the item + /// + Write, + + /// + /// Provides owner-level access to the item + /// + Owner, + + /// + /// Provides full control of the resource + /// + FullControl + } +} diff --git a/src/Commands/Model/EntraIDAppListPermissionInternal.cs b/src/Commands/Model/EntraIDAppListPermissionInternal.cs new file mode 100644 index 000000000..ded365084 --- /dev/null +++ b/src/Commands/Model/EntraIDAppListPermissionInternal.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace PnP.PowerShell.Commands.Model +{ + /// + /// Internal model for deserializing Graph API beta list permission responses. + /// List permissions use grantedToV2 (singular) rather than grantedToIdentities (array). + /// + internal class EntraIDAppListPermissionInternal + { + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("roles")] + public string[] Roles { get; set; } + + /// + /// Used in the beta list permissions API response (singular object) + /// + [JsonPropertyName("grantedToV2")] + public ListPermissionGrantedToV2Internal GrantedToV2 { get; set; } + + /// + /// Fallback for APIs that still return the older grantedToIdentities array + /// + [JsonPropertyName("grantedToIdentities")] + public List GrantedToIdentities { get; set; } + + internal AzureADAppPermission Convert() + { + var permission = new AzureADAppPermission + { + Id = Id, + Roles = Roles + }; + + if (GrantedToV2?.Application != null) + { + permission.Apps.Add(new AzureADAppIdentity + { + DisplayName = GrantedToV2.Application.DisplayName, + Id = GrantedToV2.Application.Id + }); + } + else if (GrantedToIdentities != null) + { + foreach (var identity in GrantedToIdentities) + { + if (identity?.Application != null) + { + permission.Apps.Add(new AzureADAppIdentity + { + DisplayName = identity.Application.DisplayName, + Id = identity.Application.Id + }); + } + } + } + + return permission; + } + } + + internal class ListPermissionGrantedToV2Internal + { + [JsonPropertyName("application")] + public AppIdentityInternal Application { get; set; } + } +}