Skip to content

Commit

Permalink
Add support for obfuscated id or alternate entity id (#3261)
Browse files Browse the repository at this point in the history
* Support use of an alternate entity identifier not the primary key

* Add id obfuscation

* Adjust counts

* Add test for BytesEncryptorIdObfuscator
  • Loading branch information
justin-tay authored Aug 11, 2024
1 parent 22387ac commit 8643a2f
Show file tree
Hide file tree
Showing 34 changed files with 1,304 additions and 35 deletions.
39 changes: 39 additions & 0 deletions elide-core/src/main/java/com/yahoo/elide/annotation/EntityId.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright 2024, the original author or authors.
* Licensed under the Apache License, Version 2.0
* See LICENSE file in project root for terms.
*/
package com.yahoo.elide.annotation;

import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;

/**
* Indicates the field or property that represents the identity of the entity.
* <p>
* This is intended to be used if the primary key undesirably leaks information
* about the record and to designate another field or property as the entity
* identifier.
* <p>
* Another option instead of using another field or column as the entity
* identifier is to obfuscate the id directly.
* <p>
* The following are some things to consider when choosing the entity id.
* <ul>
* <li>Opaque token</li>
* <li>Cryptographically secure</li>
* <li>Implementation only based on Randomness</li>
* <li>Predictability of the ID</li>
* <li>Leaks the count of items</li>
* <li>Leaks information about the machine/process</li>
* <li>Leaks the date of creation</li>
* </ul>
*/
@Target({ METHOD, FIELD })
@Retention(RUNTIME)
public @interface EntityId {
}
4 changes: 2 additions & 2 deletions elide-core/src/main/java/com/yahoo/elide/core/Path.java
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,8 @@ protected PathElement resolvePathAttribute(Type<?> entityClass,
String alias,
Set<Argument> arguments,
EntityDictionary dictionary) {
if (dictionary.isAttribute(entityClass, fieldName)
|| fieldName.equals(dictionary.getIdFieldName(entityClass))) {
if (dictionary.isAttribute(entityClass, fieldName) || fieldName.equals(dictionary.getIdFieldName(entityClass))
|| fieldName.equals(dictionary.getEntityIdFieldName(entityClass))) {
Type<?> attributeClass = dictionary.getType(entityClass, fieldName);
return new PathElement(entityClass, attributeClass, fieldName, alias, arguments);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import com.yahoo.elide.core.exceptions.InvalidAttributeException;
import com.yahoo.elide.core.exceptions.InvalidEntityBodyException;
import com.yahoo.elide.core.exceptions.InvalidObjectIdentifierException;
import com.yahoo.elide.core.exceptions.InvalidValueException;
import com.yahoo.elide.core.filter.expression.AndFilterExpression;
import com.yahoo.elide.core.filter.expression.FilterExpression;
import com.yahoo.elide.core.filter.predicates.InPredicate;
Expand All @@ -43,6 +44,7 @@
import com.yahoo.elide.core.request.Pagination;
import com.yahoo.elide.core.request.Sorting;
import com.yahoo.elide.core.security.ChangeSpec;
import com.yahoo.elide.core.security.obfuscation.IdObfuscator;
import com.yahoo.elide.core.security.permissions.ExpressionResult;
import com.yahoo.elide.core.security.visitors.CanPaginateVisitor;
import com.yahoo.elide.core.type.ClassType;
Expand Down Expand Up @@ -250,14 +252,33 @@ public static <T> PersistentResource<T> loadRecord(
// try to load object
Optional<FilterExpression> permissionFilter = getPermissionFilterExpression(loadClass,
requestScope, projection.getRequestedFields());
Type<?> idType = dictionary.getIdType(loadClass);

projection = projection
.copyOf()
.filterExpression(permissionFilter.orElse(null))
.build();
Serializable idOrEntityId;
Type<?> entityIdType = dictionary.getEntityIdType(loadClass);
if (entityIdType != null) {
// If it is by entity id use it
idOrEntityId = (Serializable) CoerceUtil.coerce(id, entityIdType);
} else {
Type<?> idType = dictionary.getIdType(loadClass);
IdObfuscator idObfuscator = dictionary.getIdObfuscator();
if (idObfuscator != null) {
// If an obfuscator is present use it to deobfuscate the id
try {
idOrEntityId = (Serializable) idObfuscator.deobfuscate(id, idType);
} catch (RuntimeException e) {
throw new InvalidValueException(
"Invalid identifier " + id + " for " + dictionary.getJsonAliasFor(loadClass), e);
}
} else {
idOrEntityId = (Serializable) CoerceUtil.coerce(id, idType);
}
}

obj = tx.loadObject(projection, (Serializable) CoerceUtil.coerce(id, idType), requestScope);
obj = tx.loadObject(projection, idOrEntityId, requestScope);
if (obj == null) {
throw new InvalidObjectIdentifierException(id, dictionary.getJsonAliasFor(loadClass));
}
Expand Down Expand Up @@ -408,12 +429,20 @@ private static FilterExpression buildIdFilterExpression(List<String> ids,
Type<?> entityType,
EntityDictionary dictionary,
RequestScope scope) {
Type<?> idType = dictionary.getIdType(entityType);
String idField = dictionary.getIdFieldName(entityType);

Type<?> entityIdType = dictionary.getEntityIdType(entityType);
Type<?> idType;
String idField;
if (entityIdType != null) {
idType = entityIdType;
idField = dictionary.getEntityIdFieldName(entityType);
} else {
idType = dictionary.getIdType(entityType);
idField = dictionary.getIdFieldName(entityType);
}
IdObfuscator idObfuscator = entityIdType != null ? null : dictionary.getIdObfuscator();
List<Object> coercedIds = ids.stream()
.filter(id -> scope.getObjectById(entityType, id) == null) // these don't exist yet
.map(id -> CoerceUtil.coerce(id, idType))
.map(id -> idObfuscator == null ? CoerceUtil.coerce(id, idType) : idObfuscator.deobfuscate(id, idType))
.collect(Collectors.toList());

/* construct a new SQL like filter expression, eg: book.id IN [1,2] */
Expand Down Expand Up @@ -522,6 +551,10 @@ private static void assignId(PersistentResource persistentResource, String id) {
"No id provided, cannot persist " + persistentResource.getTypeName());
}
}
// Set the entity id if necessary
if (StringUtils.isNotEmpty(id)) {
persistentResource.setEntityId(id);
}
}

private static <T> T firstOrNullIfEmpty(final Collection<T> coll) {
Expand Down Expand Up @@ -1084,6 +1117,15 @@ public boolean isIdGenerated() {
return dictionary.getEntityBinding(type).isIdGenerated();
}

/**
* Set entity ID.
*
* @param entityId entity id
*/
public void setEntityId(String entityId) {
dictionary.setEntityId(obj, entityId);
}

/**
* Gets UUID.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,24 +111,35 @@ default <T> T createNewObject(Type<T> entityClass, RequestScope scope) {
* some legacy stores are optimized to load by ID.
*
* @param entityProjection the collection to load.
* @param id - the ID of the object to load.
* @param idOrEntityId - the ID of the object to load.
* @param scope - the current request scope
* @param <T> The model type being loaded.
* It is optional for the data store to attempt evaluation.
* @return the loaded object if it exists AND any provided security filters pass.
*/
default <T> T loadObject(EntityProjection entityProjection,
Serializable id,
Serializable idOrEntityId,
RequestScope scope) {
Type<?> entityClass = entityProjection.getType();
FilterExpression filterExpression = entityProjection.getFilterExpression();

EntityDictionary dictionary = scope.getDictionary();
Type idType = dictionary.getIdType(entityClass);
String idField = dictionary.getIdFieldName(entityClass);
Type<?> idType;
String idField;
Type<?> entityIdType = dictionary.getEntityIdType(entityClass);
if (entityIdType != null) {
// by entity id
idType = entityIdType;
idField = dictionary.getEntityIdFieldName(entityClass);
} else {
// by id
idType = dictionary.getIdType(entityClass);
idField = dictionary.getIdFieldName(entityClass);
}

FilterExpression idFilter = new InPredicate(
new Path.PathElement(entityClass, idType, idField),
id
idOrEntityId
);
FilterExpression joinedFilterExpression = (filterExpression != null)
? new AndFilterExpression(idFilter, filterExpression)
Expand All @@ -147,7 +158,8 @@ default <T> T loadObject(EntityProjection entityProjection,
}

//Multiple objects with the same ID.
throw new InvalidObjectIdentifierException(id.toString(), dictionary.getJsonAliasFor(entityClass));
throw new InvalidObjectIdentifierException(idOrEntityId.toString(),
dictionary.getJsonAliasFor(entityClass));
}
return null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ public void createObject(Object entity, RequestScope scope) {
id = dictionary.getId(entity);
}

String entityIdFieldName = dictionary.getEntityIdFieldName(entityClass);
if (entityIdFieldName != null) {
id = dictionary.getId(entity);
}

replicateOperationToParent(entity, Operation.OpType.CREATE);
operations.add(new Operation(id, entity, EntityDictionary.getType(entity), Operation.OpType.CREATE));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import com.yahoo.elide.annotation.ComputedAttribute;
import com.yahoo.elide.annotation.ComputedRelationship;
import com.yahoo.elide.annotation.EntityId;
import com.yahoo.elide.annotation.Exclude;
import com.yahoo.elide.annotation.LifeCycleHookBinding;
import com.yahoo.elide.annotation.LifeCycleHookBinding.Operation;
Expand Down Expand Up @@ -86,10 +87,16 @@ public class EntityBinding {
@Getter
private AccessibleObject idField;
@Getter
private AccessibleObject entityIdField;
@Getter
private String idFieldName;
@Getter
private String entityIdFieldName;
@Getter
private Type<?> idType;
@Getter
private Type<?> entityIdType;
@Getter
private AccessType accessType;

@Getter
Expand Down Expand Up @@ -302,6 +309,8 @@ private void bindEntityFields(Type<?> cls, String type,

if (isIdField(fieldOrMethod)) {
bindEntityId(cls, type, fieldOrMethod);
} else if (isEntityIdField(fieldOrMethod)) {
bindEntityEntityId(cls, type, fieldOrMethod);
} else if (fieldOrMethod.isAnnotationPresent(Transient.class)
&& !fieldOrMethod.isAnnotationPresent(ComputedAttribute.class)
&& !fieldOrMethod.isAnnotationPresent(ComputedRelationship.class)) {
Expand Down Expand Up @@ -354,6 +363,32 @@ private void bindEntityId(Type<?> cls, String type, AccessibleObject fieldOrMeth
}
}

/**
* Bind an entity id field to an entity.
*
* @param cls Class type to bind fields
* @param type JSON API type identifier
* @param fieldOrMethod Field or method to bind
*/
private void bindEntityEntityId(Type<?> cls, String type, AccessibleObject fieldOrMethod) {
String fieldName = getFieldName(fieldOrMethod);
Type<?> fieldType = getFieldType(cls, fieldOrMethod);

//Add id field to type map for the entity
fieldsToTypes.put(fieldName, fieldType);

//Set id field, type, and name
entityIdField = fieldOrMethod;
entityIdType = fieldType;
entityIdFieldName = fieldName;

fieldsToValues.put(fieldName, fieldOrMethod);

if (entityIdField != null && !fieldOrMethod.equals(entityIdField)) {
throw new DuplicateMappingException(type + " " + cls.getName() + ":" + fieldName);
}
}

/**
* Convert a deque to a list.
*
Expand Down Expand Up @@ -758,4 +793,14 @@ public Set<ArgumentType> getEntityArguments() {
public static boolean isIdField(AccessibleObject field) {
return (field.isAnnotationPresent(Id.class) || field.isAnnotationPresent(EmbeddedId.class));
}

/**
* Returns if the field is the entity id.
*
* @param field the field to test
* @return true if it is the entity id
*/
public static boolean isEntityIdField(AccessibleObject field) {
return (field.isAnnotationPresent(EntityId.class));
}
}
Loading

0 comments on commit 8643a2f

Please sign in to comment.