Monday, June 01, 2009

Field Security Level For Microsoft Dynamics CRM 4.0 With Own Hands

Following article describes how to create mechanism which provides/restricts access to see fields of records in edit form/print preview/grids.

Idea:
1. Creation two custom entities - Field Security Level(contains name of entity to restrict access to fields and Business Unit for which restriction will be operate) and Field Security Level Attribute (contains Attribute Name and Bit flag - Access Allowed/Disallowed).
Field Security Level entity is parent to Field Security Level Attribute. Both entities are organization owned.
2. Field Security Level form scripting.
3. Writing a plugin which will create Field Security Level Attributes for just created Field Security Level based on entity name.
4. Writing a base class which will retrieve fields restriction for entity.
5. Writing a plugin which will handle Execute message, retrieve entity name and modify request to prevent appearance of 'forbidden' columns.
6. Writing a plugin which will handle Retrieve message, retrieve entity name and modify request to prevent appearance of 'forbidden' columns.
7. Writing a plugin which will handle RetrieveMultiple message, retrieve entity name and modify request to prevent appearance of 'forbidden' columns.


1.1. Creation of Field Security Level Entity:



Here I input Display Name, Plural Name, ownership is organization, name of entity. Mark all details as shown.



I set primary attribute name as new_entityname.



I create required attributes:
new_bu - Display name of Business Unit
new_buid - identifier of Business Unit
new_entitydisplayname - display name of entity (entity schema name will be stored in new_entityname field)
new_entitytypecode - entity type code

I design the appearance of form:



Iframe I have added for child details display.
Second tab will be hidden. Here is it:



1.2. Child entity creation:



Here I input Display Name, Plural Name, ownership is organization, name of entity.



I set primary attribute name as new_name.





I've added the relationship between Field Security Level and Field Security Level Attribute.



I create required attributes:
new_isallowed - attribute show accessibility of field
new_fieldsecuritylevelid - lookup attribute - to the primary entity Security Level View
new_displayname - display name of attribute



I configure form appearance.

2. Field Security Level OnLoad script:

function ConvertEntityToPicklist(fieldName, dataItems)
{
var defaultValue = crmForm.all[fieldName].DataValue;
var table = crmForm.all[fieldName + "_d"];
var select = "<select req='0' id='" + fieldName + "' name='" + fieldName + "' defaultSelected='' class='ms-crm-SelectBox' tabindex='1170'>";
var defaultValueFound = false;

for (var i = 0; i < dataItems.length; i++)
if (dataItems[i].selectSingleNode('IsCustomizable').text == "true")
{
select += "<option value='" + dataItems[i].selectSingleNode('LogicalName').text + "' ";
select += "entitytypeid='"+dataItems[i].selectSingleNode('ObjectTypeCode').text+"'";
if (dataItems[i].selectSingleNode('LogicalName').text == defaultValue)
{
select += " SELECTED";
defaultValueFound = true;
}
select += ">" + dataItems[i].selectSingleNode('DisplayName/LocLabels/LocLabel/Label').text + "</option>";
}

if ((defaultValue != null) && (defaultValue.length > 0) && !defaultValueFound)
{
select += "<option value='" + defaultValue + "' SELECTED>" + defaultValue + "</option>";
}

select += "</select>";
table.innerHTML = select;
}

function ConvertBUToPicklist(fieldName, dataItems)
{
var defaultValue = crmForm.all.new_buid.DataValue;

if (defaultValue == null)
{
defaultValue = dataItems[0].selectSingleNode('./q1:businessunitid').nodeTypedValue;
crmForm.all.new_buid.DataValue = defaultValue
}


var table = crmForm.all[fieldName + "_d"];
var select = "<select req='0' id='" + fieldName + "' name='" + fieldName + "' defaultSelected='' class='ms-crm-SelectBox' tabindex='1170'>";
var defaultValueFound = false;

for (var i = 0; i < dataItems.length; i++)
{
select += "<option value='" + dataItems[i].selectSingleNode('./q1:businessunitid').nodeTypedValue + "'";
if (dataItems[i].selectSingleNode('./q1:businessunitid').nodeTypedValue == defaultValue)
{
select += " SELECTED";
defaultValueFound = true;
}
select += ">" + dataItems[i].selectSingleNode('./q1:name').nodeTypedValue + "</option>";
}

if ((defaultValue != null) && (defaultValue.length > 0) && !defaultValueFound)
{
select += "<option value='" + defaultValue + "' SELECTED>" + defaultValue + "</option>";
}

select += "</select>";
table.innerHTML = select;
}

crmForm.all.tab1Tab.style.display = 'none';

var request = "" +
"<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
"<soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">" +
GenerateAuthenticationHeader() +
" <soap:Body>" +
" <Execute xmlns=\"http://schemas.microsoft.com/crm/2007/WebServices\">" +
" <Request xsi:type=\"RetrieveAllEntitiesRequest\">" +
" <RetrieveAsIfPublished>true</RetrieveAsIfPublished>" +
" <MetadataItems>EntitiesOnly</MetadataItems>" +
" </Request>" +
" </Execute>" +
" </soap:Body>" +
"</soap:Envelope>";

var xmlHttpRequest = new ActiveXObject("Msxml2.XMLHTTP");

xmlHttpRequest.Open("POST", "/mscrmservices/2007/MetadataService.asmx", false);
xmlHttpRequest.setRequestHeader("SOAPAction","http://schemas.microsoft.com/crm/2007/WebServices/Execute");
xmlHttpRequest.setRequestHeader("Content-Type", "text/xml; charset=utf-8");
xmlHttpRequest.setRequestHeader("Content-Length", request.length);
xmlHttpRequest.send(request);

var result = xmlHttpRequest.responseXML;
var schemaNames = result.selectNodes("//CrmMetadata/CrmMetadata");
ConvertEntityToPicklist('new_entityname', schemaNames);



request = "<?xml version='1.0' encoding='utf-8'?>"+
"<soap:Envelope xmlns:soap='http://schemas.xmlsoap.org/soap/envelope/'"+
" xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'"+
" xmlns:xsd='http://www.w3.org/2001/XMLSchema'>"+
GenerateAuthenticationHeader() +
"<soap:Body>"+
"<RetrieveMultiple xmlns='http://schemas.microsoft.com/crm/2007/WebServices'>"+
"<query xmlns:q1='http://schemas.microsoft.com/crm/2006/Query'"+
" xsi:type='q1:QueryExpression'>"+
"<q1:EntityName>businessunit</q1:EntityName>"+
"<q1:ColumnSet xsi:type='q1:ColumnSet'>"+
"<q1:Attributes>"+
"<q1:Attribute>name</q1:Attribute>"+
"</q1:Attributes>"+
"</q1:ColumnSet>"+
"<q1:Distinct>false</q1:Distinct>"+
"</query>"+
"</RetrieveMultiple>"+
"</soap:Body>"+
"</soap:Envelope>";

xmlHttpRequest = new ActiveXObject("Msxml2.XMLHTTP");

xmlHttpRequest.Open("POST", "/mscrmservices/2007/CrmService.asmx", false);
xmlHttpRequest.setRequestHeader("SOAPAction","http://schemas.microsoft.com/crm/2007/WebServices/RetrieveMultiple");
xmlHttpRequest.setRequestHeader("Content-Type", "text/xml; charset=utf-8");
xmlHttpRequest.setRequestHeader("Content-Length", request.length);
xmlHttpRequest.send(request);

result = xmlHttpRequest.responseXML;

var BUs = result.getElementsByTagName('BusinessEntity');
ConvertBUToPicklist('new_buid', BUs);

if (crmForm.FormType != 1)
{
document.getElementById('new_entityname_d').disabled = true;
document.getElementById('new_buid_d').disabled = true;

var url = "areas.aspx?";
url += "oId=" + crmFormSubmit.crmFormSubmitId.value;
url += "&oType=" + crmFormSubmit.crmFormSubmitObjectType.value;
url += "&security=" + crmFormSubmit.crmFormSubmitSecurity.value;
url += "&tabSet=new_new_fieldlevelsecurity";
crmForm.all.IFRAME_attributes.src = url;

crmForm.all.IFRAME_attributes.attachEvent("onreadystatechange", Ready);

function Ready()
{
var doc = crmForm.all.IFRAME_attributes.contentWindow.document;
if (doc.getElementById("mnuBar1") != null)
doc.getElementById("mnuBar1").style.display = "none";
}

}


Field Security Leve OnSave script:

if (crmForm.FormType == 1)
{
crmForm.all.new_bu.DataValue = document.getElementById('new_buid').options[document.getElementById('new_buid').selectedIndex].innerText;

crmForm.all.new_entitydisplayname.DataValue = document.getElementById('new_entityname').options[document.getElementById('new_entityname').selectedIndex].innerText;

crmForm.all.new_entitytypecode.DataValue = document.getElementById('new_entityname').options[document.getElementById('new_entityname').selectedIndex].getAttribute('entitytypeid');
}


3. Plugin for creation Child entities (attributes) for field security level. Step must be registered as a Post Syncronous Create to new_fieldsecuritylevel entity. Code of the plugin:

using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.Crm.Sdk;
using Microsoft.Crm.SdkTypeProxy;
using Microsoft.Crm.Sdk.Metadata;
using Microsoft.Crm.SdkTypeProxy.Metadata;

namespace FieldSecurity
{
public class FieldSecurityCreateHandler : IPlugin
{

#region CTOR

public FieldSecurityCreateHandler(string config, string secureConfig)
{
}

#endregion CTOR


#region IPlugin Members

public void Execute(IPluginExecutionContext context)
{
try
{
if (context.MessageName == MessageName.Create &&
context.InputParameters.Contains("Target") &&
context.InputParameters["Target"] is DynamicEntity &&
((DynamicEntity)context.InputParameters["Target"]).Name == "new_fieldlevelsecurity")
{
DynamicEntity fls = (DynamicEntity)context.InputParameters["Target"];
string entityname = (string)fls["new_entityname"];
Guid recordid = (Guid)context.OutputParameters["Id"];

//Metadata sevice instance creation
IMetadataService mservice = context.CreateMetadataService(true);

//Request for entity attributes creation
RetrieveEntityRequest request = new RetrieveEntityRequest();
request.EntityItems = EntityItems.IncludeAttributes;
request.LogicalName = entityname;
request.RetrieveAsIfPublished = true;

RetrieveEntityResponse response = (RetrieveEntityResponse)mservice.Execute(request);

//CrmService instance creation
ICrmService crmservice = context.CreateCrmService(true);

foreach (AttributeMetadata am in response.EntityMetadata.Attributes)
if (am.ValidForRead.Value == true &&
am.ValidForUpdate.Value == true &&
string.IsNullOrEmpty(am.AttributeOf) && //remove attributes live customeridtype
string.IsNullOrEmpty(am.AggregateOf) &&
am.LogicalName != "timezoneruleversionnumber" &&
am.LogicalName != "utcconversiontimezonecode" &&
am.AttributeType.Value != AttributeType.PrimaryKey)//remove primary key attribute
{
//Create child entity which contains information about
//attribute and restriction (show or not)
DynamicEntity attributeEntity = new DynamicEntity("new_attribute");
attributeEntity["new_name"] = am.LogicalName;
attributeEntity["new_displayname"] = am.DisplayName.LocLabels.Length == 0 ? am.LogicalName : am.DisplayName.LocLabels[0].Label;
attributeEntity["new_isallowed"] = new CrmBoolean(true);
attributeEntity["new_fieldlevelsecurityid"] = new Lookup("new_fieldlevelsecurity", recordid);

crmservice.Create(attributeEntity);
}
}
}
catch (System.Web.Services.Protocols.SoapException exc)
{
throw new Exception(exc.Detail.InnerXml);
}
}

#endregion
}
}


4. Writing a base class which will retrieve fields restriction for entity.

using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.Crm.Sdk;
using Microsoft.Crm.SdkTypeProxy;
using Microsoft.Crm.Sdk.Query;

namespace FieldSecurity
{
public abstract class BasePlugin
{
protected List<string> RetrieveRestrictions(IPluginExecutionContext context, string entityname)
{
if (entityname == "new_attribute")
return new List<string>();

ICrmService crmService = context.CreateCrmService(true);

systemuser user = (systemuser)crmService.Retrieve(EntityName.systemuser.ToString(), context.UserId, new ColumnSet(new string[] { "businessunitid" }));
Guid buid = user.businessunitid.Value;

QueryExpression query = new QueryExpression("new_attribute");
query.ColumnSet = new ColumnSet(new string[] { "new_name" });
query.Criteria.AddCondition("new_isallowed", ConditionOperator.Equal, false);

LinkEntity link = query.AddLink("new_fieldlevelsecurity", "new_fieldlevelsecurityid", "new_fieldlevelsecurityid");
link.LinkCriteria.AddCondition("new_entityname", ConditionOperator.Equal, entityname);
link.LinkCriteria.AddCondition("new_buid", ConditionOperator.Equal, "{" + buid.ToString().ToUpper() + "}");

RetrieveMultipleRequest request = new RetrieveMultipleRequest();
request.Query = query;
request.ReturnDynamicEntities = true;

List<BusinessEntity> restrictions = ((RetrieveMultipleResponse)crmService.Execute(request)).BusinessEntityCollection.BusinessEntities;

List<string> result = new List<string>();

foreach (DynamicEntity entity in restrictions)
result.Add((string)entity["new_name"]);

return result;
}
}
}


5. Writing a plugin which will handle Execute message, retrieve entity name and modify request to prevent appearance of 'forbidden' columns. Step must be registered as a Pre Syncronous on message Execute with no Primary and Child entity selection.

using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.Crm.Sdk;
using System.Xml;

namespace FieldSecurity
{
public class ExecuteHandler : BasePlugin, IPlugin
{

#region CTOR

public ExecuteHandler(string config, string secureConfig)
{
}

#endregion CTOR

#region IPlugin Members

public void Execute(IPluginExecutionContext context)
{
if (context.MessageName == "Execute" && context.InputParameters.Contains("FetchXml"))
{
XmlDocument indoc = new XmlDocument();
indoc.LoadXml((string)context.InputParameters["FetchXml"]);

string entityName = indoc.SelectSingleNode("//fetch/entity").Attributes["name"].InnerText;

List<string> restrictions = RetrieveRestrictions(context, entityName);

if (restrictions.Count == 0)
return;

foreach (XmlNode node in indoc.SelectNodes("//fetch/entity/attribute"))
if (restrictions.Contains(node.Attributes["name"].Value))
node.ParentNode.RemoveChild(node);

context.InputParameters["FetchXml"] = indoc.OuterXml;
}
}

#endregion IPlugin Members
}
}


6. Writing a plugin which will handle Retrieve message, retrieve entity name and modify request to prevent appearance of 'forbidden' columns. Step must be registered as a Pre Syncronous on message Retrieve with no Primary and Child entity selection.

using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.Crm.Sdk;
using Microsoft.Crm.Sdk.Query;
using Microsoft.Crm.SdkTypeProxy;

namespace FieldSecurity
{
public class RetrieveHandler : BasePlugin, IPlugin
{

#region CTOR

public RetrieveHandler(string config, string secureConfig)
{
}

#endregion CTOR

#region IPlugin Members

public void Execute(IPluginExecutionContext context)
{
if (context.MessageName == MessageName.Retrieve)
{
string entityName = context.PrimaryEntityName;

List<string> restrictions = RetrieveRestrictions(context, entityName);

if (restrictions.Count == 0)
return;

ColumnSet columns = (ColumnSet)context.InputParameters["ColumnSet"];

foreach (string restriction in restrictions)
while (columns.Attributes.Contains(restriction))
columns.RemoveColumn(restriction);

context.InputParameters["ColumnSet"] = columns;
}
}

#endregion IPlugin Members
}
}


7. Writing a plugin which will handle RetrieveMultiple message, retrieve entity name and modify request to prevent appearance of 'forbidden' columns. Step must be registered as a Pre Syncronous on message Retrieve with no Primary and Child entity selection.

using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.Crm.Sdk;
using Microsoft.Crm.Sdk.Query;

namespace FieldSecurity
{
public class RetrieveMultipleHandler : BasePlugin, IPlugin
{

#region CTOR

public RetrieveMultipleHandler(string config, string secureConfig)
{
}

#endregion CTOR

#region IPlugin Members

public void Execute(IPluginExecutionContext context)
{
if (context.MessageName == MessageName.RetrieveMultiple)
{
string entityName = context.PrimaryEntityName;

List<string> restrictions = RetrieveRestrictions(context, entityName);

if (restrictions.Count == 0)
return;

QueryExpression query = (QueryExpression)context.InputParameters["Query"];

foreach (string restriction in restrictions)
while (((ColumnSet)query.ColumnSet).Attributes.Contains(restriction))
((ColumnSet)query.ColumnSet).RemoveColumn(restriction);

context.InputParameters["Query"] = query;
}
}

#endregion IPlugin Members
}
}


And Video Demonstration of functionality:

41 comments:

  1. Hi a33ik,

    nice idea. Personally I might change the optic. If user is not allowed to see the information he/she should see "not allowed to view info" or something like it. Otherwise he/she would modify for example the email-address.

    ReplyDelete
  2. Hi, Carsten.

    Another one plugin for pre-create|pre-update can be written which will disallow user to change restricted fields.

    ReplyDelete
  3. Yeah. It looks good.

    Amol
    www.mscrmkb.blogspot.com

    ReplyDelete
  4. Very nice post, does it work for Advanced Find as well?

    ReplyDelete
  5. Yes, Jim. It works with advanced find.

    ReplyDelete
  6. How to hide from Reports? User can always create report to list all data and fields using report wizard.

    ReplyDelete
  7. Yes. I know this issue...

    ReplyDelete
  8. This is a great solution!!
    Besides the fields being displayed in the custom reports, I'm having an error when trying to merge two records with a restricted field:
    Unable to cast object of type "Microsoft.Crm.Sdk.AllColumns" to type "Microsoft.Crm.Sdk.Query.ColumnSet".

    ReplyDelete
  9. Thank you for your bug report. I'll fix this issue and update the post!

    ReplyDelete
  10. Thanks for the message on Linkedin. This is a great tool, however I need the security to work for Reports and the infamous print button.

    ReplyDelete
  11. This solution works with infamous print button. For the reports you have to redesign your reports...

    ReplyDelete
  12. Hi,

    I tried following the approach above and I ran into the following issues:

    1. Business unit is not rendered as a dropdown
    2. The grid (with a list of attributes) are not selectable !!

    Any help is very much appreciated.

    If possible, could please provide the customizations and the source code as a downloadable package.

    Cheers,
    Kiran Banda

    ReplyDelete
  13. Hi azzik,

    This is really a nice post..It works like a charm..Thank you for posting this wonderful article..Its really useful.

    Regards

    Arun

    ReplyDelete
  14. Hi azzik,

    Is it possible to hide the attribute while retriving the entities..Here u have remove the data from that particular attribute, but is it possible to hide that particular attribute..

    Regards

    Arun

    ReplyDelete
  15. Hi, Arun. It seems to be possible with JavaScript. This can be done with writting universal javascript and pasting it to all required Forms OnLoad handlers.

    Have you seen c360 solution?

    ReplyDelete
  16. Hi azzik,

    I have seen the screen shot for that c360 solutions..But i cant able to open the download page..Is the source code is given for that product?
    How should i write the universal javascript and pasting it to all required Forms OnLoad handlers.Can you please explain me, how to solve this?

    Regards

    Arun

    ReplyDelete
  17. Hi, Arun.

    C360 field security level is commercial product so I don't think that they will give you source code...

    About universal JavaScript code - you can write code based on CRM WebServices reading dissallowed fields from entities and hide them based on condition this field allowed/disallowed for user.

    ReplyDelete
  18. hi a33ik Butenko ;

    i tried run data audit manager but it resulted error. please help me ?if possible , could please provide the customizations and packable source code with solution ?

    ReplyDelete
  19. hi azzik,

    Regarding the universal JavaScript, where should i place that file.i have to write it in separate file or i can include in the class file as you have given above.But is there any possibility to call the javascript from class file..

    Regards

    Arun

    ReplyDelete
  20. Hi, Arun.

    You can't use C# class file. This file must be written on JavaScript.

    ReplyDelete
  21. Hi, Crackbyte.

    Files with customization and C# files can be downloaded here - http://cid-f63e4bcd4a7f64a4.skydrive.live.com/self.aspx/BlogFiles/DataAudit.zip

    ReplyDelete
  22. Hi azzik,

    If i have that separate file, then from where should i call that file.Should i have to call that in every forms in the onLoadevent?

    As per your above code, we can use the "Retrive" and "RetriveMultiple" events while loading.can you please tell me, how can i solve this?..

    Is it possible to call, javascript from the class file?.

    i have gone through one article
    from the following link
    http://bytes.com/topic/c-sharp/answers/615505-calling-javascript-function-csharp-class

    Here they mentioned that, we have to create a javascript file as a package and we can call it in the class file..i have tried and didnt get the solution.if possible can you try this and reply me please..

    Regards

    Arun

    ReplyDelete
  23. Dear azzik,

    Have you tried as i said in the previous blog..
    Can you tell me, the retrive event is same as onload event.As you have mentioned in your above code(ie. register the plugin in "Retrive" and "RetriveMultiple").will the same process take it for Onload event?

    Regards
    Arun

    ReplyDelete
  24. Hi, Arun.

    This is different events. Handling on Retrieve message is executed on the server side. OnLoad event handlers triggers on client side.

    ReplyDelete
  25. Hi azzik,

    Thank you for your reply.As you have mentioned earlier in our discussion, can you tell me about the universal JavaScript where i have to write the code?

    Regards

    Arun

    ReplyDelete
  26. You have to develop JavaScript which will read restricted fields and hide them and place on all forms you want to use it.

    ReplyDelete
  27. what type of new_entityname attribute? picklist or nvarchar?
    instead should be the primary attribute is nvarchar.. sorry if i'm wrong :)

    ReplyDelete
  28. Yes, it it nvarchar. But it is converted to picklist using JavaScript.

    ReplyDelete
  29. I have been getting this error once going into the Field Level Security entity and still can't figure out why I'm exactly getting it:

    There was an error with this field's customized event.

    Field:window

    Event:onload

    Error:'crmForm.all.IFRAME_attributes' is null or not an object


    Any help would be appreciated.
    Thanks!

    ReplyDelete
  30. Have you added iframe with name IFRAME_attributes?

    ReplyDelete
  31. I have since. I am a little new to MSCRM so sometimes I have to double check some things. However, I have a new issue. Instead of showing the child form in the iframe, it is only showing a view instead with Attribute Name and Created On instead of the Is Allowed field.

    ReplyDelete
  32. You have to change Associated View of new_attribute entity (child records).

    ReplyDelete
  33. Very nice post.

    A question though:
    This works on the output of advanced find, as you cannot see the data in the fields.
    But you can still perform a search on a column you aren't allowed to see data from.
    Can this be prevented as well?

    ReplyDelete
  34. Hi, Dieter.

    It seems that this is possible. Another one pre-Execute and pre-RetrieveMultiple plugin is required to develop.

    ReplyDelete
  35. As explained in this blog, I am completed all the steps. But seems some thing wrong with plugins or customizations. Can you please provide me the customizations and plugin code in zip file. This is really very urgent in my project. Requesting again please let me know the path where can I download them?

    ReplyDelete
  36. Error:-
    Try this action again. If the problem continues, check the Microsoft Dynamics CRM Community for solutions or contact your organization's Microsoft Dynamics CRM Administrator. Finally, you can contact Microsoft Support.

    When we use first plug-in for which will create Field Security Level Attributes for just created Field Security Level based on entity name.

    Please Help me

    Regards,
    Sandeep Verma

    ReplyDelete
  37. Hello, Sandeep.
    Try to debug it. This customization and plugin worked fine for me.

    ReplyDelete
  38. I Andriy,
    thanks for this post.
    Do you know if this kind of development is possible on 2011 version of MSCRM ?
    I am actually not entirely satisfied with the Field Level Security feature given by Microsoft.
    I am looking for a solution to implement field level security on out of box fields (like emails, phone numbers etc...) and based on the business unit of the user connected.
    Thanks in advance

    ReplyDelete
  39. Hello Pierre,
    It would be even easier to implement the same features for CRM 2011 because you will need to implement only handling of RetrieveMultiple message.

    ReplyDelete
  40. This comment has been removed by the author.

    ReplyDelete