Tuesday 8 October 2013

SharePoint 2013 - Bug - Breaking Role Inheritance on Document Library Items within a Synchronous ItemAdded Item Event Receiver will always generate an exception when not a Site Collection Administrator (e.g. a Site Owner) - Fix

This particular issue has caused me a fair amount of grief in the past when developing List Item Event Receivers against Document Libraries (there is no such problem with other List types that don't have Checkouts). I believe it is a bug (or at least a sub-optimal design) in the SaveButton code in SharePoint itself.

It essentially means you cannot change permissions on an item without getting an exception in certain scenarios/use cases like so:



In short, if you (as a non-site collection admin user such as a site owner) perform a BreakRoleInheritance() or any overloads on an item in an ItemAdded Synchronous Receiver against a Document Library in SharePoint 2013, you will always receive the following exception in your SharePoint ULS Logs and the user will see a spurious exception:

 Application error when access /Secured Document Library/Forms/EditForm.aspx, Error=Specified argument was out of the range of valid values. 
  at Microsoft.SharePoint.SPListItemCollection.get_Item(Int32 iIndex)   
  at Microsoft.SharePoint.SPListItem.EnsureItemIsValid()   
  at Microsoft.SharePoint.SPListItem.GetValue(SPField fld, Int32 columnNumber, Boolean bRaw, Boolean bThrowException)   
  at Microsoft.SharePoint.SPListItem.GetValue(String strName, Boolean bThrowIfValueMissing, Boolean bThrowIfFieldMissing)   
  at Microsoft.SharePoint.SPListItem.GetValue(String strName)   
  at Microsoft.SharePoint.SPListItem.get_File()   
  at Microsoft.SharePoint.WebControls.SaveButton.SaveItem(SPContext itemContext, Boolean uploadMode, String checkInComment)   
  at Microsoft.SharePoint.WebControls.SaveButton.SaveItem()   
  at Microsoft.SharePoint.WebControls.SaveButton.OnBubbleEvent(Object source, EventArgs e)   
  at System.Web.UI.Control.RaiseBubbleEvent(Object source, EventArgs args)   

 at System.Web.UI.Page.ProcessRequestMain(Boolean includeStagesBeforeAsyncPoint, Boolean includeStagesAfterAsyncPoint)

  Followed by this:

 System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values.  
  at Microsoft.SharePoint.SPListItemCollection.get_Item(Int32 iIndex)   
  at Microsoft.SharePoint.SPListItem.EnsureItemIsValid()   
  at Microsoft.SharePoint.SPListItem.GetValue(SPField fld, Int32 columnNumber, Boolean bRaw, Boolean bThrowException)   
  at Microsoft.SharePoint.SPListItem.GetValue(String strName, Boolean bThrowIfValueMissing, Boolean bThrowIfFieldMissing)   
  at Microsoft.SharePoint.SPListItem.GetValue(String strName)   
  at Microsoft.SharePoint.SPListItem.get_File()   
  at Microsoft.SharePoint.WebControls.SaveButton.SaveItem(SPContext itemContext, Boolean uploadMode, String checkInComment)   
  at Microsoft.SharePoint.WebControls.SaveButton.SaveItem()   
  at Microsoft.SharePoint.WebControls.SaveButton.OnBubbleEvent(Object source, EventArgs e)   
  at System.Web.UI.Control.RaiseBubbleEvent(Object source, EventArgs args)   
  at System.Web.UI.Page.ProcessRequestMain(Boolean includeStagesBeforeAsyncPoint, Boolean includeStagesAfterAsyncPoint)


Then this equally entertaining Exception:

Getting Error Message for Exception System.Web.HttpUnhandledException (0x80004005): Exception of type 'System.Web.HttpUnhandledException' was thrown. ---> System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values.   
  at Microsoft.SharePoint.SPListItemCollection.get_Item(Int32 iIndex)   
  at Microsoft.SharePoint.SPListItem.EnsureItemIsValid()   
  at Microsoft.SharePoint.SPListItem.GetValue(SPField fld, Int32 columnNumber, Boolean bRaw, Boolean bThrowException)   
  at Microsoft.SharePoint.SPListItem.GetValue(String strName, Boolean bThrowIfValueMissing, Boolean bThrowIfFieldMissing)   
  at Microsoft.SharePoint.SPListItem.GetValue(String strName)   
  at Microsoft.SharePoint.SPListItem.get_File()   
  at Microsoft.SharePoint.WebControls.SaveButton.SaveItem(SPContext itemContext, Boolean uploadMode, String checkInComment)   
  at Microsoft.SharePoint.WebControls.SaveButton.SaveItem()   
  at Microsoft.SharePoint.WebControls.SaveButton.OnBubbleEvent(Object source, EventArgs e)   
  at System.Web.UI.Control.RaiseBubbleEvent(Object source, EventArgs args)   
  at System.Web.UI.Page.ProcessRequestMain(Boolean includeStagesBeforeAsyncPoint, Boolean includeStagesAfterAsyncPoint)   
  at System.Web.UI.Page.HandleError(Exception e)   
  at System.Web.UI.Page.ProcessRequestMain(Boolean includeStagesBeforeAsyncPoint, Boolean includeStagesAfterAsyncPoint)   
  at System.Web.UI.Page.ProcessRequest(Boolean includeStagesBeforeAsyncPoint, Boolean includeStagesAfterAsyncPoint)   
  at System.Web.UI.Page.ProcessRequest()   
  at System.Web.UI.Page.ProcessRequest(HttpContext context)   
  at System.Web.HttpApplication.CallHandlerExecutionStep.System.Web.HttpApplication.IExecutionStep.Execute()   
  at System.Web.HttpApplication.ExecuteStep(IExecutionStep step, Boolean& completedSynchronously)

Let's Debug Some Microsoft Code!

Using various tools such as Redgate's Reflector VS Pro and SQL Profiler to step through SharePoint's code, I managed to find why an exception always occurs after you break inheritance on an item. Reflector was particularly useful as I could step through Microsoft's code and see all the variables as if it was my own.

To get this debugging working, I followed this article on the Redgate site: http://documentation.red-gate.com/display/REF8/Debugging+into+SharePoint+and+seeing+the+locals. Essentially, you need to make 2 changes to debug the SharePoint code:

1) Run regedit from the Run menu HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment and add a string entry of COMPLUS_ZAPDISABLE, with a value of 1


2) To stop the code being optimized away and to see the local variables in Visual Studio, you also need to add an ini file in the SharePoint assembly directory (you can find this out via the modules window when in debug mode) e.g.C:\Windows\Microsoft.Net\assembly\GAC_MSIL\Microsoft.SharePoint\v4.0_15.0.0.0__71e9bce111e9429c\Microsoft.SharePoint.dll

The ini file should be called Microsoft.SharePoint.ini with the following contents:
[.NET Framework Debugging Control]
GenerateTrackingInfo=1
AllowOptimize=0



Why does this happen?


You will always get an exception (with no way to handle it) because the properties.ListItem and properties.ListItem.File are invalidated by the BreakRoleInheritance() call. This occurs in the Update() call within the SaveButton.cs code in SharePoint 2013. I assume the code was also the same in 2010.

Because a CheckIn is attempted after the Update() code, against the file, the properties.ListItem.File will always throw the exceptions you see above.

You can see this in the code screenshot of the Microsoft SaveButton code below. Because BreakRoleInheritance invalidates the item during the update (because the SharePoint callback SQL code can't find the item), the CheckIn line will always get an exception.



The screenshot below shows the call (which goes to an external COM component, and then to SQL). This callback returns zero records.


The External Call to GetListItemDataWithCallback2():

If you run SQL Profiler at the same time, you will see that the SQL query is too specific and excludes the item on which you just Broke Role Inheritance.


After the call to SQL (which failed to retrieve the item and returns an array of zero length), an index is used on an array assuming that there is a record there. The index is out of the bounds of the array, and so fails with an exception. This is where the invalid index call occurs (during the Save Button
CheckIn() call):



The SQL that returns no records (and why the array is empty) is as below:

exec sp_executesql N'          SELECT t3.[nvarchar12] AS c15c10, t1.[SortBehavior] AS c0, UserData.[nvarchar10], UserData.[tp_ItemOrder], UserData.[nvarchar1], t1.[FolderChildCount] AS c31, t1.[ParentLeafName] AS c36, t2.[nvarchar4] AS c3c6, UserData.[ntext1], UserData.[nvarchar14], t4.[nvarchar6] AS c22c7, UserData.[tp_AppAuthor], t2.[tp_Created] AS c3c11, t3.[nvarchar6] AS c15c7, t1.[ProgId] AS c18, UserData.[nvarchar19], UserData.[tp_AppEditor], t1.[Type] AS c13, UserData.[tp_ID], t1.[ScopeId] AS c21, UserData.[nvarchar5], UserData.[bit1], t1.[ClientId] AS c26, UserData.[tp_GUID], t1.[TimeCreated] AS c1, UserData.[tp_Editor], t2.[nvarchar11] AS c3c9, UserData.[tp_Author], t4.[tp_ID] AS c22c5, t4.[nvarchar3] AS c22c12, t2.[nvarchar1] AS c3c4, t3.[tp_Created] AS c15c11, UserData.[nvarchar13], UserData.[nvarchar18], t1.[CheckinComment] AS c29, t3.[tp_ID] AS c15c5, CASE WHEN DATALENGTH(t1.DirName) = 0 THEN t1.LeafName WHEN DATALENGTH(t1.LeafName) = 0 THEN t1.DirName ELSE t1.DirName + N''/'' + t1.LeafName END  AS c16, UserData.[tp_ContentTypeId], t1.[Size] AS c24, UserData.[tp_WorkflowVersion], t1.[ETagVersion] AS c37, UserData.[nvarchar4], UserData.[tp_CheckoutUserId], UserData.[tp_Version], UserData.[nvarchar9], t4.[nvarchar9] AS c22c8, t5.[nvarchar1] AS c4, UserData.[tp_IsCurrentVersion], t2.[nvarchar6] AS c3c7, UserData.[tp_HasCopyDestinations], UserData.[tp_Level], UserData.[nvarchar12], UserData.[nvarchar17], t4.[nvarchar12] AS c22c10, t2.[nvarchar3] AS c3c12, t1.[TimeLastModified] AS c14, t3.[nvarchar9] AS c15c8, t1.[MetaInfo] AS c19, t1.[Size] AS c27, t1.[ParentVersionString] AS c35, t1.[LeafName] AS c2, UserData.[nvarchar3], UserData.[tp_Modified], UserData.[nvarchar8], t4.[nvarchar4] AS c22c6, UserData.[tp_UIVersion], t1.[ItemChildCount] AS c30, t2.[tp_ID] AS c3c5, t3.[nvarchar3] AS c15c12, UserData.[tp_CopySource], UserData.[nvarchar11], UserData.[nvarchar16], t7.[Title] AS c34c33, UserData.[tp_InstanceID], t2.[nvarchar12] AS c3c10, t3.[nvarchar4] AS c15c6, t1.[IsCheckoutToLocal] AS c17, t1.[LTCheckoutUserId] AS c25, t6.[Title] AS c32c33, UserData.[tp_UIVersionString], t1.[Id] AS c20, UserData.[nvarchar2], UserData.[nvarchar7], t4.[nvarchar11] AS c22c9, t2.[nvarchar9] AS c3c8, UserData.[nvarchar15], t4.[nvarchar1] AS c22c4, t4.[tp_Created] AS c22c11, t3.[nvarchar11] AS c15c9, t3.[nvarchar1] AS c15c4, UserData.[tp_ModerationStatus], UserData.[nvarchar6], UserData.[tp_Created], t1.[DirName] AS c23, UserData.[tp_WorkflowInstanceID] FROM AllUserData AS UserData WITH(FORCESEEK(AllUserData_PK(tp_SiteId,tp_ListID,tp_DeleteTransactionId,tp_IsCurrentVersion))) INNER LOOP JOIN Docs AS t1 WITH(NOLOCK) ON (UserData.[tp_RowOrdinal] = 0) AND (t1.SiteId=UserData.tp_SiteId) AND (t1.SiteId = @SITEID) AND (t1.ParentId = UserData.tp_ParentId) AND (t1.Id = UserData.tp_DocId) AND ( (UserData.tp_Level = 1 OR  UserData.tp_Level =255) ) AND (t1.Level = UserData.tp_Level) AND ((UserData.tp_Level = 255 AND t1.LTCheckoutUserId =@IU OR (UserData.tp_Level = 1 AND (UserData.tp_DraftOwnerId IS NULL) OR UserData.tp_Level = 2)AND (t1.LTCheckoutUserId IS NULL OR t1.LTCheckoutUserId <> @IU ))) AND (UserData.tp_ListId = @L3 AND UserData.tp_SiteId = @SITEID) AND (UserData.[tp_IsCurrentVersion] = CONVERT(bit,1) ) AND (UserData.[tp_CalculatedVersion] = 0 ) AND (UserData.[tp_DeleteTransactionId] = 0x ) AND (UserData.[tp_ListID] =@LISTID) AND (UserData.[tp_SiteId] =@SITEID) AND (UserData.[tp_CalculatedVersion] = 0 ) AND (UserData.[tp_IsCurrentVersion] = CONVERT(bit,1) ) AND (UserData.[tp_DeleteTransactionId] = 0x ) INNER LOOP JOIN (SELECT CAST(val AS uniqueidentifier) AS InValues FROM dbo.fn_UnpackCsvString(@L4TXP) ) AS Scopes ON (t1.ScopeId = Scopes.InValues) LEFT OUTER LOOP JOIN AllUserData AS t2 WITH(FORCESEEK(AllUserData_PK(tp_SiteId,tp_ListId,tp_DeleteTransactionId,tp_IsCurrentVersion,tp_ID,tp_CalculatedVersion)),NOLOCK) ON (UserData.[tp_Editor]=t2.[tp_ID]) AND (UserData.[tp_RowOrdinal] = 0) AND (t2.[tp_RowOrdinal] = 0) AND ( (t2.tp_Level = 1) ) AND (t2.[tp_IsCurrentVersion] = CONVERT(bit,1) ) AND (t2.[tp_CalculatedVersion] = 0 ) AND (t2.[tp_DeleteTransactionId] = 0x ) AND (t2.tp_ListId = @L5 AND t2.tp_SiteId = @SITEID) AND (UserData.tp_ListId = @L3 AND UserData.tp_SiteId = @SITEID) AND (UserData.[tp_IsCurrentVersion] = CONVERT(bit,1) ) AND (UserData.[tp_CalculatedVersion] = 0 ) AND (UserData.[tp_DeleteTransactionId] = 0x ) LEFT OUTER LOOP JOIN AllUserData AS t3 WITH(FORCESEEK(AllUserData_PK(tp_SiteId,tp_ListId,tp_DeleteTransactionId,tp_IsCurrentVersion,tp_ID,tp_CalculatedVersion)),NOLOCK) ON (UserData.[tp_CheckoutUserId]=t3.[tp_ID]) AND (UserData.[tp_RowOrdinal] = 0) AND (t3.[tp_RowOrdinal] = 0) AND ( (t3.tp_Level = 1) ) AND (t3.[tp_IsCurrentVersion] = CONVERT(bit,1) ) AND (t3.[tp_CalculatedVersion] = 0 ) AND (t3.[tp_DeleteTransactionId] = 0x ) AND (t3.tp_ListId = @L5 AND t3.tp_SiteId = @SITEID) AND (UserData.tp_ListId = @L3 AND UserData.tp_SiteId = @SITEID) AND (UserData.[tp_IsCurrentVersion] = CONVERT(bit,1) ) AND (UserData.[tp_CalculatedVersion] = 0 ) AND (UserData.[tp_DeleteTransactionId] = 0x ) LEFT OUTER LOOP JOIN AllUserData AS t4 WITH(FORCESEEK(AllUserData_PK(tp_SiteId,tp_ListId,tp_DeleteTransactionId,tp_IsCurrentVersion,tp_ID,tp_CalculatedVersion)),NOLOCK) ON (UserData.[tp_Author]=t4.[tp_ID]) AND (UserData.[tp_RowOrdinal] = 0) AND (t4.[tp_RowOrdinal] = 0) AND ( (t4.tp_Level = 1) ) AND (t4.[tp_IsCurrentVersion] = CONVERT(bit,1) ) AND (t4.[tp_CalculatedVersion] = 0 ) AND (t4.[tp_DeleteTransactionId] = 0x ) AND (t4.tp_ListId = @L5 AND t4.tp_SiteId = @SITEID) AND (UserData.tp_ListId = @L3 AND UserData.tp_SiteId = @SITEID) AND (UserData.[tp_IsCurrentVersion] = CONVERT(bit,1) ) AND (UserData.[tp_CalculatedVersion] = 0 ) AND (UserData.[tp_DeleteTransactionId] = 0x ) LEFT OUTER LOOP JOIN AllUserData AS t5 WITH(FORCESEEK(AllUserData_PK(tp_SiteId,tp_ListId,tp_DeleteTransactionId,tp_IsCurrentVersion,tp_ID,tp_CalculatedVersion)),NOLOCK) ON (t1.[LTCheckoutUserId]=t5.[tp_ID]) AND (t5.[tp_RowOrdinal] = 0) AND ( (t5.tp_Level = 1) ) AND (t5.[tp_IsCurrentVersion] = CONVERT(bit,1) ) AND (t5.[tp_CalculatedVersion] = 0 ) AND (t5.[tp_DeleteTransactionId] = 0x ) AND (t5.tp_ListId = @L5 AND t5.tp_SiteId = @SITEID) LEFT OUTER LOOP JOIN AppPrincipals AS t6 WITH(NOLOCK) ON (UserData.[tp_AppAuthor]=t6.[Id]) AND (UserData.[tp_RowOrdinal] = 0) AND (t6.SiteId = @SITEID) AND (UserData.tp_ListId = @L3 AND UserData.tp_SiteId = @SITEID) AND (UserData.[tp_IsCurrentVersion] = CONVERT(bit,1) ) AND (UserData.[tp_CalculatedVersion] = 0 ) AND (UserData.[tp_DeleteTransactionId] = 0x ) LEFT OUTER LOOP JOIN AppPrincipals AS t7 WITH(NOLOCK) ON (UserData.[tp_AppEditor]=t7.[Id]) AND (UserData.[tp_RowOrdinal] = 0) AND (t7.SiteId = @SITEID) AND (UserData.tp_ListId = @L3 AND UserData.tp_SiteId = @SITEID) AND (UserData.[tp_IsCurrentVersion] = CONVERT(bit,1) ) AND (UserData.[tp_CalculatedVersion] = 0 ) AND (UserData.[tp_DeleteTransactionId] = 0x ) WHERE (UserData.[tp_CalculatedVersion] = 0 ) AND (UserData.[tp_IsCurrentVersion] = CONVERT(bit,1) ) AND (UserData.[tp_DeleteTransactionId] = 0x ) AND (UserData.tp_ListID=@LISTID) AND (UserData.tp_SiteId=@SITEID) AND ( (UserData.tp_Level = 1 OR  UserData.tp_Level =255)  AND ( UserData.tp_Level= 255 AND UserData.tp_CheckoutUserId = @IU OR  ( UserData.tp_Level  = 2 AND UserData.tp_DraftOwnerId IS NOT NULL OR UserData.tp_Level  = 1 AND UserData.tp_DraftOwnerId IS  NULL  ) AND ( UserData.tp_CheckoutUserId IS  NULL  OR UserData.tp_CheckoutUserId <> @IU))) AND (UserData.tp_RowOrdinal=0) AND ((UserData.[tp_ID] = @II)) ORDER BY t1.[SortBehavior]  DESC ,UserData.[tp_ID]  ASC  OPTION (FORCE ORDER, MAXDOP 1)',N'@LFFP uniqueidentifier,@SITEID uniqueidentifier,@IU int,@L3 uniqueidentifier,@L4TXP nvarchar(4000),@L5 uniqueidentifier,@II int,@LISTID uniqueidentifier,@RequestGuid uniqueidentifier',@LFFP='00000000-0000-0000-0000-000000000000',@SITEID='DC59B5ED-EF2E-4701-89A5-88057D449776',@IU=10,@L3='5E68F1D8-4EF0-4375-9433-246368FC6E2C',@L4TXP=N'{32C47E39-1A7E-4949-9A71-CEBA54BE5AC7},{882F0725-834F-48F0-95F4-FDA490367003},{4AE1520E-ACF9-4014-BDB8-4F0C897ECD6A},{7D93E0FA-FF19-4E3A-A344-378A5E8DFD4E},{5DC69BCF-2778-4AFD-B4E9-FCAAC84AA40F},{5FCA1571-0643-4467-8067-9E24CA271EBE}',@L5='70002672-8F36-4F40-BB22-7AD9D75923F9',@II=332,@LISTID='5E68F1D8-4EF0-4375-9433-246368FC6E2C',@RequestGuid='8F43499C-ADFE-1030-7AAF-EE92CFD86881'

The Problem In Summary:
1) Your BreakRoleInheritance() call in code makes another call to the SharePoint stored procedure proc_SecRemovePrincipalFromScope
2) proc_SecRemovePrincipalFromScope updates values that are specifically filtered for in the SQL generated by SaveButton.cs for the properties.ListItem.File call
3) Because the query returns an empty array, you will get an out of index exception - even if you try
the properties.InvalidateListItem() or properties.InvalidateWeb() to force a reload.


The Fix:

If you look at the decompiled SharePoint Save Button code, you may notice that there is a Non-fatal error handler at the end. You can essentially short-circuit the CheckIn() call (that is causing the exceptions against the "missing" object in the empty array) by setting the SPItemEventProperties Event Receiver to SPEventReceiverStatus.CancelNoError in the CheckingIn() override like so:
        /// 
        /// An item is being checked in.
        /// 
        public override void ItemCheckingIn(SPItemEventProperties properties)
        {
            //For document libraries, need to do Role Inheritance on Checking in.
            ProcessMetadataChange(properties, SPEventReceiverType.ItemCheckingIn);
            properties.Status = SPEventReceiverStatus.CancelNoError;
            //base.ItemCheckingIn(properties);
        } 

You then do the CheckIn() call in the code yourself rather than rely on SharePoint to do it for you. You can see our entry point in the screenshot below - which means we can Cancel the CheckIn Event and effectively prevent the code that causes exceptions against the expired/invalidated objects in SaveButton from being processed:


When we set the properties.Status to CancelNoError, the code jumps to this soft goto handler and doesn't throw an exception like so:



DDK

3 comments:

botanden said...

Hi David,

I am observing similar behavior with synchronous ItemUpdated event, but I don't have check out / check in enabled with my document library. Can I still apply your workaround?
The SaveItem screenshot contains code with SPUtility.IsCheckedOut(item) condition. Does that apply to libraries without check out enabled?
I've also isolated BreakRoleInheritance code and wrote my test event receiver. I noticed that on newly created document library instances the event receiver doesn't throw any exceptions and works fine.
I've also raised a support incident with Microsoft, it's taking them days and they still haven't back to me.
Thanks for your post, you are the only one in the whole Internet to spot and report this very annoying bug.

Unknown said...

Hi David,

I am going through the same issue. I created an synchronous event receiver of Item updated event in document library. Whenever we create a subsite and update document it not breaking the permissions and giving the below error for first document only. For second document updates its working fine. I tried to debug the code but didn't helped much. Any help is greatly appreciated.
Specified argument was out of the range of valid values. Parameter name: index0.
at Microsoft.SharePoint.Utilities.SPChunked2DObjectArray.get_Item(Int32 index0, Int32 index1)
at Microsoft.SharePoint.SPListItemCollection.CopyRow(SPListItemCollection srcListItems, Int32 iSrcIndex, Int32 iDestIndex)
at Microsoft.SharePoint.SPListItem.EnsureItemIsValid()
at Microsoft.SharePoint.SPListItem.GetValue(SPField fld, Int32 columnNumber, Boolean bRaw, Boolean bThrowException)
at Microsoft.SharePoint.SPListItem.GetValue(String strName, Boolean bThrowIfValueMissing, Boolean bThrowIfFieldMissing)
at Microsoft.SharePoint.SPListItem.get_File()

Trịnh Tuấn Tú said...

Greate post. It saves a ton of time.
Thanks