GDPR: Right to be Forgotten and Data Portability

Enforcement of the General Data Protection Regulation (GDPR) began May 25, 2018. Companies processing the personal data of EU residents must adopt appropriate measures to protect their user data. A wealth of available resources discuss the implications of the regulation and the steps companies must take to ensure compliance.

The goal of this article is to help you tackle two of GDPR’s technically challenging requirements, the right to data portability and the right to be forgotten on Liferay Portal 6.2. For the upcoming release of @product@ 7.1, this blog post describes new features to more easily tackle these requirements. These same features are planned for @product@ version 7.0 in a future service pack release following the release of @product@ version 7.1. Until then, the principles and code described here equally apply to versions 6.2 and 7.0. Version 7.1 will have better native support for complying with GDPR. Consider upgrading to leverage those features (and more, of course).

The code supplied here demonstrates an approach you can use when developing two particularly difficult requirements of your full GDPR solution. It’s ultimately your responsibility to assess your own organization’s usage of personal data and the work required to comply fully with GDPR.

The code referenced here could be deployed via the scripting console or compiled in a web plugin. It’s up to you. A sample web plugin containing this code can be referenced here.

Right to Data Portability

The right to data portability requires organizations to provide a machine-readable export of a user’s personal data upon request.

There’s no easy way through the UI to export a given user’s personal data on Liferay Portal 6.2. One straightforward way to fulfill this requirement programmatically for applications built using Service Builder is to leverage Actionable Dynamic Queries.

This example exports all of a user’s blogs created using Liferay’s Blogs app to a JSON-formatted file. It starts by creating an instance of BlogsEntryActionableDynamicQuery and overriding the addCriteria method to grab all BlogEntries that match the user by userId or statusByUserId. This returns all blogs where the user is the original author or the last editor.

ActionableDynamicQuery actionableDynamicQuery = new BlogsEntryActionableDynamicQuery() {

    @Override
    protected void addCriteria(DynamicQuery dynamicQuery) {
        Criterion criterion = RestrictionsFactoryUtil.or(
            RestrictionsFactoryUtil.eq("userId", userId),
            RestrictionsFactoryUtil.eq("statusByUserId", userId));

            dynamicQuery.add(criterion);
    }

    @Override
    protected void performAction(Object object) throws PortalException, SystemException {
        // do something
    }
};

Next it overrides performAction to write all matching BlogEntries to a JSON array. It uses the FasterXML Jackson library to JSONify output, as it provides powerful customization features.

// the path of our export file
Path path = Files.createTempFile("export_blogsEntries", ".json");

JsonFactory jsonFactory = new JsonFactory();

final JsonGenerator jsonGenerator = jsonFactory.createGenerator(path.toFile(), JsonEncoding.UTF8);

// begin the JSON array
jsonGenerator.writeStartObject();
jsonGenerator.writeFieldName(BlogsEntry.class.getName());
jsonGenerator.writeStartArray();

final ObjectMapper objectMapper = new ObjectMapper();

// avoid exception when Jackson can't serialize a bean
objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);

ActionableDynamicQuery actionableDynamicQuery = new BlogsEntryActionableDynamicQuery() {
    @Override
    protected void addCriteria(DynamicQuery dynamicQuery) {
        Criterion criterion = RestrictionsFactoryUtil.or(
            RestrictionsFactoryUtil.eq("userId", userId),
            RestrictionsFactoryUtil.eq("statusByUserId", userId));

        dynamicQuery.add(criterion);
    }

    @Override
    protected void performAction(Object object) throws PortalException, SystemException {
        try {
            objectMapper.writeValue(jsonGenerator, object);
        }
        catch (IOException e) {
            e.printStackTrace();
        }
    }
};

actionableDynamicQuery.performActions();

// end the JSON array
jsonGenerator.writeEndArray();
jsonGenerator.writeEndObject();
jsonGenerator.close();

This naive implementation serializes all fields for BlogsEntries including system data like groupId, allowPingbacks, status, etc. The is formatted like this:

{
    "com.liferay.portlet.blogs.model.BlogsEntry": [
        {
            "visible": true,
            "inTrashExplicitly": false,
            "smallImageType": null,
            "content": "<p>Hello everyone! ...</p>",
            "cachedModel": false,
            "description": "",
            "new": false,
            ...
        }    
    ]
}

Since a user’s personal data is all that must be exported, we leverage Jackson’s filters to specify the right fields:

// specify the field names we want to export
Set<String> exportFields = new HashSet<String>(
    Arrays.asList("entryId", "title", "subtitle", "description", "content"));

filterProvider.addFilter(
    "blogsEntriesFilter", new SimpleBeanPropertyFilter.FilterExceptFilter(exportFields));

JacksonAnnotationIntrospector jacksonAnnotationIntrospector =
    new JacksonAnnotationIntrospector() {

    @Override
    public Object findFilterId(Annotated annotated) {
        return "blogsEntriesFilter";
    }
};

objectMapper.setAnnotationIntrospector(jacksonAnnotationIntrospector);

final ObjectWriter objectWriter = objectMapper.writer(filterProvider);

ActionableDynamicQuery actionableDynamicQuery = new BlogsEntryActionableDynamicQuery() {
    ...

    @Override
    protected void performAction(Object object) throws PortalException, SystemException {
        try {
            objectWriter.writeValue(jsonGenerator, object);
        }
        catch (IOException e) {
            e.printStackTrace();
        }
    }
};

Now the exported data is cleaner and only contains user-inputted personal data.

{
    "com.liferay.portlet.blogs.model.BlogsEntry": [
        {
            "content": "<p>Hello everyone! ...</p>",
            "description": "",
            "title": "Introducing Dennis",
            "entryId": 11217
        }
    ]
}

As this code is essentially boilerplate for all of Liferay’s service generated classes, you can generalize this solution using reflection.

Right to be Forgotten

The right to erasure (also known as the “right to be forgotten”) requires organizations to erase a user’s personal data upon request (excluding data the organization has a legitimate reason to retain like financial records, public interest data, etc).

Erasing the data

Unfortunately, simply deleting the User object by calling UserLocalServiceUtil.deleteUser(User user) does not sufficiently fulfill this requirement as content created by the user is understandably not deleted. Fortunately, we can leverage the same logic in the Data Portability code example to locate and delete a user’s personal data. Continuing with the BlogsEntries example, you can simply delete any blog where the userId or statusByUserId matches.

ActionableDynamicQuery actionableDynamicQuery = new BlogsEntryActionableDynamicQuery() {
    @Override
    protected void addCriteria(DynamicQuery dynamicQuery) {
        Criterion criterion = RestrictionsFactoryUtil.or(
            RestrictionsFactoryUtil.eq("userId", userId),
            RestrictionsFactoryUtil.eq("statusByUserId", userId));

        dynamicQuery.add(criterion);
    }

    @Override
    protected void performAction(Object object) throws PortalException, SystemException {
        BlogsEntry blogsEntry = (BlogsEntry)object;
        
        BlogsEntryLocalServiceUtil.deleteEntry(blogsEntry);
    }
};

actionableDynamicQuery.performActions();

Anonymizing the data

But what if blogs contain valuable data we want to keep? Perhaps a community member wrote a blog post on DE 7.0 upgrade tips but now requests to be forgotten. In many cases, businesses want to anonymize personal data rather than delete it. Anonymization requires data to be scrubbed such that it “does not relate to an identified or identifiable natural person” or in “such a manner that the data subject is not or no longer identifiable” (recital 26).

This means at the very least you must alter the userName field so the user no longer appears as the author or editor of the blog. But proper anonymization requires more than just replacing names, email addresses, and other personally identifiable data. The blog’s content must be reviewed and sanitized. For example, a blog post on DE 7.0 upgrade tips might begin with an anecdotal introduction like, “I’ve seen some horror stories in my ten years as the IT director of Gamora Inc …” Even though this sentence doesn’t include any contact details, it’s easy to identify the author via a quick social media search. Unfortunately, this means proper anonymization can potentially be an extremely cumbersome process. Imagine reviewing the thousands of forum posts an active member may have posted. Ultimately, whether and to what extent a business must judiciously review and edit every word, image, and detail during anonymization must be evaluated by each business. The scale, sensitivity, likelihood of identification and so on must be weighed, reasoned, and documented.

For our example, say you want to review the data first. One approach is to export the personal data first using the code from the “data portability” section and review the blog title, subtitle, content, etc. Various strategies can be used to search for certain contact details (like user alias, email addresses, phone numbers, etc). The content can then be updated either programmatically or through Liferay’s web interface.

After the content has been satisfactorily sanitized, you can replace the userId and userName using the same ActionableDynamicQuery logic above. Before doing so, you should create an actual User object populated with anonymous details. This makes it easier to identify all data in the future that’s been anonymized.

// assume this user has already been created
final User anonymousUser = UserLocalServiceUtil.getUser(anonymousUserId);

final String anonymousUserName = anonymousUser.getFullName();

ActionableDynamicQuery actionableDynamicQuery = new BlogsEntryActionableDynamicQuery() {
    @Override
    protected void addCriteria(DynamicQuery dynamicQuery) {
        Criterion criterion = RestrictionsFactoryUtil.or(
            RestrictionsFactoryUtil.eq("userId", userId),
            RestrictionsFactoryUtil.eq("statusByUserId", userId));

        dynamicQuery.add(criterion);
    }

    @Override
    protected void performAction(Object object) throws PortalException, SystemException {
        BlogsEntry blogsEntry = (BlogsEntry)object;

        if (blogsEntry.getUserId() == userId) {
            blogsEntry.setUserId(anonymousUserId);
            blogsEntry.setUserName(anonymousUserName);
        }
        
        if (blogsEntry.getStatusByUserId() == userId) {
            blogsEntry.setStatusByUserId(anonymousUserId);
            blogsEntry.setStatusByUserName(anonymousUserName);
        }

        BlogsEntryLocalServiceUtil.updateBlogsEntry(blogsEntry);
    }
};

actionableDynamicQuery.performActions();

Again, this solution is mostly boilerplate code and can be generalized to data built using Service Builder.


header-id: conclusion

Conclusion

These examples demonstrate how to leverage Liferay’s existing APIs to address GDPR’s new standard for personal data protection. Ultimately, true compliance involves much more than technical measures; it requires re-thinking, re-evaluating, and raising the standards of data protection for most modern organizations.

« Building and Testing Your Plugin's PACL
Was this article helpful?
0 out of 0 found this helpful