Skip to content

Commit acbb2f9

Browse files
committed
Add support for secured fields
1 parent 9160f33 commit acbb2f9

File tree

6 files changed

+322
-5
lines changed

6 files changed

+322
-5
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package ubic.gemma.core.security.jackson;
2+
3+
import com.fasterxml.jackson.core.JsonGenerator;
4+
import com.fasterxml.jackson.core.Version;
5+
import com.fasterxml.jackson.databind.*;
6+
import com.fasterxml.jackson.databind.ser.BeanSerializerModifier;
7+
import com.fasterxml.jackson.databind.ser.ContextualSerializer;
8+
import gemma.gsec.model.Securable;
9+
import lombok.extern.apachecommons.CommonsLog;
10+
import org.springframework.security.access.AccessDecisionManager;
11+
import org.springframework.security.access.AccessDeniedException;
12+
import org.springframework.security.access.ConfigAttribute;
13+
import org.springframework.security.access.SecurityConfig;
14+
import org.springframework.security.core.Authentication;
15+
import org.springframework.security.core.context.SecurityContextHolder;
16+
import ubic.gemma.model.annotations.SecuredField;
17+
18+
import java.io.IOException;
19+
import java.util.Arrays;
20+
import java.util.List;
21+
import java.util.stream.Collectors;
22+
23+
/**
24+
* Jackson module that registers a special serializer to handle {@link SecuredField} annotations.
25+
* @see SecuredField
26+
* @author poirigui
27+
*/
28+
@CommonsLog
29+
public class SecuredFieldModule extends Module {
30+
31+
private static final String SECURABLE_OWNER_ATTRIBUTE = "_securable_owner";
32+
33+
private final AccessDecisionManager accessDecisionManager;
34+
35+
public SecuredFieldModule( AccessDecisionManager accessDecisionManager ) {
36+
this.accessDecisionManager = accessDecisionManager;
37+
}
38+
39+
@Override
40+
public String getModuleName() {
41+
return SecuredFieldModule.class.getName();
42+
}
43+
44+
@Override
45+
public Version version() {
46+
return Version.unknownVersion();
47+
}
48+
49+
@Override
50+
public void setupModule( SetupContext context ) {
51+
context.addBeanSerializerModifier( new BeanSerializerModifier() {
52+
@Override
53+
public JsonSerializer<?> modifySerializer( SerializationConfig config, BeanDescription beanDesc, JsonSerializer<?> serializer ) {
54+
//noinspection unchecked
55+
return new SecuredFieldSerializer( ( JsonSerializer<Object> ) serializer, accessDecisionManager );
56+
}
57+
} );
58+
}
59+
60+
/**
61+
* Jackson serializer for fields annotated with {@link ubic.gemma.model.annotations.SecuredField}.
62+
* @see ubic.gemma.model.annotations.SecuredField
63+
* @author poirigui
64+
*/
65+
private static class SecuredFieldSerializer extends JsonSerializer<Object> implements ContextualSerializer {
66+
67+
private final JsonSerializer<Object> fallbackSerializer;
68+
private final AccessDecisionManager accessDecisionManager;
69+
70+
public SecuredFieldSerializer( JsonSerializer<Object> fallbackSerializer, AccessDecisionManager accessDecisionManager ) {
71+
this.fallbackSerializer = fallbackSerializer;
72+
this.accessDecisionManager = accessDecisionManager;
73+
}
74+
75+
@Override
76+
public JsonSerializer<?> createContextual( SerializerProvider prov, BeanProperty property ) {
77+
return new JsonSerializer<Object>() {
78+
@Override
79+
public void serialize( Object value, JsonGenerator generator, SerializerProvider provider ) throws IOException {
80+
if ( value instanceof Securable ) {
81+
Object previousValue = provider.getAttribute( SECURABLE_OWNER_ATTRIBUTE );
82+
try {
83+
provider.setAttribute( SECURABLE_OWNER_ATTRIBUTE, value );
84+
fallbackSerializer.serialize( value, generator, provider );
85+
} finally {
86+
provider.setAttribute( SECURABLE_OWNER_ATTRIBUTE, previousValue );
87+
}
88+
return;
89+
}
90+
if ( property == null || property.getAnnotation( SecuredField.class ) == null ) {
91+
fallbackSerializer.serialize( value, generator, provider );
92+
return;
93+
}
94+
SecuredField securedField = property.getAnnotation( SecuredField.class );
95+
List<ConfigAttribute> configAttributes = Arrays.stream( securedField.value() )
96+
.map( SecurityConfig::new )
97+
.collect( Collectors.toList() );
98+
Securable owner;
99+
if ( provider.getAttribute( SECURABLE_OWNER_ATTRIBUTE ) instanceof Securable ) {
100+
owner = ( Securable ) provider.getAttribute( SECURABLE_OWNER_ATTRIBUTE );
101+
} else {
102+
owner = null;
103+
}
104+
try {
105+
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
106+
// lookup any Securable entity
107+
accessDecisionManager.decide( authentication, owner, configAttributes );
108+
provider.defaultSerializeValue( value, generator );
109+
} catch ( AccessDeniedException e ) {
110+
log.trace( String.format( "Not authorized to access %s, the field will be omitted", value ) );
111+
switch ( securedField.policy() ) {
112+
case OMIT:
113+
break;
114+
case SET_NULL:
115+
provider.defaultSerializeNull( generator );
116+
break;
117+
case RAISE_EXCEPTION:
118+
throw e;
119+
}
120+
}
121+
}
122+
};
123+
}
124+
125+
@Override
126+
public void serialize( Object value, JsonGenerator gen, SerializerProvider serializers ) throws IOException {
127+
// handled in createContextual() above
128+
}
129+
}
130+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package ubic.gemma.model.annotations;
2+
3+
import org.springframework.security.access.annotation.Secured;
4+
5+
import java.lang.annotation.ElementType;
6+
import java.lang.annotation.Retention;
7+
import java.lang.annotation.RetentionPolicy;
8+
import java.lang.annotation.Target;
9+
10+
/**
11+
* Represents a {@link Secured} field.
12+
* @author poirigui
13+
* @see Secured
14+
*/
15+
@Target({ ElementType.METHOD, ElementType.FIELD })
16+
@Retention(RetentionPolicy.RUNTIME)
17+
public @interface SecuredField {
18+
/**
19+
* List of configuration attributes.
20+
*/
21+
String[] value();
22+
23+
/**
24+
* What to do when the user is not authorized to access the field.
25+
*/
26+
Policy policy() default Policy.SET_NULL;
27+
28+
enum Policy {
29+
/**
30+
* Omit the value from serialization.
31+
*/
32+
OMIT,
33+
/**
34+
* Set the value to NULL.
35+
*/
36+
SET_NULL,
37+
/**
38+
* Raise an {@link org.springframework.security.access.AccessDeniedException} exception.
39+
*/
40+
RAISE_EXCEPTION
41+
}
42+
}

gemma-core/src/main/java/ubic/gemma/model/common/auditAndSecurity/curation/AbstractCuratableValueObject.java

+8
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import lombok.extern.apachecommons.CommonsLog;
55
import org.apache.commons.text.StringEscapeUtils;
66
import ubic.gemma.model.IdentifiableValueObject;
7+
import ubic.gemma.model.annotations.SecuredField;
78
import ubic.gemma.model.common.auditAndSecurity.AuditEventValueObject;
89

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

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

23+
@SecuredField("GROUP_ADMIN")
2224
private Date lastUpdated;
25+
@SecuredField("GROUP_ADMIN")
2326
private Boolean troubled = false;
27+
@SecuredField("GROUP_ADMIN")
2428
private AuditEventValueObject lastTroubledEvent;
29+
@SecuredField("GROUP_ADMIN")
2530
private Boolean needsAttention = false;
31+
@SecuredField("GROUP_ADMIN")
2632
private AuditEventValueObject lastNeedsAttentionEvent;
33+
@SecuredField("GROUP_ADMIN")
2734
private String curationNote;
35+
@SecuredField("GROUP_ADMIN")
2836
private AuditEventValueObject lastNoteUpdateEvent;
2937

3038
/**

gemma-core/src/main/java/ubic/gemma/model/expression/experiment/ExpressionExperimentValueObject.java

+5-4
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
import gemma.gsec.model.Securable;
99
import gemma.gsec.model.SecureValueObject;
1010
import gemma.gsec.util.SecurityUtil;
11-
import lombok.*;
11+
import lombok.Getter;
12+
import lombok.Setter;
1213
import org.hibernate.Hibernate;
1314
import ubic.gemma.model.annotations.GemmaWebOnly;
1415
import ubic.gemma.model.common.auditAndSecurity.curation.AbstractCuratableValueObject;
@@ -25,7 +26,7 @@ public class ExpressionExperimentValueObject extends AbstractCuratableValueObjec
2526
implements SecureValueObject {
2627

2728
private static final long serialVersionUID = -6861385216096602508L;
28-
protected Integer numberOfBioAssays;
29+
protected int numberOfBioAssays;
2930
protected String description;
3031
protected String name;
3132

@@ -46,9 +47,9 @@ public class ExpressionExperimentValueObject extends AbstractCuratableValueObjec
4647
private String externalUri;
4748
private GeeqValueObject geeq;
4849
@JsonIgnore
49-
private Boolean isPublic = false;
50+
private boolean isPublic = false;
5051
@JsonIgnore
51-
private Boolean isShared = false;
52+
private boolean isShared = false;
5253
private String metadata;
5354
@JsonProperty("numberOfProcessedExpressionVectors")
5455
private Integer processedExpressionVectorCount;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package ubic.gemma.core.security.jackson;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import gemma.gsec.model.Securable;
6+
import org.junit.After;
7+
import org.junit.Before;
8+
import org.junit.Test;
9+
import org.mockito.ArgumentCaptor;
10+
import org.springframework.beans.factory.annotation.Autowired;
11+
import org.springframework.context.annotation.Bean;
12+
import org.springframework.context.annotation.Configuration;
13+
import org.springframework.security.access.AccessDecisionManager;
14+
import org.springframework.security.access.AccessDeniedException;
15+
import org.springframework.security.access.ConfigAttribute;
16+
import org.springframework.security.test.context.support.WithMockUser;
17+
import org.springframework.test.context.ContextConfiguration;
18+
import org.springframework.test.context.junit4.AbstractJUnit4SpringContextTests;
19+
import ubic.gemma.model.annotations.SecuredField;
20+
import ubic.gemma.model.common.auditAndSecurity.curation.AbstractCuratableValueObject;
21+
import ubic.gemma.model.expression.experiment.ExpressionExperimentValueObject;
22+
import ubic.gemma.persistence.util.TestComponent;
23+
24+
import java.util.Collection;
25+
26+
import static org.assertj.core.api.Assertions.assertThat;
27+
import static org.mockito.Mockito.*;
28+
29+
@ContextConfiguration
30+
public class SecuredFieldModuleTest extends AbstractJUnit4SpringContextTests {
31+
32+
@Configuration
33+
@TestComponent
34+
static class SecuredJsonSerializerTestContextConfiguration {
35+
36+
@Bean
37+
public ObjectMapper objectMapper( AccessDecisionManager accessDecisionManager ) {
38+
return new ObjectMapper()
39+
.registerModule( new SecuredFieldModule( accessDecisionManager ) );
40+
}
41+
42+
@Bean
43+
public AccessDecisionManager accessDecisionManager() {
44+
return mock( AccessDecisionManager.class );
45+
}
46+
}
47+
48+
@Autowired
49+
private ObjectMapper objectMapper;
50+
51+
@Autowired
52+
private AccessDecisionManager accessDecisionManager;
53+
54+
private AbstractCuratableValueObject<?> curatableVo;
55+
56+
@Before
57+
public void setUp() {
58+
curatableVo = new ExpressionExperimentValueObject();
59+
curatableVo.setCurationNote( "Reserved for curators" );
60+
}
61+
62+
@After
63+
public void tearDown() {
64+
reset( accessDecisionManager );
65+
}
66+
67+
@Test
68+
@WithMockUser(authorities = "GROUP_ADMIN")
69+
public void testCuratable() throws JsonProcessingException {
70+
//noinspection unchecked
71+
ArgumentCaptor<Collection<ConfigAttribute>> captor = ArgumentCaptor.forClass( Collection.class );
72+
doNothing()
73+
.when( accessDecisionManager )
74+
.decide( any(), any(), captor.capture() );
75+
assertThat( objectMapper.writeValueAsString( curatableVo ) ).contains( "Reserved for curators" );
76+
verify( accessDecisionManager, atLeastOnce() ).decide( any(), same( curatableVo ), anyCollection() );
77+
assertThat( captor.getValue() ).anySatisfy( ca -> assertThat( ca.getAttribute() ).isEqualTo( "GROUP_ADMIN" ) );
78+
}
79+
80+
@Test
81+
@WithMockUser(authorities = "GROUP_USER")
82+
public void testCuratableAsNonAdmin() throws JsonProcessingException {
83+
doThrow( AccessDeniedException.class )
84+
.when( accessDecisionManager )
85+
.decide( any(), any(), anyCollection() );
86+
assertThat( objectMapper.writeValueAsString( curatableVo ) )
87+
.doesNotContain( "Reserved for curators" );
88+
}
89+
90+
@Test
91+
@WithMockUser(authorities = "IS_AUTHENTICATED_ANONYMOUSLY")
92+
public void testCuratableAsAnonymous() throws JsonProcessingException {
93+
doThrow( AccessDeniedException.class )
94+
.when( accessDecisionManager )
95+
.decide( any(), any(), anyCollection() );
96+
assertThat( objectMapper.writeValueAsString( curatableVo ) )
97+
.doesNotContain( "Reserved for curators" );
98+
}
99+
100+
static class Entity implements Securable {
101+
private Long id;
102+
@SecuredField({ "GROUP_ADMIN" })
103+
private String foo;
104+
private Entity nestedEntity;
105+
106+
@Override
107+
public Long getId() {
108+
return id;
109+
}
110+
111+
public String getFoo() {
112+
return foo;
113+
}
114+
115+
public Entity getNestedEntity() {
116+
return nestedEntity;
117+
}
118+
}
119+
120+
@Test
121+
public void testSecuredFieldInASecurableEntity() throws JsonProcessingException {
122+
Entity entity = new Entity();
123+
entity.id = 1L;
124+
entity.foo = "test";
125+
entity.nestedEntity = new Entity();
126+
entity.nestedEntity.id = 2L;
127+
entity.nestedEntity.foo = "test";
128+
objectMapper.writeValueAsString( entity );
129+
verify( accessDecisionManager ).decide( any(), same( entity ), anyCollection() );
130+
verify( accessDecisionManager ).decide( any(), same( entity.nestedEntity ), anyCollection() );
131+
}
132+
}

gemma-rest/src/main/java/ubic/gemma/rest/util/JacksonConfig.java

+5-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import io.swagger.v3.core.util.Json;
66
import org.springframework.context.annotation.Bean;
77
import org.springframework.context.annotation.Configuration;
8+
import org.springframework.security.access.AccessDecisionManager;
9+
import ubic.gemma.core.security.jackson.SecuredFieldModule;
810
import ubic.gemma.rest.swagger.resolver.CustomModelResolver;
911

1012
/**
@@ -21,8 +23,10 @@ public class JacksonConfig {
2123
* @see ubic.gemma.rest.providers.ObjectMapperResolver
2224
*/
2325
@Bean
24-
public ObjectMapper objectMapper() {
26+
public ObjectMapper objectMapper( AccessDecisionManager accessDecisionManager ) {
2527
return new ObjectMapper()
28+
// handles @SecuredField annotations
29+
.registerModule( new SecuredFieldModule( accessDecisionManager ) )
2630
// parse and render date as ISO 9601
2731
.setDateFormat( new StdDateFormat() );
2832
}

0 commit comments

Comments
 (0)