Tracking File Downloads in Salesforce using Platform Events and Big Objects

Posted by Johan on Tuesday, July 2, 2019

The problem

Recently I was tasked with tracking downloads of certain files in Salesforce, more specifically files related to Opportunities. This is a common problem where you want to know if a Sales person is downloading customer data to be able to bring some customers with them when they move on to a new employer.

Using Salesforce shield was not an option, mainly because of the price, so it had to be done with “Standard Salesforce”, this is a term often heard but it usually means that we’re going to customise as hard as possible without installing anything from a 3rd party.

I was preparing to remove the standard view for Files on the affected objects and then build a custom Lightning Component that would serve the files and log each access. I love to code but I didn’t really like this approach since it’s super custom and doesn’t really scale. What if they say that they want to track all files in the future?

Anyways, Google is your friend and I ended up at Custom File Download Examples which gives you an example for how to intercept downloads and take a decision to allow or restrict access and you can even redirect to a custom VF page that takes care of whatever you want the user to do before getting the file. This has been around since version 39 but I had never heard of it.

I ended up modifying this example to create a Platform Event when someone downloads a file attached to a specific object, the Platform Event is picked up by a trigger that writes this to a Big Object. This might be overkill but given that you have 1,000,000 rows of Big Objects for free you might as well use them but of course you can do whatever you want with them.

The below example will save: 1. User Id 2. User Name 3. Content Document Version Id 4. Content Document Title 5. Content Document File Type 6. SObject Type 7. Record Id 8. Timestamp

Show me the code!

ContentDownloadHandlerFactoryImpl.cls:

public class ContentDownloadHandlerFactoryImpl implements Sfc.ContentDownloadHandlerFactory {
    public Sfc.ContentDownloadHandler getContentDownloadHandler(List<ID> ids, Sfc.ContentDownloadContext context) {
        Sfc.ContentDownloadHandler contentDownloadHandler = new Sfc.ContentDownloadHandler();
        Id fileId = ids.get(0);
        ContentVersion cv = [SELECT Id, ContentDocumentId, ContentDocument.Title, ContentDocument.FileType FROM ContentVersion WHERE Id =: fileId LIMIT 1];
        List<ContentDocumentLink> cdls = [SELECT Id, LinkedEntityId FROM ContentDocumentLink WHERE ContentDocumentId =: cv.ContentDocumentId];
        Boolean monitored = false;
        Id recordId = null;
        String objectType = null;
        for(ContentDocumentLink cdl : cdls) {
            if(ccdl.LinkedEntityId.getSObjectType() == Schema.Opportunity.getSObjectType()) {
                monitored = true;
                recordId = cdl.LinkedEntityId;
                objectType = cdl.LinkedEntityId.getSObjectType().getDescribe().getName();
            }        
        }
        if(monitored) {
            ContentDownloadEvent__e cde = new ContentDownloadEvent__e(
                UserId__c=UserInfo.getUserId(),
                UserName__c=UserInfo.getUserName(),
                ContentDocumentVersionId__c=cv.Id,
                ContentDocumentTitle__c=cv.ContentDocument.Title,
                ContentDocumentFileType__c=cv.ContentDocument.FileType,
                SObjectType__c=objectType,
                RecordId__c=recordId,
                Timestamp__c=DateTime.now()
            );
            Database.SaveResult sr = EventBus.publish(cde);
        }
        contentDownloadHandler.isDownloadAllowed = true;        
        return contentDownloadHandler;
    }
}

ContentDownload__b.object:

<?xml version="1.0" encoding="UTF-8"?>
<CustomObject xmlns="http://soap.sforce.com/2006/04/metadata">
    <deploymentStatus>InDevelopment</deploymentStatus>
    <fields>
        <fullName>ContentDocumentFileType__c</fullName>
        <externalId>false</externalId>
        <label>ContentDocumentFileType</label>
        <length>20</length>
        <required>true</required>
        <type>Text</type>
        <unique>false</unique>
    </fields>
    <fields>
        <fullName>ContentDocumentTitle__c</fullName>
        <externalId>false</externalId>
        <label>ContentDocumentTitle</label>
        <length>255</length>
        <required>true</required>
        <type>Text</type>
        <unique>false</unique>
    </fields>
    <fields>
        <fullName>ContentDocumentVersionId__c</fullName>
        <externalId>false</externalId>
        <label>ContentDocumentVersionId</label>
        <length>18</length>
        <required>true</required>
        <type>Text</type>
        <unique>false</unique>
    </fields>
    <fields>
        <fullName>RecordId__c</fullName>
        <externalId>false</externalId>
        <label>RecordId</label>
        <length>18</length>
        <required>false</required>
        <type>Text</type>
        <unique>false</unique>
    </fields>
    <fields>
        <fullName>SObjectType__c</fullName>
        <externalId>false</externalId>
        <label>SObjectType</label>
        <length>40</length>
        <required>true</required>
        <type>Text</type>
        <unique>false</unique>
    </fields>
    <fields>
        <fullName>Timestamp__c</fullName>
        <externalId>false</externalId>
        <label>Timestamp</label>
        <required>true</required>
        <type>DateTime</type>
    </fields>
    <fields>
        <fullName>UserId__c</fullName>
        <externalId>false</externalId>
        <label>UserId</label>
        <length>18</length>
        <required>true</required>
        <type>Text</type>
        <unique>false</unique>
    </fields>
    <fields>
        <fullName>UserName__c</fullName>
        <externalId>false</externalId>
        <label>UserName</label>
        <length>80</length>
        <required>true</required>
        <type>Text</type>
        <unique>false</unique>
    </fields>
    <indexes>
        <fullName>Content_Download_Index</fullName>
        <fields>
            <name>Timestamp__c</name>
            <sortDirection>DESC</sortDirection>
        </fields>
        <fields>
            <name>UserId__c</name>
            <sortDirection>ASC</sortDirection>
        </fields>
        <fields>
            <name>ContentDocumentVersionId__c</name>
            <sortDirection>ASC</sortDirection>
        </fields>
        <label>Content Download Index</label>
    </indexes>
    <label>Content Download</label>
    <pluralLabel>Content Downloads</pluralLabel>
</CustomObject>

ContentDownloadEvent.trigger:

trigger ContentDownloadEvent on ContentDownloadEvent__e (after insert) {
    List<ContentDownload__b> cds = new List<ContentDownload__b>();
    for(ContentDownloadEvent__e cde : Trigger.new) {
        ContentDownload__b cd = new ContentDownload__b();
        cd.UserId__c = cde.UserId__c;
        cd.UserName__c = cde.UserName__c;
        cd.ContentDocumentVersionId__c  = cde.ContentDocumentVersionId__c;
        cd.ContentDocumentTitle__c = cde.ContentDocumentTitle__c;
        cd.ContentDocumentFileType__c = cde.ContentDocumentFileType__c;
        cd.SObjectType__c = cde.SObjectType__c;
        cd.RecordId__c = cde.RecordId__c;
        cd.Timestamp__c = cde.Timestamp__c;
        cds.add(cd);
    }
    database.insertImmediate(cds);
}

That’s all folks

You can of course go more narrow or wide on the types of files you’re interested in, we’re leaning towards tracking all files just to see how much is downloaded and then maybe go narrower when we have a good sample of what is actually going on.

I was really happy to find this feature since it solved my problem with a minimal piece of code and it doesn’t care about where the user accesses the file. Also I was not looking forward to trying to prohibit the user from using the regular user interface which is even better.

Of course going with Platform Events and Big Objects is optional but I like the idea of separating the download from the logging.

Cheers, Johan