Skip to content
Open
2 changes: 2 additions & 0 deletions src/lib/PnP.Framework/Enums/BuiltInFieldId.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand Down Expand Up @@ -1292,6 +1293,7 @@ public static bool Contains(Guid fid)
[End] = true,
[EncodedAbsThumbnailUrl] = true,
[Description] = true,
[_ExtendedDescription] = true,
[DisplayTemplateJSTargetContentType] = true,
[V4HolidayDate] = true,
[EmailSubject] = true,
Expand Down
11 changes: 8 additions & 3 deletions src/lib/PnP.Framework/Extensions/BrandingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -273,15 +273,20 @@ public static void SetComposedLookByUrl(this Web web, string lookName, string pa
/// <param name="backgroundServerRelativeUrl">URL of background image to apply</param>
/// <param name="resetSubsitesToInherit">false (default) to apply to currently inheriting subsites only; true to force all subsites to inherit</param>
/// <param name="updateRootOnly">false (default) to apply to subsites; true to only apply to specified site</param>
/// <param name="propertyBagWriteAllowed">When provided, skips the sentinel write probe.
/// True means property bag writes are allowed; false means blocked. Null (default) auto-detects.</param>
[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>();
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();
Expand Down Expand Up @@ -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;
Expand Down
65 changes: 63 additions & 2 deletions src/lib/PnP.Framework/Extensions/WebExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,62 @@ public static bool IsNoScriptSite(this Web web)
return false;
}

/// <summary>
/// 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.
/// </summary>
/// <param name="web">Web to verify</param>
/// <returns>True if property bag writes are blocked, false otherwise</returns>
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)
Expand Down Expand Up @@ -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
/// </summary>
/// <param name="web">Site to be processed</param>
public static void ReIndexWeb(this Web web)
/// <param name="propertyBagWriteAllowed">When provided, skips the sentinel write probe.
/// True means property bag writes are allowed; false means blocked. Null (default) auto-detects.</param>
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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using PnP.Framework.Provisioning.Model;
using System;

namespace PnP.Framework.Provisioning.ObjectHandlers.Extensions
{
internal static class CustomActionExtensions
{
/// <summary>
/// Returns true when the given custom action is an SPFx extension.
/// Every SPFx extension has a non-empty ClientSideComponentId.
/// </summary>
internal static bool IsSPFxCustomAction(this CustomAction customAction)
{
return customAction.ClientSideComponentId != Guid.Empty;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -2019,19 +2019,18 @@ private Tuple<List, TokenParser> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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");
}
Expand All @@ -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");
}
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading