Hiding field internals from clients with Drupal’s json:api

3 minute read

At Meet Kinksters, I take data security very seriously. Dating sites are tricky to architect, in that they must collect significant personal information (birthdates, personal verification data, locations) but only reveal certain aspects of that data to others. It’s inappropriate to share users’ birthdates with one another, but showing their age is fine (and necessary!) The same goes with location data. You may live in the south side of the Bay Area and only want to match with those within a small radius of you (those commutes!) but others should only see “San Francisco” on your profile.

Beyond security, there are implementation details of our internal systems that would be “too much” to expose to the front-end. Or, as is the case with birthdates, there’s no need to send this information “back” to the user; it’s sufficient to demonstrate knowledge of your age. This helps us do more computation on the back-end, as well as reduce the risk of sensitive information being cached or otherwise mishandled in transit or in a user-agent.

Drupal’s json:api Extras module provides pluggable “field enhancer” functionality which is well suited to this challenge. While we can outright restrict access to fields which need not be retrieved or altered over the API, field enhancers allow us to alter data as it is normalized in and out of Drupal.

Consider a field enhancer that allows storing a birthdate, but shows only metadata about the date when viewed:

/**
 * Perform additional manipulations to date/time fields.
 *
 * @ResourceFieldEnhancer(
 *   id = "date_time_age",
 *   label = @Translation("Date Time (Age)"),
 *   description = @Translation("Formats a date to age in years."),
 *   dependencies = {"datetime"}
 * )
 */
class DateTimeAgeEnhancer extends ResourceFieldEnhancerBase {

  /**
   * {@inheritdoc}
   */
  protected function doUndoTransform($data, Context $context) {
    $date = new DrupalDateTime($data);
    $now = new DrupalDateTime();
    return [
      'meta' => [
        'age' => $date->diff($now)->y,
        'note' => "'value' key redacted.",
      ],
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function getOutputJsonSchema() {
    return [
      'type' => 'object',
    ];
  }

  /**
   * {@inheritdoc}
   */
  protected function doTransform($data, Context $context) {
    unset($data['meta']);
    // Pass through.
    return $data;
  }
}

Note that we use the meta key here inside of the attribute’s value, but unlike relationship fields, this is not a reserved word in this context.

Per the spec:

Attributes may contain any valid JSON value.

Complex data structures involving JSON objects and arrays are allowed as attribute values. However, any object that constitutes or is contained in an attribute MUST NOT contain a relationships or links member, as those members are reserved by this specification for future use.

To ensure we do not let meta information bleed back into Drupal’s Field API when normalizing data from json:api write operations, we unset() this key before passing the value along.

Finally, consider a case where we merely wish to restrict viewing certain field properties, e.g., latitudes and longitudes from a field storing location data:

/**
 * Restrict certain data from display on the front-end.
 *
 * @ResourceFieldEnhancer(
 *   id = "geofield",
 *   label = @Translation("Geofield"),
 *   description = @Translation("Restricts viewing of certain geofield data."),
 *   dependencies = {"geofield"}
 * )
 */
final class GeofieldPlacenameEnhancer extends ResourceFieldEnhancerBase {

  /**
   * {@inheritDoc}
   */
  protected function doTransform($data, Context $context) {
    // Pass through.
    return $data;
  }

  /**
   * {@inheritDoc}
   */
  protected function doUndoTransform($data, Context $context) {
    // Only reveal certain data when fetching.
    return array_intersect_key($data, array_flip(['name', 'placeholder_id']));
  }

  /**
   * {@inheritDoc}
   */
  public function getOutputJsonSchema() {
    return [
      'type' => 'object',
    ];
  }

}

Here be dragons

Finally, take special note that this is not a replacement for properly configuring field or entity access. Rather, this approach allows you to have your cake and eat it, too, when fields must be both accessible to end-users while hiding certain internals.

In our birthdate example, this means that the attribute as returned to the client cannot be posted back as-is (that is, the returned document cannot be used for a follow-up, idempotent transaction). Drupal will always return a 200 OK response after updating attribute data, containing the filtered data from the enhancer. If your API is intended for public consumption, care should be taken to document the values which may be sent to update the field.

Updated: