diff --git a/src/lib/PnP.Framework/Enums/BuiltInFieldId.cs b/src/lib/PnP.Framework/Enums/BuiltInFieldId.cs index afc70f109..04ee8d039 100644 --- a/src/lib/PnP.Framework/Enums/BuiltInFieldId.cs +++ b/src/lib/PnP.Framework/Enums/BuiltInFieldId.cs @@ -1055,6 +1055,7 @@ public static class BuiltInFieldId public static readonly Guid ResolvedBy = new Guid("{b4fa187b-eb65-478e-8bc6-93b0da320f03}"); public static readonly Guid ResolvedDate = new Guid("{c4995c71-4c5c-4e9f-afc1-a9033f2bfde5}"); public static readonly Guid Description = new Guid("{3f155110-a6a2-4d70-926c-94648101f0e8}"); + public static readonly Guid _ExtendedDescription = new Guid("{cb19284a-cde7-4570-a980-1dab8bd74470}"); public static readonly Guid HolidayDate = new Guid("{335e22c3-b8a4-4234-9790-7a03eeb7b0d4}"); public static readonly Guid V4HolidayDate = new Guid("{492b1ac0-c594-4013-a2b6-ea70f5a8a506}"); public static readonly Guid IsNonWorkingDay = new Guid("{baf7091c-01fb-4831-a975-08254f87f234}"); @@ -1292,6 +1293,7 @@ public static bool Contains(Guid fid) [End] = true, [EncodedAbsThumbnailUrl] = true, [Description] = true, + [_ExtendedDescription] = true, [DisplayTemplateJSTargetContentType] = true, [V4HolidayDate] = true, [EmailSubject] = true, diff --git a/src/lib/PnP.Framework/Extensions/BrandingExtensions.cs b/src/lib/PnP.Framework/Extensions/BrandingExtensions.cs index b1490d114..fe0d28341 100644 --- a/src/lib/PnP.Framework/Extensions/BrandingExtensions.cs +++ b/src/lib/PnP.Framework/Extensions/BrandingExtensions.cs @@ -273,15 +273,20 @@ public static void SetComposedLookByUrl(this Web web, string lookName, string pa /// URL of background image to apply /// false (default) to apply to currently inheriting subsites only; true to force all subsites to inherit /// false (default) to apply to subsites; true to only apply to specified site + /// When provided, skips the sentinel write probe. + /// True means property bag writes are allowed; false means blocked. Null (default) auto-detects. [SuppressMessage("Microsoft.Globalization", "CA1303:Do not pass literals as localized parameters", MessageId = "OfficeDevPnP.Core.Diagnostics.Log.Debug(System.String,System.String,System.Object[])")] - public static void SetThemeByUrl(this Web web, string paletteServerRelativeUrl, string fontServerRelativeUrl, string backgroundServerRelativeUrl, bool resetSubsitesToInherit = false, bool updateRootOnly = false) + public static void SetThemeByUrl(this Web web, string paletteServerRelativeUrl, string fontServerRelativeUrl, string backgroundServerRelativeUrl, bool resetSubsitesToInherit = false, bool updateRootOnly = false, bool? propertyBagWriteAllowed = null) { var websToUpdate = new List(); web.Context.Load(web, w => w.AllProperties, w => w.ServerRelativeUrl); web.Context.ExecuteQueryRetry(); Log.Info(Constants.LOGGING_SOURCE, CoreResources.BrandingExtension_ApplyTheme, paletteServerRelativeUrl, web.ServerRelativeUrl); - if (!web.IsNoScriptSite()) + var blocked = propertyBagWriteAllowed.HasValue + ? !propertyBagWriteAllowed.Value + : web.IsPropertyBagWriteBlocked(); + if (!blocked) { web.AllProperties[InheritTheme] = "False"; web.Update(); @@ -313,7 +318,7 @@ public static void SetThemeByUrl(this Web web, string paletteServerRelativeUrl, if (resetSubsitesToInherit || inheritTheme) { Log.Debug(Constants.LOGGING_SOURCE, "Inherited: " + CoreResources.BrandingExtension_ApplyTheme, paletteServerRelativeUrl, childWeb.ServerRelativeUrl); - if (!web.IsNoScriptSite()) + if (!blocked) { childWeb.AllProperties[InheritTheme] = "True"; //childWeb.ThemedCssFolderUrl = themedCssFolderUrl; diff --git a/src/lib/PnP.Framework/Extensions/WebExtensions.cs b/src/lib/PnP.Framework/Extensions/WebExtensions.cs index 47c512a88..5e47e52e8 100644 --- a/src/lib/PnP.Framework/Extensions/WebExtensions.cs +++ b/src/lib/PnP.Framework/Extensions/WebExtensions.cs @@ -352,6 +352,62 @@ public static bool IsNoScriptSite(this Web web) return false; } + /// + /// Returns true when web property bag writes should be blocked. + /// + /// On NoScript sites (DenyAddAndCustomizePages = Enabled), checks whether the + /// tenant-level override AllowWebPropertyBagUpdateWhenDenyAddAndCustomizePagesIsEnabled + /// permits property bag writes by probing the API with a sentinel write. + /// Does not require tenant-admin permissions. + /// + /// Web to verify + /// True if property bag writes are blocked, false otherwise + public static bool IsPropertyBagWriteBlocked(this Web web) + { + // Fast path: if the site is not NoScript, property bag ops are always allowed. + if (!web.IsNoScriptSite()) + { + return false; + } + + // Probe: attempt a sentinel write to detect tenant override. + string sentinelKey = "_PnP_PropertyBagProbe_" + Guid.NewGuid().ToString("N"); + try + { + var props = web.AllProperties; + web.Context.Load(props); + web.Context.ExecuteQueryRetry(); + + props[sentinelKey] = "probe"; + web.Update(); + web.Context.ExecuteQueryRetry(); + + // Write succeeded - tenant allows property bag updates. + + // Clean up sentinel (both steps required — CSOM quirk, see RemovePropertyBagValueInternal). + props[sentinelKey] = null; + props.FieldValues.Remove(sentinelKey); + web.Update(); + web.Context.ExecuteQueryRetry(); + + return false; + } + catch (ServerException ex) when ( + ex.ServerErrorCode == -2147024891 // E_ACCESSDENIED + || ex.ServerErrorCode == -1 + || (ex.ServerErrorTypeName != null && ex.ServerErrorTypeName.Contains("UnauthorizedAccessException"))) + { + // Write genuinely blocked. + return true; + } + catch + { + // Any other error (connectivity, etc.) - assume blocked to preserve + // the pre-existing safe-by-default behaviour. + return true; + } + } + private static bool IsCannotGetSiteException(Exception ex) { if (ex is ServerException) @@ -1000,9 +1056,14 @@ public static bool RemoveIndexedPropertyBagKey(this Web web, string key) /// Queues a web for a full crawl the next incremental/continous crawl /// /// Site to be processed - public static void ReIndexWeb(this Web web) + /// When provided, skips the sentinel write probe. + /// True means property bag writes are allowed; false means blocked. Null (default) auto-detects. + public static void ReIndexWeb(this Web web, bool? propertyBagWriteAllowed = null) { - if (web.IsNoScriptSite()) + var blocked = propertyBagWriteAllowed.HasValue + ? !propertyBagWriteAllowed.Value + : web.IsPropertyBagWriteBlocked(); + if (blocked) { // Update individual lists instead, as web bag is no (longer) accessible var context = web.Context; diff --git a/src/lib/PnP.Framework/Provisioning/ObjectHandlers/Extensions/CustomActionExtensions.cs b/src/lib/PnP.Framework/Provisioning/ObjectHandlers/Extensions/CustomActionExtensions.cs new file mode 100644 index 000000000..ab074de9f --- /dev/null +++ b/src/lib/PnP.Framework/Provisioning/ObjectHandlers/Extensions/CustomActionExtensions.cs @@ -0,0 +1,17 @@ +using PnP.Framework.Provisioning.Model; +using System; + +namespace PnP.Framework.Provisioning.ObjectHandlers.Extensions +{ + internal static class CustomActionExtensions + { + /// + /// Returns true when the given custom action is an SPFx extension. + /// Every SPFx extension has a non-empty ClientSideComponentId. + /// + internal static bool IsSPFxCustomAction(this CustomAction customAction) + { + return customAction.ClientSideComponentId != Guid.Empty; + } + } +} diff --git a/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectCustomActions.cs b/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectCustomActions.cs index 82e9a6cb6..adc93f81b 100644 --- a/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectCustomActions.cs +++ b/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectCustomActions.cs @@ -162,7 +162,7 @@ internal static void UpdateCustomAction(TokenParser parser, PnPMonitoredScope sc { var isDirty = false; - if (isNoScriptSite) + if (isNoScriptSite && !customAction.IsSPFxCustomAction()) { scope.LogWarning(CoreResources.Provisioning_ObjectHandlers_CustomActions_SkippingAddUpdateDueToNoScript, customAction.Name); return; diff --git a/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectListInstance.cs b/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectListInstance.cs index 315259146..4d42cb82f 100644 --- a/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectListInstance.cs +++ b/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectListInstance.cs @@ -1551,52 +1551,52 @@ private static bool UpdateCustomActions(Web web, List existingList, ListInstance { bool isDirty = false; - if (!isNoScriptSite) + // Add any UserCustomActions + var existingUserCustomActions = existingList.UserCustomActions; + web.Context.Load(existingUserCustomActions); + web.Context.ExecuteQueryRetry(); + + foreach (CustomAction userCustomAction in templateList.UserCustomActions) { - // Add any UserCustomActions - var existingUserCustomActions = existingList.UserCustomActions; - web.Context.Load(existingUserCustomActions); - web.Context.ExecuteQueryRetry(); + // Per-action NoScript guard: only block non-SPFx actions + if (isNoScriptSite && !userCustomAction.IsSPFxCustomAction()) + { + scope.LogWarning(CoreResources.Provisioning_ObjectHandlers_CustomActions_SkippingAddUpdateDueToNoScript, userCustomAction.Name); + continue; + } - foreach (CustomAction userCustomAction in templateList.UserCustomActions) + // Check for existing custom actions before adding (compare by custom action name) + if (!existingUserCustomActions.AsEnumerable().Any(uca => uca.Name == userCustomAction.Name)) { - // Check for existing custom actions before adding (compare by custom action name) - if (!existingUserCustomActions.AsEnumerable().Any(uca => uca.Name == userCustomAction.Name)) - { - CreateListCustomAction(existingList, parser, userCustomAction); - isDirty = true; - } - else + CreateListCustomAction(existingList, parser, userCustomAction); + isDirty = true; + } + else + { + var existingCustomAction = existingUserCustomActions.AsEnumerable().FirstOrDefault(uca => uca.Name == userCustomAction.Name); + if (existingCustomAction != null) { - var existingCustomAction = existingUserCustomActions.AsEnumerable().FirstOrDefault(uca => uca.Name == userCustomAction.Name); - if (existingCustomAction != null) + // If the custom action already exists + if (userCustomAction.Remove) { - // If the custom action already exists - if (userCustomAction.Remove) - { - // And if we need to remove it, we simply delete it - existingCustomAction.DeleteObject(); - } - else - { - // Otherwise we update it, and before we force the target - // registration type and ID to avoid issues - userCustomAction.RegistrationType = UserCustomActionRegistrationType.List; - userCustomAction.RegistrationId = existingList.Id.ToString("B").ToUpper(); - ObjectCustomActions.UpdateCustomAction(parser, scope, userCustomAction, existingCustomAction); - // Blank out these values again to avoid inconsistent domain model data - userCustomAction.RegistrationType = UserCustomActionRegistrationType.None; - userCustomAction.RegistrationId = null; - } - isDirty = true; + // And if we need to remove it, we simply delete it + existingCustomAction.DeleteObject(); + } + else + { + // Otherwise we update it, and before we force the target + // registration type and ID to avoid issues + userCustomAction.RegistrationType = UserCustomActionRegistrationType.List; + userCustomAction.RegistrationId = existingList.Id.ToString("B").ToUpper(); + ObjectCustomActions.UpdateCustomAction(parser, scope, userCustomAction, existingCustomAction, isNoScriptSite); + // Blank out these values again to avoid inconsistent domain model data + userCustomAction.RegistrationType = UserCustomActionRegistrationType.None; + userCustomAction.RegistrationId = null; } + isDirty = true; } } } - else - { - scope.LogWarning(CoreResources.Provisioning_ObjectHandlers_ListInstances_SkipAddingOrUpdatingCustomActions); - } return isDirty; } @@ -2019,19 +2019,18 @@ private Tuple CreateList(Web web, ListInstance templateList, // Add any custom action if (templateList.UserCustomActions.Any()) { - if (!isNoScriptSite) + foreach (var userCustomAction in templateList.UserCustomActions) { - foreach (var userCustomAction in templateList.UserCustomActions) + // Per-action NoScript guard: only block non-SPFx actions + if (isNoScriptSite && !userCustomAction.IsSPFxCustomAction()) { - CreateListCustomAction(createdList, parser, userCustomAction); + scope.LogWarning(CoreResources.Provisioning_ObjectHandlers_CustomActions_SkippingAddUpdateDueToNoScript, userCustomAction.Name); + continue; } - - web.Context.ExecuteQueryRetry(); - } - else - { - scope.LogWarning(CoreResources.Provisioning_ObjectHandlers_ListInstances_SkipAddingOrUpdatingCustomActions); + CreateListCustomAction(createdList, parser, userCustomAction); } + + web.Context.ExecuteQueryRetry(); } // Process list webhooks diff --git a/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectNavigation.cs b/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectNavigation.cs index 2b48550db..74b5ea6c3 100644 --- a/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectNavigation.cs +++ b/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectNavigation.cs @@ -142,8 +142,8 @@ public override TokenParser ProvisionObjects(Web web, ProvisioningTemplate templ return parser; } - // Check if this is not a noscript site as navigation features are not supported - bool isNoScriptSite = web.IsNoScriptSite(); + // Check if property bag writes are blocked (NoScript without tenant override) + bool propertyBagWriteBlocked = applyingInformation.PropertyBagWriteAllowed == false; // Retrieve the current web navigation settings var navigationSettings = new WebNavigationSettings(web.Context, web); @@ -158,7 +158,7 @@ public override TokenParser ProvisionObjects(Web web, ProvisioningTemplate templ web.Update(); web.Context.ExecuteQueryRetry(); - if (!isNoScriptSite) + if (!propertyBagWriteBlocked) { navigationSettings.Update(TaxonomySession.GetTaxonomySession(web.Context)); web.Context.ExecuteQueryRetry(); @@ -194,7 +194,7 @@ public override TokenParser ProvisionObjects(Web web, ProvisioningTemplate templ break; } - if (!isNoScriptSite) + if (!propertyBagWriteBlocked) { navigationSettings.Update(TaxonomySession.GetTaxonomySession(web.Context)); web.Context.ExecuteQueryRetry(); @@ -225,7 +225,7 @@ public override TokenParser ProvisionObjects(Web web, ProvisioningTemplate templ navigationSettings.CurrentNavigation.TermSetId = Guid.Parse(parser.ParseString(template.Navigation.CurrentNavigation.ManagedNavigation.TermSetId)); break; case CurrentNavigationType.StructuralLocal: - if (!isNoScriptSite) + if (!propertyBagWriteBlocked) { web.SetPropertyBagValue(NavigationShowSiblings, "false"); } @@ -238,7 +238,7 @@ public override TokenParser ProvisionObjects(Web web, ProvisioningTemplate templ break; case CurrentNavigationType.Structural: default: - if (!isNoScriptSite) + if (!propertyBagWriteBlocked) { web.SetPropertyBagValue(NavigationShowSiblings, "true"); } @@ -251,7 +251,7 @@ public override TokenParser ProvisionObjects(Web web, ProvisioningTemplate templ break; } - if (!isNoScriptSite) + if (!propertyBagWriteBlocked) { navigationSettings.Update(TaxonomySession.GetTaxonomySession(web.Context)); web.Context.ExecuteQueryRetry(); diff --git a/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectPersistTemplateInfo.cs b/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectPersistTemplateInfo.cs index 9eca0326d..38510789b 100644 --- a/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectPersistTemplateInfo.cs +++ b/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectPersistTemplateInfo.cs @@ -24,11 +24,9 @@ public override TokenParser ProvisionObjects(Web web, ProvisioningTemplate templ { using (var scope = new PnPMonitoredScope(this.Name)) { - // Check if this is not a noscript site as we're not allowed to write to the web property bag is that one - bool isNoScriptSite = web.IsNoScriptSite(); - if (isNoScriptSite) + // Check if property bag writes are blocked (NoScript without tenant override) + if (applyingInformation.PropertyBagWriteAllowed == false) { - return parser; } @@ -60,7 +58,7 @@ public override bool WillProvision(Web web, ProvisioningTemplate template, Provi { if (!_willProvision.HasValue) { - _willProvision = !web.IsNoScriptSite(); + _willProvision = applyingInformation.PropertyBagWriteAllowed != false; } return _willProvision.Value; } diff --git a/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectPropertyBagEntry.cs b/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectPropertyBagEntry.cs index d5fec7eb1..4a2f226b4 100644 --- a/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectPropertyBagEntry.cs +++ b/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectPropertyBagEntry.cs @@ -30,9 +30,8 @@ public override TokenParser ProvisionObjects(Web web, ProvisioningTemplate templ "DesignPreview" }); - // Check if this is not a noscript site as we're not allowed to write to the web property bag is that one - bool isNoScriptSite = web.IsNoScriptSite(); - if (isNoScriptSite) + // Check if property bag writes are blocked (NoScript without tenant override) + if (applyingInformation.PropertyBagWriteAllowed == false) { return parser; } @@ -187,7 +186,7 @@ public override bool WillProvision(Web web, ProvisioningTemplate template, Provi { if (!_willProvision.HasValue) { - _willProvision = template.PropertyBagEntries.Any() && !web.IsNoScriptSite(); + _willProvision = template.PropertyBagEntries.Any() && (applyingInformation.PropertyBagWriteAllowed != false); } return _willProvision.Value; diff --git a/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectWebSettings.cs b/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectWebSettings.cs index 8950f3299..ecdf85491 100644 --- a/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectWebSettings.cs +++ b/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ObjectWebSettings.cs @@ -343,7 +343,7 @@ public override TokenParser ProvisionObjects(Web web, ProvisioningTemplate templ } } - if (!isNoScriptSite) + if (applyingInformation.PropertyBagWriteAllowed != false) { web.NoCrawl = webSettings.NoCrawl; } diff --git a/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ProvisioningTemplateApplyingInformation.cs b/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ProvisioningTemplateApplyingInformation.cs index e42da5388..434080fac 100644 --- a/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ProvisioningTemplateApplyingInformation.cs +++ b/src/lib/PnP.Framework/Provisioning/ObjectHandlers/ProvisioningTemplateApplyingInformation.cs @@ -129,5 +129,13 @@ public Dictionary AccessTokens /// Defines a delay to wait for after modern site creation /// public Int32 DelayAfterModernSiteCreation { get; set; } + + /// + /// Controls whether property bag writes are allowed on NoScript sites. + /// When null (default), the provisioning engine auto-detects via a sentinel write probe. + /// Set to true if the tenant has AllowWebPropertyBagUpdateWhenDenyAddAndCustomizePagesIsEnabled = true. + /// Set to false to skip all property bag write operations without probing. + /// + public bool? PropertyBagWriteAllowed { get; set; } } } diff --git a/src/lib/PnP.Framework/Provisioning/ObjectHandlers/SiteToTemplateConversion.cs b/src/lib/PnP.Framework/Provisioning/ObjectHandlers/SiteToTemplateConversion.cs index 85598cd4d..049e5cf60 100644 --- a/src/lib/PnP.Framework/Provisioning/ObjectHandlers/SiteToTemplateConversion.cs +++ b/src/lib/PnP.Framework/Provisioning/ObjectHandlers/SiteToTemplateConversion.cs @@ -460,6 +460,11 @@ internal void ApplyRemoteTemplate(Web web, ProvisioningTemplate template, Provis CallWebHooks(template, tokenParser, ProvisioningTemplateWebhookKind.ProvisioningTemplateStarted); + if (!provisioningInfo.PropertyBagWriteAllowed.HasValue) + { + provisioningInfo.PropertyBagWriteAllowed = !web.IsPropertyBagWriteBlocked(); + } + foreach (var handler in objectHandlers) { if (handler.WillProvision(web, template, provisioningInfo))