Wednesday, September 9, 2020

Deployment SCWDP Packages Using Octopus

    There are few ways, how Sitecore is deployed to your hosting. If you are using Azure PaaS then for deployment you use Azure Pipelines. But, when you use on-premise hosting then there is very high probability that you use TeamCity + Octopus.

     Sitecore had introduced SCWDP packages with with Sitecore Azure Toolkit. Indeed, SCWDP packages are well-known WDP packages. It is standard format of files that is used by web deploy to publish your code to IIS. I like SCWDP tools in Sitecore Azure Toolkit. It is step forward to standardization. You are able pack your code and deploy to website. Also, you have backward compatibility. It is possible to convert regular Sitecore packages to .scwdp files with one command line. And you can easily deploy these packages to Azure PaaS. Many thank to Bart Verdonck for list of articles, how to create and use .scwdp packages in Azure.

    But when you will try to use these .scwdp packages with Octopus, you will be unpleasantly surprised. Builtin Octopus step templates don't allow you to deploy these files. Of course you can deploy .scwdp file using powershell+msdeploy, but you need to repeat it for each your project and each package. That is why I decided to wrap this powershell script into step template that could be reused in many projects. 

    Octopus has quite friendly way to contribute step templates. You need to fork OctopusDeploy/Library, create step template by exporting your step from Octopus and send pull request. After review and approve of pull request, it becomes available in list of community templates. Now it can be easily reused in different Sitecore deployment on different Octopus servers.


P.S. I know that Docker is future, and Sitecore is moving in that direction to provide unified deployments to different cloud platforms and on-premise hosting. But, I hope that for companies, who are on 9 version, this Octopus step template will save their time.

Saturday, May 2, 2020

Execute GDPR "Right to be forgotten" on your custom data in Sitecore 9.3

This article is inspired by Rob Habraken presentation during Global Virtual SUGCON 2020. Rob described how to automate anonymizing based on Sitecore Marketing Automation actions. Also, he described how it could be applied to data in Sitecore Forms.

From my side, I recently worked on cleaning up custom data in Sitecore to execute "right to be forgotten" and be fully GDPR compliant. I want to present general way, how to implement it in Sitecore for any custom data.

Sitecore 9.3 is GDPR compliant. And you should not worry, how it saves contact data. But you should know that all contact data could be anonymized by next action:

  1. Open Experience Profile
  2. Find contact that you are interested in
  3. Click on actions menu
  4. Select Anonymize action

This sequence of action, will execute right to be forgotten.

It works until you need to save some additional data in some separate locations(CRM, data storage, etc.). Once you need to have right to be forgotten for your custom data, you need to implement it by yourself. You are not able to rely on Sitecore, because Sitecore has no idea how you implemented custom saving of data. That is why you need to subscribe to Sitecore xConnect events to clean up your data in a same time, when it is done by Sitecore.

Sitecore xConnect has plugin architecture and you are able to register your plugin. It is very convenient, your configuration and your code lives separately from xConnect, but interacts via events. It makes your code very maintainable and you can easily upgrade it to next version of Sitecore.

To join your custom data with Sitecore - you have one requirement, you need to save your custom data with contact identifier. Having contact identifier provides you ability to find records related to contact, for which you need to execute right to be forgotten.

To run your code during right to be forgotten, you need to register your plugin in Sitecore xConnect:
<Settings>
  <Sitecore>
    <XConnect>
      <Collection>
        <Services>
          <EraseFormSubmissionWhenExecutingRightToBeForgotten>
            <Type>Sitecore.Weareyou.Feature.FormsExtension.XConnectServicePlugin.EraseFormSubmissionWhenExecutingRightToBeForgotten, Sitecore.Weareyou.Feature.FormsExtension</Type>
            <As>Sitecore.XConnect.Service.Plugins.IXConnectServicePlugin, Sitecore.XConnect.Service.Plugins</As>
            <LifeTime>Singleton</LifeTime>
          </EraseFormSubmissionWhenExecutingRightToBeForgotten>
        </Services>
      </Collection>
    </XConnect>
  </Sitecore>
</Settings>

Place this file under <xConnect root folder>\\App_Data\Config\Sitecore\Collection\.

Then you need to implement your plugin:


using System;
using Microsoft.Extensions.Logging;
using Sitecore.Framework.Conditions;
using Sitecore.XConnect;
using Sitecore.XConnect.Operations;
using Sitecore.XConnect.Service.Plugins;

namespace Sitecore.Weareyou.Feature.FormsExtension.XConnectServicePlugin
{
    /// <summary>
    /// Implementation was inspired by Sitecore.EmailCampaign.XConnect.Operations.ClearSupressionListWhenExecutingRightToBeForgotten
    /// XConnect service plugin that clears forms data on executing right to be forgotten
    /// Makes Sitecore Forms to be GDPR compliant
    /// e.g. Sitecore > xProfile > Any contact > Action > Anonymize
    ///
    /// To enable it you need to copy XML configuration to sc93xconnect.dev.local\App_Data\Config\Sitecore\Collection
    /// Configuration is present in this repository Project\xConnect\code\App_data\config\sitecore\Collection\sc.XConnect.Service.Plugins.FormsAnonymize.xml
    /// </summary>
    public class EraseFormSubmissionWhenExecutingRightToBeForgotten : IXConnectServicePlugin, IDisposable
    {
        private const string PluginName = "EraseFormSubmissionWhenExecutingRightToBeForgotten";
        private XdbContextConfiguration _configuration;
        private readonly ILogger _logger;
        
        public EraseFormSubmissionWhenExecutingRightToBeForgotten(ILogger<EraseFormSubmissionWhenExecutingRightToBeForgotten> logger)
        {
        _logger = logger;
        }
        public void Dispose()
        {
            GC.SuppressFinalize(this);
        }

        /// <summary>
        /// Subscribes  to events
        /// </summary>
        /// <param name="config">XdbContextConfiguration</param>
        public void Register(XdbContextConfiguration config)
        {
            _configuration = Condition.Requires(config, "config").IsNotNull().Value;
            _configuration.OperationAdded += OnOperationAdded;
        }

        private void OnOperationAdded(object sender, XdbOperationEventArgs xdbOperationEventArgs)
        {
            Condition.Requires(xdbOperationEventArgs, "xdbOperationEventArgs").IsNotNull();
            RightToBeForgottenOperation rightToBeForgottenOperation;
            if ((rightToBeForgottenOperation = (xdbOperationEventArgs.Operation as RightToBeForgottenOperation)) != null)
            {
                if (rightToBeForgottenOperation.Status == XdbOperationStatus.Canceled)
                {
                    _logger.LogDebug($"[{PluginName}] Skipping operation as it has been cancelled");
                }
                else
                {
                    rightToBeForgottenOperation.DependencyAdded += OnDependencyAddedToRightToBeForgottenOperation;
                }
            }
        }

        private void OnDependencyAddedToRightToBeForgottenOperation(object sender, DependencyAddedEventArgs args)
        {
            Condition.Requires(args, "args").IsNotNull();
            GetEntityOperation<Contact> getEntityOperation;
            if ((getEntityOperation = (args.Predecessor as GetEntityOperation<Contact>)) != null)
            {
                getEntityOperation.StatusChanged += OnGetEntityOperationStatusChanged;
            }
        }

        private void OnGetEntityOperationStatusChanged(object sender, StatusChangedEventArgs args)
        {
            Condition.Requires(sender, "sender").IsNotNull();
            Condition.Requires(args, "args").IsNotNull();
            GetEntityOperation<Contact> getEntityOperation;
            if ((getEntityOperation = (sender as GetEntityOperation<Contact>)) != null && args.NewStatus == XdbOperationStatus.Succeeded)
            {
                var entity = getEntityOperation.Entity;
                var contactId = entity.Id;
                if (contactId.HasValue)
                {
                    var identifier = entity.Identifiers
                        .FirstOrDefault(i => i.Source.Equals("xDB.Tracker"));
                    if(identifier != null) { 
                        if (Guid.TryParse(identifier?.Identifier, out var id))
                        {
                           //-------------------
                           //HERE SHOULD BE CODE THAT CLEAN UP CONTACT DATA IN YOUR CUSTOM LOCATION
                           //-------------------
                        }
                    }
                }
            }
        }

        public void Unregister()
        {
            _configuration.OperationAdded -= OnOperationAdded;
        }
    }
}

What this plugin does: it subscribes on xConnect events and run your code once right to be forgotten event was triggered.


Let's sum up, how to continue to be GDPR complaint if you need to save some custom user data:
  1. If it is possible, you need to save user contact data in Sitecore to have anything in one place (e.g. contact facets)
  2. If for some reason, you need separate storage for contacts data then you need to save these data with contact identifier. It allows you to find these data later.
  3. You need to implement and register xConnect plugin. It should clear your data, when right to be forgotten was triggered in Sitecore

You can see this approach in an action in my GitHub repository forked from Rob Haraken repository that he used for demo on Sitecore Virtual SUGCON 2020.