Since both methods return the same type UserDto
, they'll be of the same type in the GraphQL schema as well. To completely remove a field in only one of the cases, you'd have to map UserDto
to 2 distinct types based on the context. Early versions of SPQR did support this out-of-the-box, but it proved painfully difficult to maintain both in user projects and in the library itself and was thus removed.
That said, it is always possible to customize SPQR to your heart's content. This is the central tenet behind its design. So there are still ways to achieve the behavior you're after. One way is to leave the types as they are, and conditionally remove the values of the sensitive fields on the fly. But it is also possible to have distinct types and remove the sensitive fields from the schema altogether. Here's a list of ideas (that I haven't tested fully, so you might have to add some exception handling etc):
Sanitizing the values at runtime
1. Using permissions and Spring Security alone
If it is your userRead
and userSelect
permissions that capture the ability to see birthday
field, you could do something like this:
@GraphQLQuery
@PreAuthorize("hasAuthority('userRead')")
public LocalDate birthday(@GraphQLContext UserDto user) {
return user.getBirthday();
}
Now this method will be called to resolve User.birthday
instead of the getter or field on UserDto
, that would normally be used. And this method can be protected by Spring Security normally, as it is on a managed bean (unlike the getter/field).
2. Using an interceptor
If the permissions do not solve your problem, you could introduce a custom annotation like @Sanitize("birthday = null")
and an interceptor (ResolverInterceptor
) that post-processes the result before returning it to the user.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Sanitize {
String value();
}
public class Sanitizer implements ResolverInterceptor, ResolverInterceptorFactory {
private static final SpelExpressionParser parser = new SpelExpressionParser();
@Override
public Object aroundInvoke(InvocationContext context, Continuation continuation) throws Exception {
Object result = continuation.proceed(context);
String exp = context.getResolver().getTypedElement().getAnnotation(Sanitize.class).value();
SpelExpression expr = parser.parseRaw(exp);
if (result instanceof Collection) {
Collection c = (Collection) result;
c.forEach(i -> expr.getValue(i, null));
} else {
expr.getValue(result, null);
}
return result;
}
@Override
public List<ResolverInterceptor> getInterceptors(ResolverInterceptorFactoryParams params) {
if (params.getResolver().getTypedElement().isAnnotationPresent(Sanitize.class)) {
return Collections.singletonList(this);
}
return Collections.emptyList();
}
}
@Bean
public ExtensionProvider<GeneratorConfiguration, ResolverInterceptorFactory> interceptors() {
return (config, interceptors) -> interceptors.append(new Sanitizer());
}
You can now do things like:
@GraphQLQuery(name = "getAllUsers")
@PreAuthorize("hasAuthority('userSelect')")
@Sanitize(username = 'REDACTED') //Replace username with 'REDACTED'
@Sanitize(birthday = null) //Set birthday to null
public List<UserDto> getAllUsers() {...}
I added SpEL support here mostly for fun, but you can make it much simpler and just set null
for the given field name. Or even have hard-coded logic, and skip all the expression and reflection magic altogether.
3. Abusing Spring Security @PostFilter
You can achieve pretty much the same thing as above by abusing Spring Security's @PostFilter
by providing a custom MethodSecurityExpressionHandler
and implementing filter
so that it does what I did above. This is just a more convoluted version of my previous idea, but makes it independent of SPQR and GraphQL - it would apply even if you invoke the method via REST or anything else.
Having separate types per context
If removing/replacing the redacted values on the fly isn't enough, and you really want to remove the field itself from the schema, you could do something like:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE_USE)
public @interface Sanitize {
String typeName() default ""; //Or make this mandatory to keep it simple
String[] fields();
}
//Custom TypeMapper that produces types without the sensitive fields
public static class SanitizingTypeMapper extends ObjectTypeMapper {
@Override
protected List<GraphQLFieldDefinition> getFields(String typeName, AnnotatedType javaType, TypeMappingEnvironment env) {
List<GraphQLFieldDefinition> fields = super.getFields(typeName, javaType, env);
if (javaType.isAnnotationPresent(Sanitize.class)) {
Sanitize sanitize = javaType.getAnnotation(Sanitize.class);
Set<String> sanitized = new HashSet<>(Arrays.asList(sanitize.fields()));
return fields.stream()
.filter(field -> !sanitized.contains(field.getName()))
.collect(Collectors.toList());
}
return fields;
}
//Each sanitized type needs a unique name
//You can just return sanitize.typeName(), the complication is purely optional
@Override
protected String getTypeName(AnnotatedType type, BuildContext buildContext) {
if (type.isAnnotationPresent(Sanitize.class)) {
Sanitize sanitize = type.getAnnotation(Sanitize.class);
if (Utils.isNotEmpty(sanitize.typeName())) {
return sanitize.typeName();
}
String[] fields = sanitize.fields();
Arrays.sort(fields); //Important!
StringBuilder name = new StringBuilder("Sanitized");
for (String f : fields) {
name.append(Utils.capitalize(f));
}
name.append(super.getTypeName(type, buildContext));
return name.toString();
}
return super.getTypeName(type, buildContext);
}
}
@Bean
public ExtensionProvider<GeneratorConfiguration, TypeMapper> customMappers() {
return (config, mappers) -> mappers.replace(ObjectTypeMapper.class, __ -> new SanitizingTypeMapper());
}
With this, you can do:
@GraphQLQuery(name = "getAllUsers")
@PreAuthorize("hasAuthority('userSelect')")
public List<@Sanitize(fields = {"birthday", "location"}) UserDto> getAllUsers() {...}
This will generate a type SanitizedBirthdayLocationUser
and will map the result of getAllUsers
to that. To give the type a custom name (and make triple sure it really is unique or all hell breaks loose):
@GraphQLQuery(name = "getAllUsers")
@PreAuthorize("hasAuthority('userSelect')")
public List<@Sanitize(typeName = "UserLite", fields = {"birthday", "location"}) UserDto> getAllUsers() {...}
Notes
I left the separate types option at the bottom intentionally. While it may be the cleanest option when used sparingly, after all you have the type system working with you and it even requires less custom code than some of the other options (ignoring the entirely optional type name generation I added), if you have many such instances, your schema can quickly become overwhelming for the client to understand, and overwhelming for you to maintain. So as with everything in systems design — choose your trade-offs carefully.