diff --git a/CHANGELOG.md b/CHANGELOG.md index 49874de40..69b76c46c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - Added `Get-PnPFlowOwner` cmdlet which allows retrieving the owners of a Power Automate flow [#3314](https://github.com/pnp/powershell/pull/3314) - Added `-AvailableForTagging` to `Set-PnPTerm` which allows the available for tagging property on a Term to be set [#3321](https://github.com/pnp/powershell/pull/3321) - Added `Get-PnPPowerPlatformConnector` cmdlet which allows for all custom connectors to be retrieved [#3309](https://github.com/pnp/powershell/pull/3309) +- Added `Set-PnPSearchExternalItem` cmdlet which allows ingesting external items into the Microsoft Search index for custom connectors. [#3420](https://github.com/pnp/powershell/pull/3420) +- Added `Get-PnPTenantInfo` which allows retrieving tenant information by its Id or domain name [#3414](https://github.com/pnp/powershell/pull/3414) +- Added option to create a Microsft 365 Group with dynamic membership by passing in `-DynamicMembershipRule` [#3426](https://github.com/pnp/powershell/pull/3426) - Added option to pass in a Stream or XML string to `Read-PnPTenantTemplate` allowing the tenant template to be modified before being applied. [#3431](https://github.com/pnp/powershell/pull/3431) - Added `Get-PnPTenantInfo` which allows retrieving tenant information by its Id or domain name. [#3414](https://github.com/pnp/powershell/pull/3414) - Added option to create a Microsoft 365 Group with dynamic membership by passing in `-DynamicMembershipRule` [#3426](https://github.com/pnp/powershell/pull/3426) diff --git a/documentation/Set-PnPSearchExternalItem.md b/documentation/Set-PnPSearchExternalItem.md new file mode 100644 index 000000000..925664d56 --- /dev/null +++ b/documentation/Set-PnPSearchExternalItem.md @@ -0,0 +1,228 @@ +--- +Module Name: PnP.PowerShell +schema: 2.0.0 +applicable: SharePoint Online +online version: https://pnp.github.io/powershell/cmdlets/Set-PnPSearchExternalItem.html +external help file: PnP.PowerShell.dll-Help.xml +title: Set-PnPSearchExternalItem +--- + +# Set-PnPSearchExternalItem + +## SYNOPSIS +Adds or updates an external item in Microsoft Search + +## SYNTAX + +```powershell +Set-PnPSearchExternalItem -ItemId -ConnectionId -Properties [-ContentValue ] [-ContentType ] [-GrantUsers ] [-GrantGroups ] [-DenyUsers ] [-DenyGroups ] [-GrantExternalGroups ] [-DenyExternalGroups ] [-GrantEveryone ] [-Verbose] [-Connection ] +``` + +## DESCRIPTION + +This cmdlet can be used to add or update an external item in Microsoft Search on custom connectors. The cmdlet will create a new external item if the item does not exist yet. If the item already exists, it will be updated. + +## EXAMPLES + +### EXAMPLE 1 +```powershell +Set-PnPSearchExternalItem -ConnectionId "pnppowershell" -ItemId "12345" -Properties @{ "Test1"= "Test van deze PnP PowerShell Connector"; "Test2" = "Red","Blue"; "Test3" = ([System.DateTime]::Now)} -ContentValue "Sample value" -ContentType Text -GrantEveryone +``` + +This will add an item in the external Microsoft Search index with the properties as provided and grants everyone access to find the item back through Microsoft Search. + +### EXAMPLE 2 +```powershell +Set-PnPSearchExternalItem -ConnectionId "pnppowershell" -ItemId "12345" -Properties @{ "Test1"= "Test van deze PnP PowerShell Connector"; "Test2" = "Red","Blue"; "Test3" = ([System.DateTime]::Now)} -ContentValue "Sample value" -ContentType Text -GrantUsers "user@contoso.onmicrosoft.com" +``` + +This will add an item in the external Microsoft Search index with the properties as provided and grants only the user with the specified UPN access to find the item back through Microsoft Search. + +## 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 +``` + +### -ItemId +Unique identifier of the external item in Microsoft Search. You can provide any identifier you want to identity this item. This identifier will be used to update the item if it already exists. + +```yaml +Type: String +Parameter Sets: (All) +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ConnectionId +The Connection ID of the custom connector to use. This is the ID that was entered when registering the custom connector and will indicate for which custom connector this external item is being added to the Microsoft Search index. + +```yaml +Type: String +Parameter Sets: (All) +Required: True +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Properties +A hashtable with all the managed properties you want to provide for this external item. The key of the hashtable is the name of the managed property, the value is the value you want to provide for this managed property. The value can be a string, a string array or a DateTime object. + +```yaml +Type: Hashtable +Parameter Sets: (All) +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ContentValue +A summary of the content that is being indexed. Can be used to display in the search result. + +```yaml +Type: String +Parameter Sets: (All) +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ContentType +Defines the type of content used in the ContentValue attribue. Defaults to Text. + +```yaml +Type: SearchExternalItemContentType +Parameter Sets: (All) +Accepted values: Text, Html +Required: False +Position: Named +Default value: Text +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -GrantUsers +When provided, the external item will only be shown to the users provided through this parameter. It can contain one or multiple users by providing AzureADUser objects, user principal names or Entra user IDs. + +```yaml +Type: AzureADUserPipeBind[] +Parameter Sets: (All) +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -GrantGroups +When provided, the external item will only be shown to the users which are members of the groups provided through this parameter. It can contain one or multiple groups by providing AzureADGroup objects, group names or Entra group IDs. + +```yaml +Type: AzureADGroupPipeBind[] +Parameter Sets: (All) +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -DenyUsers +When provided, the external item not be shown to the users provided through this parameter. It can contain one or multiple users by providing AzureADUser objects, user principal names or Entra user IDs. + +```yaml +Type: AzureADUserPipeBind[] +Parameter Sets: (All) +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -DenyGroups +When provided, the external item will not be shown to the users which are members of the groups provided through this parameter. It can contain one or multiple groups by providing AzureADGroup objects, group names or Entra group IDs. + +```yaml +Type: AzureADGroupPipeBind[] +Parameter Sets: (All) +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -GrantExternalGroups +When provided, the external item will be shown to the groups provided through this parameter. It can contain one or multiple users by providing the external group identifiers. + +```yaml +Type: String[] +Parameter Sets: (All) +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -DenyExternalGroups +When provided, the external item will not be shown to the groups provided through this parameter. It can contain one or multiple users by providing the external group identifiers. + +```yaml +Type: String[] +Parameter Sets: (All) +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -GrantEveryone +When provided, the external item will be shown to everyone. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Verbose +When provided, additional debug statements will be shown while executing the cmdlet. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +## RELATED LINKS + +[Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) \ No newline at end of file diff --git a/src/Commands/Enums/SearchExternalItemAclAccessType.cs b/src/Commands/Enums/SearchExternalItemAclAccessType.cs new file mode 100644 index 000000000..b5d0b15eb --- /dev/null +++ b/src/Commands/Enums/SearchExternalItemAclAccessType.cs @@ -0,0 +1,19 @@ +namespace PnP.PowerShell.Commands.Enums +{ + /// + /// Contains the possible ACL access types for external items to define if the ACL instructs a grant or deny + /// + /// + public enum SearchExternalItemAclAccessType : short + { + /// + /// Grants access + /// + Grant = 0, + + /// + /// Denies access + /// + Deny = 1 + } +} diff --git a/src/Commands/Enums/SearchExternalItemAclType.cs b/src/Commands/Enums/SearchExternalItemAclType.cs new file mode 100644 index 000000000..f7ffbc54a --- /dev/null +++ b/src/Commands/Enums/SearchExternalItemAclType.cs @@ -0,0 +1,34 @@ +namespace PnP.PowerShell.Commands.Enums +{ + /// + /// Contains the possible types of ACLs that can be set on external items to define to what type of object the access will be provided + /// + /// + public enum SearchExternalItemAclType : short + { + /// + /// Access will be provided to a user + /// + User = 0, + + /// + /// Access will be provided to a group + /// + Group = 1, + + /// + /// Access will be provided to everyone + /// + Everyone = 2, + + /// + /// Access will be provided to everyone except external users + /// + EveryoneExceptExternalUsers = 3, + + /// + /// Access will be provided to an external group + /// + ExternalGroup = 4 + } +} diff --git a/src/Commands/Enums/SearchExternalItemContentType.cs b/src/Commands/Enums/SearchExternalItemContentType.cs new file mode 100644 index 000000000..0ada930d3 --- /dev/null +++ b/src/Commands/Enums/SearchExternalItemContentType.cs @@ -0,0 +1,19 @@ +namespace PnP.PowerShell.Commands.Enums +{ + /// + /// Contains the possible content types for the search external item to define the type of content it contains + /// + /// + public enum SearchExternalItemContentType : short + { + /// + /// The content is HTML + /// + Html = 0, + + /// + /// The content is plain text + /// + Text = 1 + } +} diff --git a/src/Commands/JsonConverters/MicrosoftSearchExternalItemPropertyConverter.cs b/src/Commands/JsonConverters/MicrosoftSearchExternalItemPropertyConverter.cs new file mode 100644 index 000000000..32bbdb91e --- /dev/null +++ b/src/Commands/JsonConverters/MicrosoftSearchExternalItemPropertyConverter.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PnP.PowerShell.Commands.JsonConverters; + +/// +/// Custom JSON converter to convert a Hashtable to the format expected by the Microsoft Search API +/// +/// +internal sealed class MicrosoftSearchExternalItemPropertyConverter : JsonConverter +{ + public override Hashtable Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var converter = (JsonConverter)options.GetConverter(typeof(Hashtable)); + return converter.Read(ref reader, typeToConvert, options); + } + + public override void Write(Utf8JsonWriter writer, Hashtable hashtable, JsonSerializerOptions options) + { + writer.WriteStartObject(); + foreach(DictionaryEntry value in hashtable) + { + switch(value.Value) + { + case string: + writer.WritePropertyName($"{value.Key}@odata.type"); + writer.WriteStringValue("String"); + + writer.WritePropertyName(value.Key.ToString()); + writer.WriteStringValue(value.Value.ToString()); + break; + + case DateTime dateTime: + writer.WritePropertyName($"{value.Key}@odata.type"); + writer.WriteStringValue("DateTimeOffset"); + + writer.WritePropertyName(value.Key.ToString()); + writer.WriteRawValue($"\"{dateTime:o}\""); + break; + + case IEnumerable ieNumerable: + writer.WritePropertyName($"{value.Key}@odata.type"); + writer.WriteStringValue("Collection(String)"); + + writer.WritePropertyName(value.Key.ToString()); + writer.WriteStartArray(); + foreach(object item in ieNumerable) + { + writer.WriteStringValue(item.ToString()); + } + writer.WriteEndArray(); + break; + + default: + writer.WritePropertyName(value.Key.ToString()); + writer.WriteStringValue(value.Value.ToString()); + break; + } + } + writer.WriteEndObject(); + } +} \ No newline at end of file diff --git a/src/Commands/Model/Graph/MicrosoftSearch/ExternalItem.cs b/src/Commands/Model/Graph/MicrosoftSearch/ExternalItem.cs new file mode 100644 index 000000000..8366643fc --- /dev/null +++ b/src/Commands/Model/Graph/MicrosoftSearch/ExternalItem.cs @@ -0,0 +1,36 @@ +using System.Collections; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace PnP.PowerShell.Commands.Model.Graph.MicrosoftSearch; + +/// +/// Defines an external item used by Microsoft Search +/// +/// + /// The managed properties to provide to Microsoft Search about this external item + /// + [JsonConverter(typeof(JsonConverters.MicrosoftSearchExternalItemPropertyConverter))] + public Hashtable Properties { get; set; } + + /// + /// The ACLs to assign to set permissions on this external item + /// + [JsonPropertyName("acl")] + public List Acls { get; set; } + + /// + /// The content of the external item + /// + [JsonPropertyName("content")] + public ExternalItemContent Content { get; set; } +} \ No newline at end of file diff --git a/src/Commands/Model/Graph/MicrosoftSearch/ExternalItemAcl.cs b/src/Commands/Model/Graph/MicrosoftSearch/ExternalItemAcl.cs new file mode 100644 index 000000000..d9ff927fd --- /dev/null +++ b/src/Commands/Model/Graph/MicrosoftSearch/ExternalItemAcl.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace PnP.PowerShell.Commands.Model.Graph.MicrosoftSearch; + +/// +/// Defines an ACL for an external item used by Microsoft Search +/// +/// +public class ExternalItemAcl +{ + /// + /// The type of the ACL, i.e. User, Group, Everyone, etc. + /// + [JsonConverter(typeof(JsonStringEnumConverter))] + [JsonPropertyName("type")] + public Enums.SearchExternalItemAclType Type { get; set; } + + /// + /// The value of the ACL, i.e. the user or group id + /// + [JsonPropertyName("value")] + public string Value { get; set; } + + /// + /// The access type of the ACL, i.e. Grant or Deny + /// + [JsonConverter(typeof(JsonStringEnumConverter))] + [JsonPropertyName("accessType")] + public Enums.SearchExternalItemAclAccessType AccessType { get; set; } +} \ No newline at end of file diff --git a/src/Commands/Model/Graph/MicrosoftSearch/ExternalItemContent.cs b/src/Commands/Model/Graph/MicrosoftSearch/ExternalItemContent.cs new file mode 100644 index 000000000..2a68d6534 --- /dev/null +++ b/src/Commands/Model/Graph/MicrosoftSearch/ExternalItemContent.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace PnP.PowerShell.Commands.Model.Graph.MicrosoftSearch; + +/// +/// Defines the content of an external item to add to Microsoft Search +/// +/// +public class ExternalItemContent +{ + /// + /// The type of content, Text or Html + /// + [JsonConverter(typeof(JsonStringEnumConverter))] + [JsonPropertyName("type")] + public Enums.SearchExternalItemContentType Type { get; set; } + + /// + /// The content to add to the Microsoft Search index + /// + [JsonPropertyName("value")] + public string Value { get; set; } +} \ No newline at end of file diff --git a/src/Commands/Model/Graph/MicrosoftSearch/ExternalItemProperty.cs b/src/Commands/Model/Graph/MicrosoftSearch/ExternalItemProperty.cs new file mode 100644 index 000000000..bb5fe7380 --- /dev/null +++ b/src/Commands/Model/Graph/MicrosoftSearch/ExternalItemProperty.cs @@ -0,0 +1,10 @@ +namespace PnP.PowerShell.Commands.Model.Graph.MicrosoftSearch; + +/// +/// Defines managed metadata properties to use on the external item which will be in the Microsoft Search index +/// +/// +public class ExternalItemProperty +{ + +} \ No newline at end of file diff --git a/src/Commands/Search/SetSearchExternalItem.cs b/src/Commands/Search/SetSearchExternalItem.cs new file mode 100644 index 000000000..a44e4b004 --- /dev/null +++ b/src/Commands/Search/SetSearchExternalItem.cs @@ -0,0 +1,174 @@ +using System.Collections; +using System.Management.Automation; +using PnP.PowerShell.Commands.Base; +using PnP.PowerShell.Commands.Base.PipeBinds; +using PnP.PowerShell.Commands.Attributes; +using System.Net.Http.Json; +using System.Linq; +using System.Collections.Generic; + +namespace PnP.PowerShell.Commands.Search +{ + [Cmdlet(VerbsCommon.Set, "PnPSearchExternalItem")] + [RequiredMinimalApiPermissions("ExternalItem.ReadWrite.All")] + [OutputType(typeof(Model.Graph.MicrosoftSearch.ExternalItem))] + public class AddSearchExternalItem : PnPGraphCmdlet + { + [Parameter(Mandatory = true)] + public string ConnectionId; + + [Parameter(Mandatory = true)] + [ValidateLength(1,128)] + public string ItemId; + + [Parameter(Mandatory = true)] + public Hashtable Properties; + + #region Content + + [Parameter(Mandatory = false)] + public string ContentValue; + + [Parameter(Mandatory = false)] + public Enums.SearchExternalItemContentType ContentType; + + #endregion + + #region ACL + + [Parameter(Mandatory = false)] + public AzureADUserPipeBind[] GrantUsers; + + [Parameter(Mandatory = false)] + public AzureADGroupPipeBind[] GrantGroups; + + [Parameter(Mandatory = false)] + public AzureADUserPipeBind[] DenyUsers; + + [Parameter(Mandatory = false)] + public AzureADGroupPipeBind[] DenyGroups; + + [Parameter(Mandatory = false)] + public string[] GrantExternalGroups; + + [Parameter(Mandatory = false)] + public string[] DenyExternalGroups; + + [Parameter(Mandatory = false)] + public SwitchParameter GrantEveryone; + + #endregion + + protected override void ExecuteCmdlet() + { + var bodyContent = new Model.Graph.MicrosoftSearch.ExternalItem + { + Id = ItemId, + Acls = new(), + Properties = Properties, + Content = new() { + Type = ContentType, + Value = ContentValue + } + }; + + WriteVerbose($"Adding {(ParameterSpecified(nameof(GrantUsers)) ? GrantUsers.Length : 0)} Grant User ACLs"); + bodyContent.Acls.AddRange(GetUserAcls(GrantUsers, Enums.SearchExternalItemAclAccessType.Grant)); + + WriteVerbose($"Adding {(ParameterSpecified(nameof(DenyUsers)) ? DenyUsers.Length : 0)} Deny User ACLs"); + bodyContent.Acls.AddRange(GetUserAcls(DenyUsers, Enums.SearchExternalItemAclAccessType.Deny)); + + WriteVerbose($"Adding {(ParameterSpecified(nameof(GrantGroups)) ? GrantGroups.Length : 0)} Grant Group ACLs"); + bodyContent.Acls.AddRange(GetGroupAcls(GrantGroups, Enums.SearchExternalItemAclAccessType.Grant)); + + WriteVerbose($"Adding {(ParameterSpecified(nameof(DenyGroups)) ? DenyGroups.Length : 0)} Deny Group ACLs"); + bodyContent.Acls.AddRange(GetGroupAcls(DenyGroups, Enums.SearchExternalItemAclAccessType.Deny)); + + WriteVerbose($"Adding {(ParameterSpecified(nameof(GrantExternalGroups)) ? GrantExternalGroups.Length : 0)} Grant External Group ACLs"); + bodyContent.Acls.AddRange(GetExternalGroupAcls(GrantExternalGroups, Enums.SearchExternalItemAclAccessType.Grant)); + + WriteVerbose($"Adding {(ParameterSpecified(nameof(DenyExternalGroups)) ? DenyExternalGroups.Length : 0)} Deny External Group ACLs"); + bodyContent.Acls.AddRange(GetExternalGroupAcls(DenyExternalGroups, Enums.SearchExternalItemAclAccessType.Deny)); + + if(GrantEveryone.ToBool()) + { + WriteVerbose($"Adding Grant Everyone ACL"); + bodyContent.Acls.Add(new Model.Graph.MicrosoftSearch.ExternalItemAcl + { + Type = Enums.SearchExternalItemAclType.Everyone, + AccessType = Enums.SearchExternalItemAclAccessType.Grant + }); + } + + var jsonContent = JsonContent.Create(bodyContent); + WriteVerbose($"Constructed payload: {jsonContent.ReadAsStringAsync().GetAwaiter().GetResult()}"); + + var graphApiUrl = $"v1.0/external/connections/{ConnectionId}/items/{ItemId}"; + WriteVerbose($"Calling Graph API at {graphApiUrl}"); + + var results = Utilities.REST.GraphHelper.PutAsync(Connection, graphApiUrl, AccessToken, jsonContent).GetAwaiter().GetResult(); + + WriteVerbose($"Graph API responded with HTTP {results.StatusCode} {results.ReasonPhrase}"); + + var resultsContent = results.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + + WriteVerbose($"Graph API responded with payload: {resultsContent}"); + + var externalItemResult = System.Text.Json.JsonSerializer.Deserialize(resultsContent); + + WriteObject(externalItemResult, false); + } + + private List GetUserAcls(AzureADUserPipeBind[] users, Enums.SearchExternalItemAclAccessType accessType) + { + var acls = new List(); + if(users == null) return acls; + + foreach (var user in users) + { + var userAclId = user.UserId ?? user.GetUser(AccessToken)?.Id.Value.ToString(); + + acls.Add(new Model.Graph.MicrosoftSearch.ExternalItemAcl + { + Type = Enums.SearchExternalItemAclType.User, + Value = userAclId, + AccessType = accessType + }); + } + + return acls; + } + + private IEnumerable GetGroupAcls(AzureADGroupPipeBind[] groups, Enums.SearchExternalItemAclAccessType accessType) + { + var acls = new List(); + if(groups == null) return acls; + + foreach (var group in groups) + { + var userAclId = group.GroupId ?? group.GetGroup(Connection, AccessToken)?.Id; + + acls.Add(new Model.Graph.MicrosoftSearch.ExternalItemAcl + { + Type = Enums.SearchExternalItemAclType.Group, + Value = userAclId, + AccessType = accessType + }); + } + + return acls; + } + + private IEnumerable GetExternalGroupAcls(string[] groups, Enums.SearchExternalItemAclAccessType accessType) + { + if (groups == null) return new List(); + + return groups.Select(group => new Model.Graph.MicrosoftSearch.ExternalItemAcl + { + Type = Enums.SearchExternalItemAclType.ExternalGroup, + Value = group, + AccessType = accessType + }).ToArray(); + } + } +} \ No newline at end of file diff --git a/src/Commands/Utilities/REST/GraphHelper.cs b/src/Commands/Utilities/REST/GraphHelper.cs index c001c2ec2..e222bab5b 100644 --- a/src/Commands/Utilities/REST/GraphHelper.cs +++ b/src/Commands/Utilities/REST/GraphHelper.cs @@ -363,7 +363,7 @@ public static async Task GetResponseMessageAsync(PnPConnect { if (ex.Error != null) { - throw new PSInvalidOperationException(ex.Error.Message); + throw new PSInvalidOperationException($"Call to Microsoft Graph URL {message.RequestUri} failed with status code {response.StatusCode}{(!string.IsNullOrEmpty(ex.Error.Message) ? $": {ex.Error.Message}" : "")}"); } } else