Skip to content

Commit 9a8cc9c

Browse files
authored
Merge pull request #15 from andriawan/14-role-based-access-implementation
Role based access implementation
2 parents ae9d664 + 68b3324 commit 9a8cc9c

File tree

23 files changed

+556
-60
lines changed

23 files changed

+556
-60
lines changed

.github/workflows/pull_request_flow.yml

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ jobs:
1616
uses: actions/checkout@v4
1717
with:
1818
token: ${{ secrets.GITHUB_TOKEN }}
19+
- name: Run Spotless check
20+
run: mvn spotless:check
1921
- name: Copy Private and Public Keys
2022
run: |
2123
echo "${{ secrets.SAMPLE_PRIVATE_KEY }}" > ${CERTS_PATH}/private_key.pem
@@ -37,23 +39,6 @@ jobs:
3739
- name: Run tests and collect coverage
3840
run: mvn -B clean test jacoco:report
3941
- name: Upload coverage to Codecov
40-
if: false
4142
uses: codecov/codecov-action@v5
4243
env:
43-
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
44-
- name: Run Spotless and apply formatting
45-
run: mvn spotless:apply
46-
- name: Commit and push changes if formatting was applied
47-
env:
48-
GIT_EMAIL: ${{ github.actor }}@users.noreply.github.com
49-
GIT_NAME: ${{ github.actor }}"
50-
run: |
51-
git config --global user.name "$GIT_NAME"
52-
git config --global user.email "$GIT_EMAIL"
53-
if [[ -n "$(git status --porcelain)" ]]; then
54-
git add -A
55-
git commit -m "chore(format): apply Spotless auto-format"
56-
git push
57-
else
58-
echo "No formatting changes to commit."
59-
fi
44+
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
This is a minimal Spring Boot application built with standard best practices. It includes JWT bearer token authentication via Spring Security, supports auto-reloading .env configuration, and provides clean, self-documented APIs using Swagger/OpenAPI. Designed as a lightweight foundation for scalable, secure RESTful services.
44

5+
## Coverage Status
6+
7+
[![codecov](https://codecov.io/gh/andriawan/springboot-best-practice/graph/badge.svg?token=4QCLOGCOXI)](https://codecov.io/gh/andriawan/springboot-best-practice)
8+
59
## Prerequisites
610

711
This project uses JWTs signed with **asymmetric encryption** (e.g., RSA).

src/main/java/com/andriawan/andresource/config/Security.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import org.springframework.context.annotation.Configuration;
1717
import org.springframework.core.annotation.Order;
1818
import org.springframework.security.config.Customizer;
19+
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
1920
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
2021
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
2122
import org.springframework.security.config.http.SessionCreationPolicy;
@@ -32,6 +33,7 @@
3233

3334
@Configuration
3435
@EnableWebSecurity
36+
@EnableMethodSecurity
3537
public class Security {
3638

3739
@Value("${rsa.key.private}")

src/main/java/com/andriawan/andresource/controller/UserController.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import java.util.List;
99
import lombok.RequiredArgsConstructor;
1010
import org.springframework.http.ResponseEntity;
11+
import org.springframework.security.access.prepost.PreAuthorize;
1112
import org.springframework.security.core.userdetails.UsernameNotFoundException;
1213
import org.springframework.web.bind.annotation.GetMapping;
1314
import org.springframework.web.bind.annotation.RequestMapping;
@@ -22,12 +23,14 @@ public class UserController {
2223
private final UserService userService;
2324

2425
@GetMapping("/users")
26+
@PreAuthorize("hasAuthority('SCOPE_ROLE_ADMIN')")
2527
public ResponseEntity<List<UserResponse>> getAllUser(Principal principal) {
2628
List<UserResponse> users = userService.getAllUsers();
2729
return ResponseEntity.ok(users);
2830
}
2931

3032
@GetMapping("/me")
33+
@PreAuthorize("hasAuthority('SCOPE_ROLE_USER') or hasAuthority('SCOPE_ROLE_ADMIN')")
3134
public ResponseEntity<UserResponse> getAuthenticatedUser(Principal principal) {
3235
try {
3336
UserResponse user = userService.getUserByEmail(principal.getName());

src/main/java/com/andriawan/andresource/dto/UserResponse.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.andriawan.andresource.dto;
22

33
import java.time.ZonedDateTime;
4+
import java.util.Set;
45
import lombok.AllArgsConstructor;
56
import lombok.Builder;
67
import lombok.Data;
@@ -17,4 +18,5 @@ public class UserResponse {
1718
private ZonedDateTime createdAt;
1819
private ZonedDateTime updatedAt;
1920
private Boolean isActive;
21+
private Set<String> roles;
2022
}
Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
11
package com.andriawan.andresource.entity;
22

3-
import java.util.ArrayList;
43
import java.util.Collection;
4+
import java.util.stream.Collectors;
5+
import lombok.AllArgsConstructor;
6+
import lombok.Getter;
57
import org.springframework.security.core.GrantedAuthority;
8+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
69
import org.springframework.security.core.userdetails.UserDetails;
710

11+
@AllArgsConstructor
12+
@Getter
813
public class AuthenticatedUser implements UserDetails {
914

10-
private User user;
11-
12-
public AuthenticatedUser(User user) {
13-
this.user = user;
14-
}
15+
private final User user;
1516

1617
@Override
1718
public Collection<? extends GrantedAuthority> getAuthorities() {
18-
final ArrayList<GrantedAuthority> roles = new ArrayList<GrantedAuthority>();
19-
return roles;
19+
var roles = user.getRoles();
20+
return roles.stream()
21+
.map(role -> new SimpleGrantedAuthority(role.getName()))
22+
.collect(Collectors.toList());
2023
}
2124

2225
@Override
@@ -28,8 +31,4 @@ public String getPassword() {
2831
public String getUsername() {
2932
return user.getEmail();
3033
}
31-
32-
public User getUser() {
33-
return user;
34-
}
3534
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.andriawan.andresource.entity;
2+
3+
import jakarta.persistence.*;
4+
import java.time.Instant;
5+
import java.util.Set;
6+
import lombok.AllArgsConstructor;
7+
import lombok.Builder;
8+
import lombok.EqualsAndHashCode;
9+
import lombok.Getter;
10+
import lombok.NoArgsConstructor;
11+
import lombok.Setter;
12+
import lombok.ToString;
13+
import org.hibernate.annotations.CreationTimestamp;
14+
import org.hibernate.annotations.UpdateTimestamp;
15+
16+
@Entity
17+
@Table(name = "roles")
18+
@Getter
19+
@Setter
20+
@NoArgsConstructor
21+
@AllArgsConstructor
22+
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
23+
@Builder
24+
@ToString(exclude = "users")
25+
public class Role {
26+
27+
@Id
28+
@EqualsAndHashCode.Include
29+
@GeneratedValue(strategy = GenerationType.IDENTITY)
30+
private Integer id;
31+
32+
@Column(nullable = false, unique = true)
33+
@EqualsAndHashCode.Include
34+
private String name;
35+
36+
@ManyToMany(mappedBy = "roles")
37+
private Set<User> users;
38+
39+
@CreationTimestamp
40+
@Column(name = "created_at", updatable = false)
41+
private Instant createdAt;
42+
43+
@UpdateTimestamp
44+
@Column(name = "updated_at")
45+
private Instant updatedAt;
46+
}

src/main/java/com/andriawan/andresource/entity/User.java

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,41 @@
11
package com.andriawan.andresource.entity;
22

33
import com.fasterxml.jackson.annotation.JsonIgnore;
4+
import jakarta.persistence.CascadeType;
45
import jakarta.persistence.Column;
56
import jakarta.persistence.Entity;
7+
import jakarta.persistence.FetchType;
68
import jakarta.persistence.GeneratedValue;
79
import jakarta.persistence.GenerationType;
810
import jakarta.persistence.Id;
11+
import jakarta.persistence.JoinColumn;
12+
import jakarta.persistence.JoinTable;
13+
import jakarta.persistence.ManyToMany;
914
import jakarta.persistence.PrePersist;
1015
import jakarta.persistence.PreUpdate;
1116
import jakarta.persistence.Table;
1217
import java.time.ZonedDateTime;
18+
import java.util.Set;
1319
import lombok.AllArgsConstructor;
1420
import lombok.Builder;
15-
import lombok.Data;
21+
import lombok.EqualsAndHashCode;
22+
import lombok.Getter;
1623
import lombok.NoArgsConstructor;
24+
import lombok.Setter;
25+
import lombok.ToString;
1726

18-
@Data
27+
@Getter
28+
@Setter
1929
@NoArgsConstructor
2030
@AllArgsConstructor
31+
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
2132
@Builder
2233
@Entity
2334
@Table(name = "users")
35+
@ToString(exclude = "roles")
2436
public class User {
2537
@Id
38+
@EqualsAndHashCode.Include
2639
@GeneratedValue(strategy = GenerationType.IDENTITY)
2740
private Long id;
2841

@@ -43,18 +56,27 @@ public class User {
4356
private ZonedDateTime updatedAt;
4457

4558
@Column(name = "is_active", nullable = false)
46-
private Boolean isActive;
59+
@Builder.Default
60+
private Boolean isActive = true;
4761

4862
@Column(name = "is_deleted", nullable = false)
49-
private Boolean isDeleted;
63+
@Builder.Default
64+
private Boolean isDeleted = false;
65+
66+
@ManyToMany(
67+
fetch = FetchType.LAZY,
68+
cascade = {CascadeType.MERGE, CascadeType.PERSIST})
69+
@JoinTable(
70+
name = "user_roles",
71+
joinColumns = @JoinColumn(name = "user_id"),
72+
inverseJoinColumns = @JoinColumn(name = "role_id"))
73+
private Set<Role> roles;
5074

5175
@PrePersist
5276
protected void onCreate() {
5377
ZonedDateTime now = ZonedDateTime.now();
5478
this.createdAt = now;
5579
this.updatedAt = now;
56-
this.isActive = true;
57-
this.isDeleted = false;
5880
}
5981

6082
@PreUpdate

src/main/java/com/andriawan/andresource/mapper/UserMapper.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@
33
import com.andriawan.andresource.dto.UserCreate;
44
import com.andriawan.andresource.dto.UserResponse;
55
import com.andriawan.andresource.dto.UserUpdate;
6+
import com.andriawan.andresource.entity.Role;
67
import com.andriawan.andresource.entity.User;
78
import java.util.List;
9+
import java.util.Set;
10+
import java.util.stream.Collectors;
811
import org.mapstruct.BeanMapping;
912
import org.mapstruct.InjectionStrategy;
1013
import org.mapstruct.Mapper;
1114
import org.mapstruct.Mapping;
1215
import org.mapstruct.MappingTarget;
16+
import org.mapstruct.Named;
1317
import org.mapstruct.NullValuePropertyMappingStrategy;
1418

1519
@Mapper(componentModel = "spring", injectionStrategy = InjectionStrategy.CONSTRUCTOR)
@@ -20,17 +24,25 @@ public interface UserMapper {
2024
@Mapping(target = "updatedAt", ignore = true)
2125
@Mapping(target = "isActive", ignore = true)
2226
@Mapping(target = "isDeleted", ignore = true)
27+
@Mapping(target = "roles", ignore = true)
2328
User toEntity(UserCreate dto);
2429

2530
@Mapping(target = "id", ignore = true)
2631
@Mapping(target = "createdAt", ignore = true)
2732
@Mapping(target = "updatedAt", ignore = true)
2833
@Mapping(target = "isDeleted", ignore = true)
2934
@Mapping(target = "password", ignore = true)
35+
@Mapping(target = "roles", ignore = true)
3036
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
3137
void updateEntityFromDto(UserUpdate dto, @MappingTarget User entity);
3238

39+
@Mapping(source = "roles", target = "roles", qualifiedByName = "rolesToRoleNames")
3340
UserResponse toResponseDto(User entity);
3441

3542
List<UserResponse> toResponseDtoList(List<User> entities);
43+
44+
@Named("rolesToRoleNames")
45+
default Set<String> rolesToRoleNames(Set<Role> roles) {
46+
return roles.stream().map(Role::getName).collect(Collectors.toSet());
47+
}
3648
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.andriawan.andresource.repository;
2+
3+
import com.andriawan.andresource.entity.Role;
4+
import java.util.Optional;
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.stereotype.Repository;
7+
8+
@Repository
9+
public interface RoleRepository extends JpaRepository<Role, Integer> {
10+
Optional<Role> findByName(String name);
11+
}

0 commit comments

Comments
 (0)