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; } + } +}