From b0710ce1705d587bfb89948d6a22c1509a902c40 Mon Sep 17 00:00:00 2001 From: Fabian Hutzli Date: Sun, 19 Apr 2026 22:32:42 +0200 Subject: [PATCH] Add Get, Grant, Set and Revoke cmdlets for EntraID app list item permissions Implements ListItems.SelectedOperations.Selected support via the Microsoft Graph beta API. The -ListItem parameter accepts the integer item ID only. 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-PnPEntraIDAppListItemPermission.md | 168 ++++++++++++++++ .../Grant-PnPEntraIDAppListItemPermission.md | 151 ++++++++++++++ .../Revoke-PnPEntraIDAppListItemPermission.md | 138 +++++++++++++ .../Set-PnPEntraIDAppListItemPermission.md | 139 +++++++++++++ .../Apps/GetEntraIDAppListItemPermission.cs | 190 ++++++++++++++++++ .../Apps/GrantEntraIDAppListItemPermission.cs | 130 ++++++++++++ .../RevokeEntraIDAppListItemPermission.cs | 100 +++++++++ .../Apps/SetEntraIDAppListItemPermission.cs | 154 ++++++++++++++ .../Enums/AzureADNewListPermissionRole.cs | 29 +++ .../Enums/AzureADUpdateListPermissionRole.cs | 29 +++ .../Model/EntraIDAppListPermissionInternal.cs | 70 +++++++ 11 files changed, 1298 insertions(+) create mode 100644 documentation/Get-PnPEntraIDAppListItemPermission.md create mode 100644 documentation/Grant-PnPEntraIDAppListItemPermission.md create mode 100644 documentation/Revoke-PnPEntraIDAppListItemPermission.md create mode 100644 documentation/Set-PnPEntraIDAppListItemPermission.md create mode 100644 src/Commands/Apps/GetEntraIDAppListItemPermission.cs create mode 100644 src/Commands/Apps/GrantEntraIDAppListItemPermission.cs create mode 100644 src/Commands/Apps/RevokeEntraIDAppListItemPermission.cs create mode 100644 src/Commands/Apps/SetEntraIDAppListItemPermission.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-PnPEntraIDAppListItemPermission.md b/documentation/Get-PnPEntraIDAppListItemPermission.md new file mode 100644 index 000000000..e3adbc39e --- /dev/null +++ b/documentation/Get-PnPEntraIDAppListItemPermission.md @@ -0,0 +1,168 @@ +--- +Module Name: PnP.PowerShell +schema: 2.0.0 +applicable: SharePoint Online +online version: https://pnp.github.io/powershell/cmdlets/Get-PnPEntraIDAppListItemPermission.html +external help file: PnP.PowerShell.dll-Help.xml +title: Get-PnPEntraIDAppListItemPermission +--- + +# Get-PnPEntraIDAppListItemPermission + +## SYNOPSIS + +**Required Permissions** + + * Microsoft Graph API: Sites.ReadWrite.All + +Returns Entra ID App permissions for a list item. + +## SYNTAX + +### All Permissions +```powershell +Get-PnPEntraIDAppListItemPermission -List -ListItem [-Site ] [-Connection ] +``` + +### By Permission Id +```powershell +Get-PnPEntraIDAppListItemPermission -PermissionId -List -ListItem [-Site ] [-Connection ] +``` + +### By App Display Name or App Id +```powershell +Get-PnPEntraIDAppListItemPermission -AppIdentity -List -ListItem [-Site ] [-Connection ] +``` + +## DESCRIPTION + +This cmdlet returns app permissions for a list item in either the current or a given site. It is used in conjunction with the Entra ID SharePoint application permission `ListItems.SelectedOperations.Selected`. + +The `-ListItem` parameter accepts the integer item ID. Use `Get-PnPListItem` to look up the ID if needed. + +## EXAMPLES + +### EXAMPLE 1 +```powershell +Get-PnPEntraIDAppListItemPermission -List "Documents" -ListItem 5 +``` + +Returns all app permissions set on the list item with integer id 5 in the Documents library of the currently connected site. + +### EXAMPLE 2 +```powershell +Get-PnPEntraIDAppListItemPermission -List "Documents" -ListItem 5 -Site https://contoso.sharepoint.com/sites/projects +``` + +Returns all app permissions set on list item 5 in the Documents library of the specified site collection. + +### EXAMPLE 4 +```powershell +Get-PnPEntraIDAppListItemPermission -List "Documents" -ListItem 5 -PermissionId aTowaS50fG1zLnNwLmV4dHxlMzhjZmIzMS00 +``` + +Returns the specific permission details for the given permission id on the list item. + +### EXAMPLE 5 +```powershell +Get-PnPEntraIDAppListItemPermission -List "Documents" -ListItem 5 -AppIdentity "My App" +``` + +Returns the specific permission details for the app with the provided display name on the list item. + +### EXAMPLE 6 +```powershell +Get-PnPEntraIDAppListItemPermission -List "Documents" -ListItem 5 -AppIdentity "89ea5c94-7736-4e25-95ad-3fa95f62b66e" +``` + +Returns the specific permission details for the app with the provided app id on the list item. + +## 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 containing the item. 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 +``` + +### -ListItem +The integer ID of the list item to retrieve permissions for. Use `Get-PnPListItem` to look up the ID if needed. + +```yaml +Type: Int32 +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-PnPEntraIDAppListItemPermission.md b/documentation/Grant-PnPEntraIDAppListItemPermission.md new file mode 100644 index 000000000..787540090 --- /dev/null +++ b/documentation/Grant-PnPEntraIDAppListItemPermission.md @@ -0,0 +1,151 @@ +--- +Module Name: PnP.PowerShell +schema: 2.0.0 +applicable: SharePoint Online +online version: https://pnp.github.io/powershell/cmdlets/Grant-PnPEntraIDAppListItemPermission.html +external help file: PnP.PowerShell.dll-Help.xml +title: Grant-PnPEntraIDAppListItemPermission +--- + +# Grant-PnPEntraIDAppListItemPermission + +## SYNOPSIS + +**Required Permissions** + + * Microsoft Graph API: Sites.ReadWrite.All + +Adds permissions for a given Entra ID application registration on a list item. + +## SYNTAX + +```powershell +Grant-PnPEntraIDAppListItemPermission -AppId -DisplayName -Permissions -List -ListItem [-Site ] [-Connection ] +``` + +## DESCRIPTION + +This cmdlet adds permissions for a given Entra ID application registration on a list item. It is used in conjunction with the Entra ID SharePoint application permission `ListItems.SelectedOperations.Selected`. + +The `-ListItem` parameter accepts the integer item ID. Use `Get-PnPListItem` to look up the ID if needed. + +## EXAMPLES + +### EXAMPLE 1 +```powershell +Grant-PnPEntraIDAppListItemPermission -AppId "aa37b89e-75a7-47e3-bdb6-b763851c61b6" -DisplayName "TestApp" -Permissions Read -List "Documents" -ListItem 5 +``` + +Grants the Entra ID application registration Read access on list item 5 in the Documents library of the currently connected site. + +### EXAMPLE 2 +```powershell +Grant-PnPEntraIDAppListItemPermission -AppId "aa37b89e-75a7-47e3-bdb6-b763851c61b6" -DisplayName "TestApp" -Permissions Owner -List "Documents" -ListItem 5 -Site https://contoso.sharepoint.com/sites/projects +``` + +Grants Owner access on list item 5 in 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 containing the item. 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 +``` + +### -ListItem +The integer ID of the list item to grant permissions on. Use `Get-PnPListItem` to look up the ID if needed. + +```yaml +Type: Int32 +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-PnPEntraIDAppListItemPermission.md b/documentation/Revoke-PnPEntraIDAppListItemPermission.md new file mode 100644 index 000000000..ddda831ac --- /dev/null +++ b/documentation/Revoke-PnPEntraIDAppListItemPermission.md @@ -0,0 +1,138 @@ +--- +Module Name: PnP.PowerShell +schema: 2.0.0 +applicable: SharePoint Online +online version: https://pnp.github.io/powershell/cmdlets/Revoke-PnPEntraIDAppListItemPermission.html +external help file: PnP.PowerShell.dll-Help.xml +title: Revoke-PnPEntraIDAppListItemPermission +--- + +# Revoke-PnPEntraIDAppListItemPermission + +## SYNOPSIS + +**Required Permissions** + + * Microsoft Graph API: Sites.ReadWrite.All + +Revokes permissions for a given Entra ID application registration on a list item. + +## SYNTAX + +```powershell +Revoke-PnPEntraIDAppListItemPermission -PermissionId -List -ListItem [-Site ] [-Force] [-Connection ] +``` + +## DESCRIPTION + +This cmdlet revokes an existing permission for an Entra ID application registration on a list item. It is used in conjunction with the Entra ID SharePoint application permission `ListItems.SelectedOperations.Selected`. + +Use [Get-PnPEntraIDAppListItemPermission](Get-PnPEntraIDAppListItemPermission.md) to retrieve the `PermissionId` required by this cmdlet. + +The `-ListItem` parameter accepts the integer item ID. Use `Get-PnPListItem` to look up the ID if needed. + +## EXAMPLES + +### EXAMPLE 1 +```powershell +Revoke-PnPEntraIDAppListItemPermission -PermissionId aTowaS50fG1zLnNwLmV4dHxlMzhjZmIzMS00 -List "Documents" -ListItem 5 +``` + +Revokes the permission with the specified id on list item 5 in the Documents library of the currently connected site. A confirmation prompt will be shown before the permission is removed. + +### EXAMPLE 2 +```powershell +Revoke-PnPEntraIDAppListItemPermission -PermissionId aTowaS50fG1zLnNwLmV4dHxlMzhjZmIzMS00 -List "Documents" -ListItem 5 -Site https://contoso.sharepoint.com/sites/projects -Force +``` + +Revokes the permission on list item 5 in 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 containing the item. 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 +``` + +### -ListItem +The integer ID of the list item from which to revoke the permission. Use `Get-PnPListItem` to look up the ID if needed. + +```yaml +Type: Int32 +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-PnPEntraIDAppListItemPermission](Get-PnPEntraIDAppListItemPermission.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-PnPEntraIDAppListItemPermission.md b/documentation/Set-PnPEntraIDAppListItemPermission.md new file mode 100644 index 000000000..9de37eb74 --- /dev/null +++ b/documentation/Set-PnPEntraIDAppListItemPermission.md @@ -0,0 +1,139 @@ +--- +Module Name: PnP.PowerShell +schema: 2.0.0 +applicable: SharePoint Online +online version: https://pnp.github.io/powershell/cmdlets/Set-PnPEntraIDAppListItemPermission.html +external help file: PnP.PowerShell.dll-Help.xml +title: Set-PnPEntraIDAppListItemPermission +--- + +# Set-PnPEntraIDAppListItemPermission + +## SYNOPSIS + +**Required Permissions** + + * Microsoft Graph API: Sites.ReadWrite.All + +Updates permissions for a given Entra ID application registration on a list item. + +## SYNTAX + +```powershell +Set-PnPEntraIDAppListItemPermission -PermissionId -Permissions -List -ListItem [-Site ] [-Connection ] +``` + +## DESCRIPTION + +This cmdlet updates an existing permission for an Entra ID application registration on a list item. It is used in conjunction with the Entra ID SharePoint application permission `ListItems.SelectedOperations.Selected`. + +Use [Get-PnPEntraIDAppListItemPermission](Get-PnPEntraIDAppListItemPermission.md) to retrieve the `PermissionId` required by this cmdlet. + +The `-ListItem` parameter accepts the integer item ID. Use `Get-PnPListItem` to look up the ID if needed. + +## EXAMPLES + +### EXAMPLE 1 +```powershell +Set-PnPEntraIDAppListItemPermission -PermissionId aTowaS50fG1zLnNwLmV4dHxlMzhjZmIzMS00 -Permissions Read -List "Documents" -ListItem 5 +``` + +Updates the permission to Read access on list item 5 in the Documents library of the currently connected site. + +### EXAMPLE 2 +```powershell +Set-PnPEntraIDAppListItemPermission -PermissionId aTowaS50fG1zLnNwLmV4dHxlMzhjZmIzMS00 -Permissions Write -List "Documents" -ListItem 5 -Site https://contoso.sharepoint.com/sites/projects +``` + +Updates the permission to Write access on list item 5 in 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 containing the item. 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 +``` + +### -ListItem +The integer ID of the list item whose permission should be updated. Use `Get-PnPListItem` to look up the ID if needed. + +```yaml +Type: Int32 +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-PnPEntraIDAppListItemPermission](Get-PnPEntraIDAppListItemPermission.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/GetEntraIDAppListItemPermission.cs b/src/Commands/Apps/GetEntraIDAppListItemPermission.cs new file mode 100644 index 000000000..c6d750758 --- /dev/null +++ b/src/Commands/Apps/GetEntraIDAppListItemPermission.cs @@ -0,0 +1,190 @@ +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, "PnPEntraIDAppListItemPermission", DefaultParameterSetName = ParameterSet_ALL)] + [RequiredApiDelegatedOrApplicationPermissions("graph/Sites.FullControl.All")] + [OutputType(typeof(AzureADAppPermission))] + public class GetPnPEntraIDAppListItemPermission : 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; + + [Parameter(Mandatory = true, ParameterSetName = ParameterSet_ALL)] + [Parameter(Mandatory = true, ParameterSetName = ParameterSet_PERMISSIONID)] + [Parameter(Mandatory = true, ParameterSetName = ParameterSet_APPIDENTITY)] + [ValidateNotNullOrEmpty] + public string List; + + [Parameter(Mandatory = true, ParameterSetName = ParameterSet_ALL)] + [Parameter(Mandatory = true, ParameterSetName = ParameterSet_PERMISSIONID)] + [Parameter(Mandatory = true, ParameterSetName = ParameterSet_APPIDENTITY)] + public int ListItem; + + [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))) + { + var cleanPermissionId = Uri.EscapeDataString(PermissionId.Trim().Replace(" ", "").Replace("\t", "").Replace("\r", "").Replace("\n", "")); + var result = GraphRequestHelper.Get($"beta/sites/{siteId}/lists/{listId}/items/{ListItem}/permissions/{cleanPermissionId}"); + if (result != null) + { + var converted = result.Convert(); + EnrichWithDisplayNames(converted); + WriteObject(converted); + } + } + else + { + var permissions = GraphRequestHelper.GetResultCollection($"beta/sites/{siteId}/lists/{listId}/items/{ListItem}/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}/items/{ListItem}/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); + } + } + } + } + + /// + /// 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 GET 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 = 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) + { + 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 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 valueEl)) + { + foreach (var item in valueEl.EnumerateArray()) + { + if (item.TryGetProperty("displayName", out JsonElement nameEl) && + nameEl.GetString().Equals(listIdentifier, StringComparison.OrdinalIgnoreCase) && + item.TryGetProperty("id", out JsonElement idEl)) + { + return Guid.Parse(idEl.GetString()); + } + } + } + + return Guid.Empty; + } + } +} diff --git a/src/Commands/Apps/GrantEntraIDAppListItemPermission.cs b/src/Commands/Apps/GrantEntraIDAppListItemPermission.cs new file mode 100644 index 000000000..5adb038cc --- /dev/null +++ b/src/Commands/Apps/GrantEntraIDAppListItemPermission.cs @@ -0,0 +1,130 @@ +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, "PnPEntraIDAppListItemPermission")] + [RequiredApiDelegatedPermissions("graph/Sites.FullControl.All")] + [OutputType(typeof(AzureADAppPermission))] + public class GrantPnPEntraIDAppListItemPermission : PnPGraphCmdlet + { + [Parameter(Mandatory = true)] + [ValidateNotNullOrEmpty] + public Guid AppId; + + [Parameter(Mandatory = true)] + [ValidateNotNullOrEmpty] + public string DisplayName; + + [Parameter(Mandatory = true)] + [ValidateNotNullOrEmpty] + public string List; + + [Parameter(Mandatory = true)] + public int ListItem; + + [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 item {ListItem} in list {listId}"); + + var result = Utilities.REST.RestHelper.Post( + Connection.HttpClient, + $"https://{Connection.GraphEndPoint}/beta/sites/{siteId}/lists/{listId}/items/{ListItem}/permissions", + AccessToken, + payload); + + WriteObject(result?.Convert()); + } + + 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 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 valueEl)) + { + foreach (var item in valueEl.EnumerateArray()) + { + if (item.TryGetProperty("displayName", out JsonElement nameEl) && + nameEl.GetString().Equals(listIdentifier, StringComparison.OrdinalIgnoreCase) && + item.TryGetProperty("id", out JsonElement idEl)) + { + return Guid.Parse(idEl.GetString()); + } + } + } + + return Guid.Empty; + } + } +} diff --git a/src/Commands/Apps/RevokeEntraIDAppListItemPermission.cs b/src/Commands/Apps/RevokeEntraIDAppListItemPermission.cs new file mode 100644 index 000000000..b3d63e08f --- /dev/null +++ b/src/Commands/Apps/RevokeEntraIDAppListItemPermission.cs @@ -0,0 +1,100 @@ +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, "PnPEntraIDAppListItemPermission")] + [RequiredApiDelegatedOrApplicationPermissions("graph/Sites.FullControl.All")] + public class RevokePnPEntraIDAppListItemPermission : PnPGraphCmdlet + { + [Parameter(Mandatory = true)] + [ValidateNotNullOrEmpty] + public string PermissionId; + + [Parameter(Mandatory = true)] + [ValidateNotNullOrEmpty] + public string List; + + [Parameter(Mandatory = true)] + public int ListItem; + + [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 item permission?", string.Empty)) + { + var cleanPermissionId = Uri.EscapeDataString(PermissionId.Trim().Replace(" ", "").Replace("\t", "").Replace("\r", "").Replace("\n", "")); + LogDebug($"Revoking permission {cleanPermissionId} from item {ListItem} in list {listId} on site {siteId}"); + Utilities.REST.RestHelper.Delete( + Connection.HttpClient, + $"https://{Connection.GraphEndPoint}/beta/sites/{siteId}/lists/{listId}/items/{ListItem}/permissions/{cleanPermissionId}", + AccessToken); + } + } + + 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 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 valueEl)) + { + foreach (var item in valueEl.EnumerateArray()) + { + if (item.TryGetProperty("displayName", out JsonElement nameEl) && + nameEl.GetString().Equals(listIdentifier, StringComparison.OrdinalIgnoreCase) && + item.TryGetProperty("id", out JsonElement idEl)) + { + return Guid.Parse(idEl.GetString()); + } + } + } + + return Guid.Empty; + } + } +} diff --git a/src/Commands/Apps/SetEntraIDAppListItemPermission.cs b/src/Commands/Apps/SetEntraIDAppListItemPermission.cs new file mode 100644 index 000000000..50c93a426 --- /dev/null +++ b/src/Commands/Apps/SetEntraIDAppListItemPermission.cs @@ -0,0 +1,154 @@ +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, "PnPEntraIDAppListItemPermission")] + [RequiredApiDelegatedOrApplicationPermissions("graph/Sites.FullControl.All")] + [OutputType(typeof(AzureADAppPermission))] + public class SetPnPEntraIDAppListItemPermission : PnPGraphCmdlet + { + [Parameter(Mandatory = true)] + [ValidateNotNullOrEmpty] + public string PermissionId; + + [Parameter(Mandatory = true)] + [ValidateNotNullOrEmpty] + public string List; + + [Parameter(Mandatory = true)] + public int ListItem; + + [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() + }; + + var cleanPermissionId = Uri.EscapeDataString(PermissionId.Trim().Replace(" ", "").Replace("\t", "").Replace("\r", "").Replace("\n", "")); + LogDebug($"Updating permission {cleanPermissionId} on item {ListItem} in list {listId} to {string.Join(", ", payload.roles)}"); + + var result = Utilities.REST.RestHelper.Patch( + Connection.HttpClient, + $"https://{Connection.GraphEndPoint}/beta/sites/{siteId}/lists/{listId}/items/{ListItem}/permissions/{cleanPermissionId}", + AccessToken, + payload); + + if (result != null) + { + var converted = result.Convert(); + EnrichWithDisplayNames(converted); + WriteObject(converted); + } + } + + 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 = 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) + { + LogDebug($"Could not resolve display name for app {app.Id}: {ex.Message}"); + } + } + } + + 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 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 valueEl)) + { + foreach (var item in valueEl.EnumerateArray()) + { + if (item.TryGetProperty("displayName", out JsonElement nameEl) && + nameEl.GetString().Equals(listIdentifier, StringComparison.OrdinalIgnoreCase) && + 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..ec09e18a6 --- /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/list-item permission responses. + /// These APIs 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/list-item 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; } + } +}