-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
6 changed files
with
322 additions
and
5 deletions.
There are no files selected for viewing
130 changes: 130 additions & 0 deletions
130
gemma-core/src/main/java/ubic/gemma/core/security/jackson/SecuredFieldModule.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,130 @@ | ||
package ubic.gemma.core.security.jackson; | ||
|
||
import com.fasterxml.jackson.core.JsonGenerator; | ||
import com.fasterxml.jackson.core.Version; | ||
import com.fasterxml.jackson.databind.*; | ||
import com.fasterxml.jackson.databind.ser.BeanSerializerModifier; | ||
import com.fasterxml.jackson.databind.ser.ContextualSerializer; | ||
import gemma.gsec.model.Securable; | ||
import lombok.extern.apachecommons.CommonsLog; | ||
import org.springframework.security.access.AccessDecisionManager; | ||
import org.springframework.security.access.AccessDeniedException; | ||
import org.springframework.security.access.ConfigAttribute; | ||
import org.springframework.security.access.SecurityConfig; | ||
import org.springframework.security.core.Authentication; | ||
import org.springframework.security.core.context.SecurityContextHolder; | ||
import ubic.gemma.model.annotations.SecuredField; | ||
|
||
import java.io.IOException; | ||
import java.util.Arrays; | ||
import java.util.List; | ||
import java.util.stream.Collectors; | ||
|
||
/** | ||
* Jackson module that registers a special serializer to handle {@link SecuredField} annotations. | ||
* @see SecuredField | ||
* @author poirigui | ||
*/ | ||
@CommonsLog | ||
public class SecuredFieldModule extends Module { | ||
|
||
private static final String SECURABLE_OWNER_ATTRIBUTE = "_securable_owner"; | ||
|
||
private final AccessDecisionManager accessDecisionManager; | ||
|
||
public SecuredFieldModule( AccessDecisionManager accessDecisionManager ) { | ||
this.accessDecisionManager = accessDecisionManager; | ||
} | ||
|
||
@Override | ||
public String getModuleName() { | ||
return SecuredFieldModule.class.getName(); | ||
} | ||
|
||
@Override | ||
public Version version() { | ||
return Version.unknownVersion(); | ||
} | ||
|
||
@Override | ||
public void setupModule( SetupContext context ) { | ||
context.addBeanSerializerModifier( new BeanSerializerModifier() { | ||
@Override | ||
public JsonSerializer<?> modifySerializer( SerializationConfig config, BeanDescription beanDesc, JsonSerializer<?> serializer ) { | ||
//noinspection unchecked | ||
return new SecuredFieldSerializer( ( JsonSerializer<Object> ) serializer, accessDecisionManager ); | ||
} | ||
} ); | ||
} | ||
|
||
/** | ||
* Jackson serializer for fields annotated with {@link ubic.gemma.model.annotations.SecuredField}. | ||
* @see ubic.gemma.model.annotations.SecuredField | ||
* @author poirigui | ||
*/ | ||
private static class SecuredFieldSerializer extends JsonSerializer<Object> implements ContextualSerializer { | ||
|
||
private final JsonSerializer<Object> fallbackSerializer; | ||
private final AccessDecisionManager accessDecisionManager; | ||
|
||
public SecuredFieldSerializer( JsonSerializer<Object> fallbackSerializer, AccessDecisionManager accessDecisionManager ) { | ||
this.fallbackSerializer = fallbackSerializer; | ||
this.accessDecisionManager = accessDecisionManager; | ||
} | ||
|
||
@Override | ||
public JsonSerializer<?> createContextual( SerializerProvider prov, BeanProperty property ) { | ||
return new JsonSerializer<Object>() { | ||
@Override | ||
public void serialize( Object value, JsonGenerator generator, SerializerProvider provider ) throws IOException { | ||
if ( value instanceof Securable ) { | ||
Object previousValue = provider.getAttribute( SECURABLE_OWNER_ATTRIBUTE ); | ||
try { | ||
provider.setAttribute( SECURABLE_OWNER_ATTRIBUTE, value ); | ||
fallbackSerializer.serialize( value, generator, provider ); | ||
} finally { | ||
provider.setAttribute( SECURABLE_OWNER_ATTRIBUTE, previousValue ); | ||
} | ||
return; | ||
} | ||
if ( property == null || property.getAnnotation( SecuredField.class ) == null ) { | ||
fallbackSerializer.serialize( value, generator, provider ); | ||
return; | ||
} | ||
SecuredField securedField = property.getAnnotation( SecuredField.class ); | ||
List<ConfigAttribute> configAttributes = Arrays.stream( securedField.value() ) | ||
.map( SecurityConfig::new ) | ||
.collect( Collectors.toList() ); | ||
Securable owner; | ||
if ( provider.getAttribute( SECURABLE_OWNER_ATTRIBUTE ) instanceof Securable ) { | ||
owner = ( Securable ) provider.getAttribute( SECURABLE_OWNER_ATTRIBUTE ); | ||
} else { | ||
owner = null; | ||
} | ||
try { | ||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); | ||
// lookup any Securable entity | ||
accessDecisionManager.decide( authentication, owner, configAttributes ); | ||
provider.defaultSerializeValue( value, generator ); | ||
} catch ( AccessDeniedException e ) { | ||
log.trace( String.format( "Not authorized to access %s, the field will be omitted", value ) ); | ||
switch ( securedField.policy() ) { | ||
case OMIT: | ||
break; | ||
case SET_NULL: | ||
provider.defaultSerializeNull( generator ); | ||
break; | ||
case RAISE_EXCEPTION: | ||
throw e; | ||
} | ||
} | ||
} | ||
}; | ||
} | ||
|
||
@Override | ||
public void serialize( Object value, JsonGenerator gen, SerializerProvider serializers ) throws IOException { | ||
// handled in createContextual() above | ||
} | ||
} | ||
} |
42 changes: 42 additions & 0 deletions
42
gemma-core/src/main/java/ubic/gemma/model/annotations/SecuredField.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
package ubic.gemma.model.annotations; | ||
|
||
import org.springframework.security.access.annotation.Secured; | ||
|
||
import java.lang.annotation.ElementType; | ||
import java.lang.annotation.Retention; | ||
import java.lang.annotation.RetentionPolicy; | ||
import java.lang.annotation.Target; | ||
|
||
/** | ||
* Represents a {@link Secured} field. | ||
* @author poirigui | ||
* @see Secured | ||
*/ | ||
@Target({ ElementType.METHOD, ElementType.FIELD }) | ||
@Retention(RetentionPolicy.RUNTIME) | ||
public @interface SecuredField { | ||
/** | ||
* List of configuration attributes. | ||
*/ | ||
String[] value(); | ||
|
||
/** | ||
* What to do when the user is not authorized to access the field. | ||
*/ | ||
Policy policy() default Policy.SET_NULL; | ||
|
||
enum Policy { | ||
/** | ||
* Omit the value from serialization. | ||
*/ | ||
OMIT, | ||
/** | ||
* Set the value to NULL. | ||
*/ | ||
SET_NULL, | ||
/** | ||
* Raise an {@link org.springframework.security.access.AccessDeniedException} exception. | ||
*/ | ||
RAISE_EXCEPTION | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
132 changes: 132 additions & 0 deletions
132
gemma-core/src/test/java/ubic/gemma/core/security/jackson/SecuredFieldModuleTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
package ubic.gemma.core.security.jackson; | ||
|
||
import com.fasterxml.jackson.core.JsonProcessingException; | ||
import com.fasterxml.jackson.databind.ObjectMapper; | ||
import gemma.gsec.model.Securable; | ||
import org.junit.After; | ||
import org.junit.Before; | ||
import org.junit.Test; | ||
import org.mockito.ArgumentCaptor; | ||
import org.springframework.beans.factory.annotation.Autowired; | ||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Configuration; | ||
import org.springframework.security.access.AccessDecisionManager; | ||
import org.springframework.security.access.AccessDeniedException; | ||
import org.springframework.security.access.ConfigAttribute; | ||
import org.springframework.security.test.context.support.WithMockUser; | ||
import org.springframework.test.context.ContextConfiguration; | ||
import org.springframework.test.context.junit4.AbstractJUnit4SpringContextTests; | ||
import ubic.gemma.model.annotations.SecuredField; | ||
import ubic.gemma.model.common.auditAndSecurity.curation.AbstractCuratableValueObject; | ||
import ubic.gemma.model.expression.experiment.ExpressionExperimentValueObject; | ||
import ubic.gemma.persistence.util.TestComponent; | ||
|
||
import java.util.Collection; | ||
|
||
import static org.assertj.core.api.Assertions.assertThat; | ||
import static org.mockito.Mockito.*; | ||
|
||
@ContextConfiguration | ||
public class SecuredFieldModuleTest extends AbstractJUnit4SpringContextTests { | ||
|
||
@Configuration | ||
@TestComponent | ||
static class SecuredJsonSerializerTestContextConfiguration { | ||
|
||
@Bean | ||
public ObjectMapper objectMapper( AccessDecisionManager accessDecisionManager ) { | ||
return new ObjectMapper() | ||
.registerModule( new SecuredFieldModule( accessDecisionManager ) ); | ||
} | ||
|
||
@Bean | ||
public AccessDecisionManager accessDecisionManager() { | ||
return mock( AccessDecisionManager.class ); | ||
} | ||
} | ||
|
||
@Autowired | ||
private ObjectMapper objectMapper; | ||
|
||
@Autowired | ||
private AccessDecisionManager accessDecisionManager; | ||
|
||
private AbstractCuratableValueObject<?> curatableVo; | ||
|
||
@Before | ||
public void setUp() { | ||
curatableVo = new ExpressionExperimentValueObject(); | ||
curatableVo.setCurationNote( "Reserved for curators" ); | ||
} | ||
|
||
@After | ||
public void tearDown() { | ||
reset( accessDecisionManager ); | ||
} | ||
|
||
@Test | ||
@WithMockUser(authorities = "GROUP_ADMIN") | ||
public void testCuratable() throws JsonProcessingException { | ||
//noinspection unchecked | ||
ArgumentCaptor<Collection<ConfigAttribute>> captor = ArgumentCaptor.forClass( Collection.class ); | ||
doNothing() | ||
.when( accessDecisionManager ) | ||
.decide( any(), any(), captor.capture() ); | ||
assertThat( objectMapper.writeValueAsString( curatableVo ) ).contains( "Reserved for curators" ); | ||
verify( accessDecisionManager, atLeastOnce() ).decide( any(), same( curatableVo ), anyCollection() ); | ||
assertThat( captor.getValue() ).anySatisfy( ca -> assertThat( ca.getAttribute() ).isEqualTo( "GROUP_ADMIN" ) ); | ||
} | ||
|
||
@Test | ||
@WithMockUser(authorities = "GROUP_USER") | ||
public void testCuratableAsNonAdmin() throws JsonProcessingException { | ||
doThrow( AccessDeniedException.class ) | ||
.when( accessDecisionManager ) | ||
.decide( any(), any(), anyCollection() ); | ||
assertThat( objectMapper.writeValueAsString( curatableVo ) ) | ||
.doesNotContain( "Reserved for curators" ); | ||
} | ||
|
||
@Test | ||
@WithMockUser(authorities = "IS_AUTHENTICATED_ANONYMOUSLY") | ||
public void testCuratableAsAnonymous() throws JsonProcessingException { | ||
doThrow( AccessDeniedException.class ) | ||
.when( accessDecisionManager ) | ||
.decide( any(), any(), anyCollection() ); | ||
assertThat( objectMapper.writeValueAsString( curatableVo ) ) | ||
.doesNotContain( "Reserved for curators" ); | ||
} | ||
|
||
static class Entity implements Securable { | ||
private Long id; | ||
@SecuredField({ "GROUP_ADMIN" }) | ||
private String foo; | ||
private Entity nestedEntity; | ||
|
||
@Override | ||
public Long getId() { | ||
return id; | ||
} | ||
|
||
public String getFoo() { | ||
return foo; | ||
} | ||
|
||
public Entity getNestedEntity() { | ||
return nestedEntity; | ||
} | ||
} | ||
|
||
@Test | ||
public void testSecuredFieldInASecurableEntity() throws JsonProcessingException { | ||
Entity entity = new Entity(); | ||
entity.id = 1L; | ||
entity.foo = "test"; | ||
entity.nestedEntity = new Entity(); | ||
entity.nestedEntity.id = 2L; | ||
entity.nestedEntity.foo = "test"; | ||
objectMapper.writeValueAsString( entity ); | ||
verify( accessDecisionManager ).decide( any(), same( entity ), anyCollection() ); | ||
verify( accessDecisionManager ).decide( any(), same( entity.nestedEntity ), anyCollection() ); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters