Skip to content

Commit

Permalink
Add support for secured fields
Browse files Browse the repository at this point in the history
  • Loading branch information
arteymix committed Sep 7, 2023
1 parent 9160f33 commit acbb2f9
Show file tree
Hide file tree
Showing 6 changed files with 322 additions and 5 deletions.
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
}
}
}
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import lombok.extern.apachecommons.CommonsLog;
import org.apache.commons.text.StringEscapeUtils;
import ubic.gemma.model.IdentifiableValueObject;
import ubic.gemma.model.annotations.SecuredField;
import ubic.gemma.model.common.auditAndSecurity.AuditEventValueObject;

import java.util.Date;
Expand All @@ -19,12 +20,19 @@ public abstract class AbstractCuratableValueObject<C extends Curatable> extends

private static final String TROUBLE_DETAILS_NONE = "No trouble details provided.";

@SecuredField("GROUP_ADMIN")
private Date lastUpdated;
@SecuredField("GROUP_ADMIN")
private Boolean troubled = false;
@SecuredField("GROUP_ADMIN")
private AuditEventValueObject lastTroubledEvent;
@SecuredField("GROUP_ADMIN")
private Boolean needsAttention = false;
@SecuredField("GROUP_ADMIN")
private AuditEventValueObject lastNeedsAttentionEvent;
@SecuredField("GROUP_ADMIN")
private String curationNote;
@SecuredField("GROUP_ADMIN")
private AuditEventValueObject lastNoteUpdateEvent;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
import gemma.gsec.model.Securable;
import gemma.gsec.model.SecureValueObject;
import gemma.gsec.util.SecurityUtil;
import lombok.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.Hibernate;
import ubic.gemma.model.annotations.GemmaWebOnly;
import ubic.gemma.model.common.auditAndSecurity.curation.AbstractCuratableValueObject;
Expand All @@ -25,7 +26,7 @@ public class ExpressionExperimentValueObject extends AbstractCuratableValueObjec
implements SecureValueObject {

private static final long serialVersionUID = -6861385216096602508L;
protected Integer numberOfBioAssays;
protected int numberOfBioAssays;
protected String description;
protected String name;

Expand All @@ -46,9 +47,9 @@ public class ExpressionExperimentValueObject extends AbstractCuratableValueObjec
private String externalUri;
private GeeqValueObject geeq;
@JsonIgnore
private Boolean isPublic = false;
private boolean isPublic = false;
@JsonIgnore
private Boolean isShared = false;
private boolean isShared = false;
private String metadata;
@JsonProperty("numberOfProcessedExpressionVectors")
private Integer processedExpressionVectorCount;
Expand Down
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() );
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import io.swagger.v3.core.util.Json;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDecisionManager;
import ubic.gemma.core.security.jackson.SecuredFieldModule;
import ubic.gemma.rest.swagger.resolver.CustomModelResolver;

/**
Expand All @@ -21,8 +23,10 @@ public class JacksonConfig {
* @see ubic.gemma.rest.providers.ObjectMapperResolver
*/
@Bean
public ObjectMapper objectMapper() {
public ObjectMapper objectMapper( AccessDecisionManager accessDecisionManager ) {
return new ObjectMapper()
// handles @SecuredField annotations
.registerModule( new SecuredFieldModule( accessDecisionManager ) )
// parse and render date as ISO 9601
.setDateFormat( new StdDateFormat() );
}
Expand Down

0 comments on commit acbb2f9

Please sign in to comment.