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.

Senior Xiao Ying
Senior Xiao Ying
Senior Xiao Ying
Simple Field-Level Permission Control in Spring Boot 3.x

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.

Field permission scenario diagram
Field permission scenario diagram
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

AOPSpring Bootsecurityjacksonjava-17Field Permission
Senior Xiao Ying
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.