Skip to content

Commit

Permalink
Entity dictionary auto-scan for security checks and lifecycle hooks. (#…
Browse files Browse the repository at this point in the history
…1108)

* Added logic to scan classpath for elide checks and lifecycle hooks

* Added unit tests

* Added spring and standalone integration tests

* Minor cleanup

* Fixed legit codacy issues

* Inspection rework

* Minor cleanup

* Fixed bug where we check for the wrong duplicate class

* Fixing GraphQL Logging on ForbiddenAccessExceptions to match JSON-API (#1109)

* Fixed checkstyle for multiple copyrights.  Removed Hook annotation (it will be added in a different way in Elide 5).
  • Loading branch information
aklish committed Dec 18, 2019
1 parent 2b41c13 commit c5de4be
Show file tree
Hide file tree
Showing 22 changed files with 414 additions and 110 deletions.
1 change: 1 addition & 0 deletions checkstyle-style.xml
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@
<metadata name="net.sf.eclipsecs.core.comment" value="Checks that the file starts with a yahoo/apache copyright block."/>
<property name="severity" value="error"/>
<property name="headerFile" value="java.header"/>
<property name="multiLines" value="2"/>
<property name="fileExtensions" value="java,groovy,g4"/>
</module>
<module name="RegexpHeader">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright 2018, the original author or authors.
* Copyright 2019, Yahoo Inc.
* Licensed under the Apache License, Version 2.0
* See LICENSE file in project root for terms.
*/
package com.yahoo.elide.annotation;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* A convenience annotation that help you register elide check.
* <br><br>
* Example: <br>
* <pre>
* <code>@SecurityCheck("i am an expression")</code>
* public static class{@literal Inline<Post>} extends{@literal OperationCheck<Post>} {
* <code>@Override</code>
* public boolean ok(Post object, RequestScope requestScope,
* {@literal Optional<ChangeSpec>} changeSpec) {
* return false;
* }
* }
* </pre>
*
* <b>NOTE: </b> The class you annotated must be a {@link com.yahoo.elide.security.checks.Check},
* otherwise a RuntimeException is thrown.
*
* @author olOwOlo
*
* This class is based on https://github.com/illyasviel/elide-spring-boot/blob/master
* /elide-spring-boot-autoconfigure/src/main/java/org/illyasviel/elide/spring/boot/annotation/ElideCheck.java
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
public @interface SecurityCheck {

/**
* The expression which will be used for
* {@link com.yahoo.elide.annotation.ReadPermission#expression()},
* {@link com.yahoo.elide.annotation.UpdatePermission#expression()},
* {@link com.yahoo.elide.annotation.CreatePermission#expression()},
* {@link com.yahoo.elide.annotation.DeletePermission#expression()}.
* @return The expression you want to defined.
*/
String value();
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@
import java.util.Optional;

/**
* Function which will be invoked for Elide lifecycle triggers
* Function which will be invoked for Elide lifecycle triggers.
* @param <T> The elide entity type associated with this callback.
*/
@FunctionalInterface
public interface LifeCycleHook<T> {
/**
* Run for a lifecycle event
* Run for a lifecycle event.
* @param elideEntity The entity that triggered the event
* @param requestScope The request scope
* @param changes Optionally, the changes that were made to the entity
Expand Down
7 changes: 4 additions & 3 deletions elide-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,11 @@
<artifactId>jansi</artifactId>
<version>1.14</version>
</dependency>

<dependency>
<groupId>org.reflections</groupId>
<artifactId>reflections</artifactId>
<version>0.9.11</version>
<groupId>io.github.classgraph</groupId>
<artifactId>classgraph</artifactId>
<version>4.4.12</version>
</dependency>

<dependency>
Expand Down
20 changes: 17 additions & 3 deletions elide-core/src/main/java/com/yahoo/elide/Injector.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,29 @@
package com.yahoo.elide;

/**
* Used to inject all beans at time of construction.
* Abstraction around dependency injection.
*/
@FunctionalInterface
public interface Injector {

/**
* Inject an entity bean.
* Inject an elide object.
*
* @param entity Entity bean to inject
* @param entity object to inject
*/
void inject(Object entity);

/**
* Instantiates a new instance of a class using the DI framework.
*
* @param cls The class to instantiate.
* @return An instance of the class.
*/
default <T> T instantiate(Class<T> cls) {
try {
return cls.newInstance();
} catch (InstantiationException | IllegalAccessException e) {
throw new IllegalStateException(e);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/*
* Copyright 2016, Yahoo Inc.
* Copyright 2018, Yahoo Inc.
* Copyright 2018, the original author or authors.
* Licensed under the Apache License, Version 2.0
* See LICENSE file in project root for terms.
*/
Expand All @@ -13,6 +14,7 @@
import com.yahoo.elide.annotation.Exclude;
import com.yahoo.elide.annotation.Include;
import com.yahoo.elide.annotation.MappedInterface;
import com.yahoo.elide.annotation.SecurityCheck;
import com.yahoo.elide.annotation.SharePermission;
import com.yahoo.elide.core.exceptions.HttpStatusException;
import com.yahoo.elide.core.exceptions.InternalServerErrorException;
Expand All @@ -23,6 +25,7 @@
import com.yahoo.elide.security.checks.prefab.Collections.RemoveOnly;
import com.yahoo.elide.security.checks.prefab.Common;
import com.yahoo.elide.security.checks.prefab.Role;
import com.yahoo.elide.utils.ClassScanner;
import com.yahoo.elide.utils.coerce.CoerceUtil;

import com.google.common.collect.BiMap;
Expand Down Expand Up @@ -831,18 +834,18 @@ public boolean isShareable(Class<?> entityClass) {
* @param cls Entity bean class
*/
public void bindEntity(Class<?> cls) {
if (isClassBound(cls)) {
//Ignore duplicate bindings.
return;
}

Class<?> declaredClass = lookupIncludeClass(cls);

if (declaredClass == null) {
log.trace("Missing include or excluded class {}", cls.getName());
return;
}

if (isClassBound(declaredClass)) {
//Ignore duplicate bindings.
return;
}

Include include = (Include) getFirstAnnotation(declaredClass, Arrays.asList(Include.class));
Entity entity = (Entity) getFirstAnnotation(declaredClass, Arrays.asList(Entity.class));

Expand Down Expand Up @@ -1185,6 +1188,27 @@ public boolean isAttribute(Class<?> entityClass, String attributeName) {
return getEntityBinding(entityClass).attributes.contains(attributeName);
}

/**
* Scan for security checks and automatically bind them to the dictionary.
*/
public void scanForSecurityChecks() {

// Logic is based on https://github.com/illyasviel/elide-spring-boot/blob/master
// /elide-spring-boot-autoconfigure/src/main/java/org/illyasviel/elide
// /spring/boot/autoconfigure/ElideAutoConfiguration.java

for (Class<?> cls : ClassScanner.getAnnotatedClasses(SecurityCheck.class)) {
if (Check.class.isAssignableFrom(cls)) {
SecurityCheck securityCheckMeta = cls.getAnnotation(SecurityCheck.class);
log.debug("Register Elide Check [{}] with expression [{}]",
cls.getCanonicalName(), securityCheckMeta.value());
checkNames.put(securityCheckMeta.value(), cls.asSubclass(Check.class));
} else {
throw new IllegalStateException("Class annotated with SecurityCheck is not a Check");
}
}
}

/**
* Binds a lifecycle hook to a particular field or method in an entity. The hook will be called a
* single time per request per field READ, CREATE, or UPDATE.
Expand Down
49 changes: 27 additions & 22 deletions elide-core/src/main/java/com/yahoo/elide/utils/ClassScanner.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,13 @@
*/
package com.yahoo.elide.utils;

import org.reflections.Reflections;
import org.reflections.scanners.SubTypesScanner;
import org.reflections.scanners.TypeAnnotationsScanner;
import org.reflections.util.ClasspathHelper;
import org.reflections.util.ConfigurationBuilder;
import org.reflections.util.FilterBuilder;
import io.github.classgraph.ClassGraph;
import io.github.classgraph.ClassInfo;
import io.github.classgraph.ScanResult;

import java.lang.annotation.Annotation;
import java.util.Set;
import java.util.stream.Collectors;

/**
* Scans a package for classes by looking at files in the classpath.
Expand All @@ -38,15 +36,25 @@ static public Set<Class<?>> getAnnotatedClasses(Package toScan, Class<? extends
* @return The classes
*/
static public Set<Class<?>> getAnnotatedClasses(String packageName, Class<? extends Annotation> annotation) {
ConfigurationBuilder configurationBuilder = new ConfigurationBuilder();

configurationBuilder.addUrls(ClasspathHelper.forPackage(packageName));
configurationBuilder.setScanners(new SubTypesScanner(), new TypeAnnotationsScanner());
configurationBuilder.filterInputsBy(new FilterBuilder().include(FilterBuilder.prefix(packageName)));

Reflections reflections = new Reflections(configurationBuilder);
try (ScanResult scanResult = new ClassGraph().enableAllInfo().whitelistPackages(packageName).scan()) {
return scanResult.getClassesWithAnnotation(annotation.getCanonicalName()).stream()
.map((ClassInfo::loadClass))
.collect(Collectors.toSet());
}
}

return reflections.getTypesAnnotatedWith(annotation, true);
/**
* Scans all classes accessible from the context class loader which belong to the current class loader.
*
* @param annotation Annotation to search
* @return The classes
*/
static public Set<Class<?>> getAnnotatedClasses(Class<? extends Annotation> annotation) {
try (ScanResult scanResult = new ClassGraph().enableAllInfo().scan()) {
return scanResult.getClassesWithAnnotation(annotation.getCanonicalName()).stream()
.map((ClassInfo::loadClass))
.collect(Collectors.toSet());
}
}

/**
Expand All @@ -55,13 +63,10 @@ static public Set<Class<?>> getAnnotatedClasses(String packageName, Class<? exte
* @return All the classes within a package.
*/
static public Set<Class<?>> getAllClasses(String packageName) {
ConfigurationBuilder configurationBuilder = new ConfigurationBuilder();

configurationBuilder.addUrls(ClasspathHelper.forPackage(packageName));
configurationBuilder.setScanners(new SubTypesScanner(false));
configurationBuilder.filterInputsBy(new FilterBuilder().include(FilterBuilder.prefix(packageName)));

Reflections reflections = new Reflections(configurationBuilder);
return reflections.getSubTypesOf(Object.class);
try (ScanResult scanResult = new ClassGraph().enableAllInfo().whitelistPackages(packageName).scan()) {
return scanResult.getAllClasses().stream()
.map((ClassInfo::loadClass))
.collect(Collectors.toSet());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
import com.yahoo.elide.annotation.MappedInterface;
import com.yahoo.elide.annotation.OnUpdatePreSecurity;
import com.yahoo.elide.annotation.ReadPermission;
import com.yahoo.elide.annotation.SecurityCheck;
import com.yahoo.elide.functions.LifeCycleHook;
import com.yahoo.elide.models.generics.Employee;
import com.yahoo.elide.models.generics.Manager;
import com.yahoo.elide.security.checks.UserCheck;
import com.yahoo.elide.security.checks.prefab.Collections.AppendOnly;
import com.yahoo.elide.security.checks.prefab.Collections.RemoveOnly;
import com.yahoo.elide.security.checks.prefab.Common.UpdateOnCreate;
Expand All @@ -41,6 +43,7 @@
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -88,6 +91,24 @@ public void testFindCheckByExpression() {
assertEquals("Prefab.Common.UpdateOnCreate", getCheckIdentifier(UpdateOnCreate.class));
}

@Test
public void testCheckScan() {

@SecurityCheck("User is Admin")
class Foo extends UserCheck {

@Override
public boolean ok(com.yahoo.elide.security.User user) {
return false;
}
}

EntityDictionary testDictionary = new EntityDictionary(new HashMap<>());
testDictionary.scanForSecurityChecks();

assertEquals("User is Admin", testDictionary.getCheckIdentifier(Foo.class));
}

@Test
public void testGetAttributeOrRelationAnnotation() {
String[] fields = {"field1", "field2", "field3", "relation1", "relation2"};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ public void testGetAllClasses() {
@Test
public void testGetAnnotatedClasses() {
Set<Class<?>> classes = ClassScanner.getAnnotatedClasses("example", ReadPermission.class);
assertEquals(6, classes.size());
for (Class<?> cls : classes) {
assertTrue(cls.isAnnotationPresent(ReadPermission.class));
}
}

@Test
public void testGetAllAnnotatedClasses() {
Set<Class<?>> classes = ClassScanner.getAnnotatedClasses(ReadPermission.class);
assertEquals(20, classes.size());
for (Class<?> cls : classes) {
assertTrue(cls.isAnnotationPresent(ReadPermission.class));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,7 @@
import com.yahoo.elide.core.filter.expression.FilterExpression;
import com.yahoo.elide.core.pagination.Pagination;
import com.yahoo.elide.core.sort.Sorting;

import org.reflections.Reflections;
import org.reflections.scanners.SubTypesScanner;
import org.reflections.scanners.TypeAnnotationsScanner;
import org.reflections.util.ClasspathHelper;
import org.reflections.util.ConfigurationBuilder;
import com.yahoo.elide.utils.ClassScanner;

import java.io.IOException;
import java.io.Serializable;
Expand All @@ -36,13 +31,7 @@ public TestDataStore(Package beanPackage) {

@Override
public void populateEntityDictionary(EntityDictionary dictionary) {
Reflections reflections = new Reflections(new ConfigurationBuilder()
.addUrls(ClasspathHelper.forPackage(beanPackage.getName()))
.setScanners(new SubTypesScanner(), new TypeAnnotationsScanner()));
reflections.getTypesAnnotatedWith(Entity.class).stream()
.filter(entityAnnotatedClass -> entityAnnotatedClass.getPackage().getName()
.startsWith(beanPackage.getName()))
.forEach(dictionary::bindEntity);
ClassScanner.getAnnotatedClasses(beanPackage, Entity.class).stream().forEach(dictionary::bindEntity);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import com.yahoo.elide.core.ErrorObjects;
import com.yahoo.elide.core.HttpStatus;
import com.yahoo.elide.core.exceptions.CustomErrorException;
import com.yahoo.elide.core.exceptions.ForbiddenAccessException;
import com.yahoo.elide.core.exceptions.HttpStatusException;
import com.yahoo.elide.core.exceptions.InvalidEntityBodyException;
import com.yahoo.elide.core.exceptions.TransactionException;
Expand Down Expand Up @@ -216,7 +217,13 @@ private ElideResponse executeGraphQLRequest(ObjectMapper mapper, Object principa
.responseCode(e.getResponse().getStatus())
.body(e.getResponse().getEntity().toString()).build();
} catch (HttpStatusException e) {
log.debug("Caught HTTP status exception {}", e.getStatus(), e);
if (e instanceof ForbiddenAccessException) {
if (log.isDebugEnabled()) {
log.debug("{}", ((ForbiddenAccessException) e).getLoggedMessage());
}
} else {
log.debug("Caught HTTP status exception {}", e.getStatus(), e);
}
return buildErrorResponse(new HttpStatusException(200, "") {
@Override
public int getStatus() {
Expand Down

0 comments on commit c5de4be

Please sign in to comment.