Simple Field-Level Permission Control in Spring Boot 3.x
Field-level permission control provides fine-grained data access in Spring Boot applications, allowing read/write restrictions per object field; the article explains core concepts, compares annotation, AOP, serializer, and database approaches, and presents a complete implementation using Spring Boot 3.1, Java 17, Jackson, and Spring Security.
Field Permission Control Overview
Field permission control is a fine‑grained data‑access mechanism that restricts a user's read/write rights on specific fields of an object, addressing the limitation of traditional permission models that operate only at the interface or object level.
Core Concepts
Field‑level permission : controls visibility and editability of individual fields.
Dynamic filtering : filters fields at runtime based on user roles, context, etc.
Serialization control : governs field output during object serialization.
Implementation Approaches Comparison
Annotation‑driven : custom annotations mark fields that require permission checks.
AOP aspect : a Spring AOP aspect performs permission checks before and after method execution.
Serializer‑based : a custom Jackson serializer filters fields during JSON conversion.
Database‑level : field filtering is performed directly in SQL queries.
Technology Stack
Spring Boot 3.1.0
Java 17
Jackson 2.15+
Spring Security 6.1+
Spring AOP
Complete Code Implementation
Project Dependencies (pom.xml)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>field-permission-demo</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>Permission Annotations
// Field permission annotation
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface FieldPermission {
/** Allowed roles */
String[] roles() default {};
/** SpEL expression */
String expression() default "";
/** Permission type */
PermissionType type() default PermissionType.BOTH;
/** Alias for serialization */
String alias() default "";
}
// Permission type enum
public enum PermissionType {
READ, // read‑only
WRITE, // write‑only
BOTH // read‑write
}
// Class‑level permission annotation
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ClassPermission {
String[] roles() default {};
String expression() default "";
}Permission Context Management
@Component
public class SecurityContextHolder {
private static final ThreadLocal<UserContext> USER_CONTEXT = new ThreadLocal<>();
@Data
@AllArgsConstructor
public static class UserContext {
private Long userId;
private String username;
private Set<String> roles;
private Map<String, Object> attributes;
public boolean hasRole(String role) { return roles.contains(role); }
public boolean hasAnyRole(String... roles) { return Arrays.stream(roles).anyMatch(this.roles::contains); }
}
public static void setUserContext(UserContext userContext) { USER_CONTEXT.set(userContext); }
public static UserContext getUserContext() { return USER_CONTEXT.get(); }
public static void clear() { USER_CONTEXT.remove(); }
public static Set<String> getCurrentUserRoles() {
UserContext ctx = getUserContext();
return ctx != null ? ctx.getRoles() : Collections.emptySet();
}
public static boolean hasRole(String role) {
UserContext ctx = getUserContext();
return ctx != null && ctx.hasRole(role);
}
}Custom Jackson Serializer
/** Field permission serializer */
public class FieldPermissionSerializer extends JsonSerializer<Object> {
@Override
public void serialize(Object value, JsonGenerator gen, SerializerProvider provider) throws IOException {
BeanPropertyWriter writer = (BeanPropertyWriter) gen.getOutputContext().getCurrentValue();
if (hasFieldPermission(writer)) {
gen.writeObject(value);
}
// otherwise field is omitted
}
private boolean hasFieldPermission(BeanPropertyWriter writer) {
Field field = getFieldFromWriter(writer);
if (field == null) return true;
FieldPermission permission = field.getAnnotation(FieldPermission.class);
if (permission == null) return true;
return checkPermission(permission);
}
private boolean checkPermission(FieldPermission permission) {
Set<String> userRoles = SecurityContextHolder.getCurrentUserRoles();
if (permission.roles().length > 0) {
boolean hasRole = Arrays.stream(permission.roles()).anyMatch(userRoles::contains);
if (!hasRole) return false;
}
if (!permission.expression().isEmpty()) {
return evaluateExpression(permission.expression());
}
return true;
}
private boolean evaluateExpression(String expression) {
// Simplified placeholder – real implementation would evaluate SpEL
return true;
}
private Field getFieldFromWriter(BeanPropertyWriter writer) {
try {
return writer.getMember().getDeclaringClass().getDeclaredField(writer.getName());
} catch (NoSuchFieldException e) {
return null;
}
}
}
public class FieldPermissionModule extends SimpleModule {
public FieldPermissionModule() { super("FieldPermissionModule"); }
@Override
public void setupModule(SetupContext context) {
super.setupModule(context);
context.addBeanSerializerModifier(new FieldPermissionSerializerModifier());
}
private static class FieldPermissionSerializerModifier extends BeanSerializerModifier {
@Override
public List<BeanPropertyWriter> changeProperties(SerializationConfig config, BeanDescription beanDesc, List<BeanPropertyWriter> beanProperties) {
for (BeanPropertyWriter writer : beanProperties) {
Field field = getFieldFromWriter(writer);
if (field != null && field.isAnnotationPresent(FieldPermission.class)) {
writer.assignSerializer(new FieldPermissionSerializer());
}
}
return beanProperties;
}
}
}AOP Permission Aspect
@Aspect
@Component
public class FieldPermissionAspect {
private final ObjectMapper objectMapper;
public FieldPermissionAspect(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
this.objectMapper.registerModule(new FieldPermissionModule());
}
/** Around advice: filter fields before returning */
@Around("@annotation(org.springframework.web.bind.annotation.GetMapping) || " +
"@annotation(org.springframework.web.bind.annotation.PostMapping) || " +
"@annotation(org.springframework.web.bind.annotation.PutMapping) || " +
"@annotation(org.springframework.web.bind.annotation.DeleteMapping)")
public Object aroundControllerMethod(ProceedingJoinPoint joinPoint) throws Throwable {
Object result = joinPoint.proceed();
return filterFieldsByPermission(result);
}
private Object filterFieldsByPermission(Object obj) {
if (obj == null) return null;
try {
String json = objectMapper.writeValueAsString(obj);
if (obj instanceof Collection) {
return objectMapper.readValue(json, objectMapper.getTypeFactory().constructCollectionType(List.class, getGenericType(obj)));
} else {
return objectMapper.readValue(json, obj.getClass());
}
} catch (Exception e) {
throw new RuntimeException("Field permission filtering failed", e);
}
}
private Class<?> getGenericType(Object obj) {
if (obj instanceof Collection && !((Collection<?>) obj).isEmpty()) {
return ((Collection<?>) obj).iterator().next().getClass();
}
return Object.class;
}
}Data Model Definition
@Data
@ClassPermission(roles = {"ADMIN", "USER"})
public class UserDTO {
@FieldPermission(roles = {"ADMIN", "USER"})
private Long id;
@FieldPermission(roles = {"ADMIN", "USER"})
private String username;
@FieldPermission(roles = {"ADMIN"}) // only admin sees email
private String email;
@FieldPermission(roles = {"ADMIN"}) // only admin sees phone
private String phone;
@FieldPermission(roles = {"ADMIN"}) // only admin sees creation time
private LocalDateTime createTime;
@FieldPermission(roles = {"ADMIN", "MANAGER"}) // admin & manager see department
private String department;
@FieldPermission(roles = {"ADMIN"}) // only admin sees salary
private BigDecimal salary;
@FieldPermission(roles = {"ADMIN", "USER"})
private String nickname;
@FieldPermission(roles = {"ADMIN", "USER"})
private Integer age;
public UserDTO() {}
public UserDTO(Long id, String username, String email, String phone, String department,
BigDecimal salary, String nickname, Integer age) {
this.id = id;
this.username = username;
this.email = email;
this.phone = phone;
this.createTime = LocalDateTime.now();
this.department = department;
this.salary = salary;
this.nickname = nickname;
this.age = age;
}
}Controller Implementation
@RestController
@RequestMapping("/api/users")
public class UserController {
private final List<UserDTO> userData = Arrays.asList(
new UserDTO(1L, "admin", "[email protected]", "13800138000", "技术部", new BigDecimal("30000.00"), "管理员", 30),
new UserDTO(2L, "user1", "[email protected]", "13900139000", "市场部", new BigDecimal("15000.00"), "用户1", 25),
new UserDTO(3L, "manager", "[email protected]", "13700137000", "销售部", new BigDecimal("25000.00"), "经理", 35)
);
@GetMapping
@PreAuthorize("hasAnyRole('ADMIN', 'USER', 'MANAGER')")
public List<UserDTO> getAllUsers() { return userData; }
@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN', 'USER', 'MANAGER')")
public UserDTO getUserById(@PathVariable Long id) {
return userData.stream()
.filter(user -> user.getId().equals(id))
.findFirst()
.orElseThrow(() -> new RuntimeException("用户不存在"));
}
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public UserDTO createUser(@RequestBody UserDTO userDTO) {
// creation logic (omitted)
return userDTO;
}
}Security Configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll())
.httpBasic(withDefaults());
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails admin = User.withUsername("admin").password("{noop}password").roles("ADMIN").build();
UserDetails user = User.withUsername("user").password("{noop}password").roles("USER").build();
UserDetails manager = User.withUsername("manager").password("{noop}password").roles("MANAGER").build();
return new InMemoryUserDetailsManager(admin, user, manager);
}
@Bean
public FilterRegistrationBean<SecurityContextFilter> securityContextFilter() {
FilterRegistrationBean<SecurityContextFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new SecurityContextFilter());
bean.addUrlPatterns("/api/*");
return bean;
}
}
public class SecurityContextFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
Set<String> roles = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.map(r -> r.replace("ROLE_", ""))
.collect(Collectors.toSet());
SecurityContextHolder.UserContext ctx = new SecurityContextHolder.UserContext(
1L, // mock user ID
authentication.getName(),
roles,
Collections.emptyMap()
);
SecurityContextHolder.setUserContext(ctx);
}
chain.doFilter(request, response);
} finally {
SecurityContextHolder.clear();
}
}
}Technical Advantages
Fine‑grained control : permission at the field level.
Dynamic & flexible : role‑based and SpEL expression evaluation.
Non‑intrusive : implemented via annotations and AOP, leaving business code untouched.
High performance : filtering occurs during serialization, incurring minimal overhead.
Applicable Scenarios
Data isolation in multi‑tenant systems.
Systems where different roles must see different field content.
Financial or government applications requiring strict data permission.
API endpoints that need to return different fields based on the caller.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Senior Xiao Ying
Dedicated to sharing Java backend technical experience and original tutorials, offering career transition advice and resume editing. Recognized as a rising star in CSDN's Java backend community and ranked Top 3 in the 2022 New Star Program for Java backend.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
