Spring Securityの標準的なロールベース認可だけでは、複雑なビジネス要件に対応できないケースがあります。本記事では、AuthorizationManagerのカスタム実装、データベースからの権限動的取得、PermissionEvaluatorによるリソースベースのアクセス制御など、柔軟な認可ロジックの実装方法を解説します。
実行環境と前提条件#
本記事の内容を実践するにあたり、以下の環境を前提としています。
| 項目 |
バージョン・要件 |
| Java |
17以上 |
| Spring Boot |
3.4.x |
| Spring Security |
6.4.x |
| ビルドツール |
Maven または Gradle |
| データベース |
PostgreSQL(他のRDBMSでも可) |
事前に以下の知識があると理解がスムーズです。
- Spring Securityの基本概念(認証・認可の違い)
@PreAuthorize、@PostAuthorizeの基本的な使い方
- Spring Data JPAの基礎
動的認可が必要なユースケース#
静的なロールベース認可では対応が難しいユースケースを見てみましょう。
| ユースケース |
課題 |
解決策 |
| リソース所有者のみ編集可能 |
ロールだけでは所有権を判定できない |
PermissionEvaluator |
| 権限の動的管理 |
再デプロイなしで権限変更したい |
DB連携AuthorizationManager |
| テナント別アクセス制御 |
マルチテナント環境での分離 |
カスタムAuthorizationManager |
| 時間帯や条件による制限 |
営業時間内のみ操作可能など |
条件付きAuthorizationManager |
| 階層的なリソースアクセス |
フォルダ配下のファイルへのアクセス制御 |
再帰的権限チェック |
AuthorizationManagerの基本アーキテクチャ#
Spring Security 6.x以降、認可の中心的なインターフェースはAuthorizationManagerです。
flowchart TB
subgraph "認可フロー"
A[HTTPリクエスト/メソッド呼び出し] --> B[AuthorizationManager]
B --> C{authorize}
C --> D[AuthorizationDecision]
D -->|granted=true| E[アクセス許可]
D -->|granted=false| F[AccessDeniedException]
end
subgraph "AuthorizationManagerの種類"
G[AuthorityAuthorizationManager]
H[AuthenticatedAuthorizationManager]
I[カスタムAuthorizationManager]
end
B --> G
B --> H
B --> IAuthorizationManagerインターフェースは非常にシンプルです。
1
2
3
4
5
6
7
8
9
10
11
|
public interface AuthorizationManager<T> {
@Nullable
AuthorizationDecision check(Supplier<Authentication> authentication, T object);
default void verify(Supplier<Authentication> authentication, T object) {
AuthorizationDecision decision = check(authentication, object);
if (decision != null && !decision.isGranted()) {
throw new AccessDeniedException("Access Denied");
}
}
}
|
カスタムAuthorizationManagerの実装#
メソッドセキュリティ向けAuthorizationManager#
メソッド呼び出しに対するカスタム認可を実装します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
|
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.core.Authentication;
import org.aopalliance.intercept.MethodInvocation;
import org.springframework.stereotype.Component;
import java.util.function.Supplier;
@Component
public class DynamicMethodAuthorizationManager
implements AuthorizationManager<MethodInvocation> {
private final PermissionRepository permissionRepository;
private final UserPermissionService userPermissionService;
public DynamicMethodAuthorizationManager(
PermissionRepository permissionRepository,
UserPermissionService userPermissionService) {
this.permissionRepository = permissionRepository;
this.userPermissionService = userPermissionService;
}
@Override
public AuthorizationDecision check(
Supplier<Authentication> authenticationSupplier,
MethodInvocation invocation) {
Authentication authentication = authenticationSupplier.get();
if (authentication == null || !authentication.isAuthenticated()) {
return new AuthorizationDecision(false);
}
String methodName = invocation.getMethod().getName();
String className = invocation.getMethod().getDeclaringClass().getSimpleName();
String resource = className + "." + methodName;
// データベースから必要な権限を取得
String requiredPermission = permissionRepository
.findRequiredPermissionByResource(resource)
.orElse(null);
if (requiredPermission == null) {
// 権限設定がない場合は許可(またはポリシーに応じて拒否)
return new AuthorizationDecision(true);
}
// ユーザーが必要な権限を持っているかチェック
boolean hasPermission = userPermissionService
.hasPermission(authentication.getName(), requiredPermission);
return new AuthorizationDecision(hasPermission);
}
}
|
設定クラスでの登録#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.context.annotation.Role;
import org.springframework.aop.Advisor;
@Configuration
@EnableMethodSecurity(prePostEnabled = false) // デフォルトを無効化
public class MethodSecurityConfig {
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
Advisor dynamicAuthorizationAdvisor(
DynamicMethodAuthorizationManager authorizationManager) {
// カスタムAuthorizationManagerをメソッドインターセプターとして登録
return new AuthorizationManagerBeforeMethodInterceptor(
AuthorizationMethodPointcuts.forAllMethods(),
authorizationManager
);
}
}
|
データベース連携による動的権限管理#
実運用では、権限をデータベースで管理し、アプリケーション再起動なしで変更できることが求められます。
エンティティ設計#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
|
import jakarta.persistence.*;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "permissions")
public class Permission {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String name; // 例: "document:read", "document:write"
private String description;
@Column(name = "resource_pattern")
private String resourcePattern; // 例: "DocumentService.*"
@ManyToMany(mappedBy = "permissions")
private Set<Role> roles = new HashSet<>();
// getter/setter省略
}
@Entity
@Table(name = "roles")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String name;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "role_permissions",
joinColumns = @JoinColumn(name = "role_id"),
inverseJoinColumns = @JoinColumn(name = "permission_id")
)
private Set<Permission> permissions = new HashSet<>();
// getter/setter省略
}
@Entity
@Table(name = "resource_permissions")
public class ResourcePermission {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "resource_type", nullable = false)
private String resourceType; // 例: "Document", "Project"
@Column(name = "resource_id")
private Long resourceId;
@Column(name = "user_id")
private Long userId;
@Column(name = "permission_name", nullable = false)
private String permissionName; // 例: "read", "write", "delete"
// getter/setter省略
}
|
リポジトリとサービス実装#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface PermissionRepository extends JpaRepository<Permission, Long> {
@Query("""
SELECT p.name FROM Permission p
WHERE :resource LIKE CONCAT(p.resourcePattern, '%')
""")
Optional<String> findRequiredPermissionByResource(@Param("resource") String resource);
@Query("""
SELECT p FROM Permission p
JOIN p.roles r
JOIN r.users u
WHERE u.username = :username
""")
List<Permission> findByUsername(@Param("username") String username);
}
public interface ResourcePermissionRepository
extends JpaRepository<ResourcePermission, Long> {
@Query("""
SELECT COUNT(rp) > 0 FROM ResourcePermission rp
WHERE rp.resourceType = :resourceType
AND rp.resourceId = :resourceId
AND rp.userId = :userId
AND rp.permissionName = :permission
""")
boolean existsByResourceAndUserAndPermission(
@Param("resourceType") String resourceType,
@Param("resourceId") Long resourceId,
@Param("userId") Long userId,
@Param("permission") String permission);
List<ResourcePermission> findByResourceTypeAndResourceId(
String resourceType, Long resourceId);
}
|
権限チェックサービス#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
|
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@Transactional(readOnly = true)
public class UserPermissionService {
private final PermissionRepository permissionRepository;
private final ResourcePermissionRepository resourcePermissionRepository;
private final UserRepository userRepository;
public UserPermissionService(
PermissionRepository permissionRepository,
ResourcePermissionRepository resourcePermissionRepository,
UserRepository userRepository) {
this.permissionRepository = permissionRepository;
this.resourcePermissionRepository = resourcePermissionRepository;
this.userRepository = userRepository;
}
@Cacheable(value = "userPermissions", key = "#username")
public Set<String> getPermissions(String username) {
return permissionRepository.findByUsername(username)
.stream()
.map(Permission::getName)
.collect(Collectors.toSet());
}
public boolean hasPermission(String username, String permission) {
return getPermissions(username).contains(permission);
}
public boolean hasResourcePermission(
String username,
String resourceType,
Long resourceId,
String permission) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(username));
// まずグローバル権限をチェック
if (hasPermission(username, resourceType + ":" + permission)) {
return true;
}
// 次にリソース固有の権限をチェック
return resourcePermissionRepository.existsByResourceAndUserAndPermission(
resourceType, resourceId, user.getId(), permission);
}
}
|
キャッシュの設定と無効化#
権限情報はキャッシュして性能を向上させますが、変更時には適切に無効化する必要があります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Caching;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class PermissionManagementService {
private final RoleRepository roleRepository;
private final PermissionRepository permissionRepository;
private final UserRepository userRepository;
// コンストラクタ省略
@Caching(evict = {
@CacheEvict(value = "userPermissions", allEntries = true)
})
public void addPermissionToRole(Long roleId, Long permissionId) {
Role role = roleRepository.findById(roleId)
.orElseThrow(() -> new EntityNotFoundException("Role not found"));
Permission permission = permissionRepository.findById(permissionId)
.orElseThrow(() -> new EntityNotFoundException("Permission not found"));
role.getPermissions().add(permission);
roleRepository.save(role);
}
@CacheEvict(value = "userPermissions", key = "#username")
public void addRoleToUser(String username, Long roleId) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(username));
Role role = roleRepository.findById(roleId)
.orElseThrow(() -> new EntityNotFoundException("Role not found"));
user.getRoles().add(role);
userRepository.save(user);
}
}
|
PermissionEvaluatorによるリソースベース認可#
PermissionEvaluatorは、@PreAuthorizeや@PostAuthorize内でhasPermission()式を使用するための仕組みです。リソース単位のアクセス制御に最適です。
PermissionEvaluatorの実装#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
|
import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import java.io.Serializable;
@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {
private final UserPermissionService userPermissionService;
private final DocumentRepository documentRepository;
private final ProjectRepository projectRepository;
public CustomPermissionEvaluator(
UserPermissionService userPermissionService,
DocumentRepository documentRepository,
ProjectRepository projectRepository) {
this.userPermissionService = userPermissionService;
this.documentRepository = documentRepository;
this.projectRepository = projectRepository;
}
/**
* hasPermission(targetDomainObject, permission) の評価
* 例: @PreAuthorize("hasPermission(#document, 'write')")
*/
@Override
public boolean hasPermission(
Authentication authentication,
Object targetDomainObject,
Object permission) {
if (authentication == null || targetDomainObject == null) {
return false;
}
String username = authentication.getName();
String permissionStr = permission.toString();
return switch (targetDomainObject) {
case Document doc -> evaluateDocumentPermission(
username, doc, permissionStr);
case Project project -> evaluateProjectPermission(
username, project, permissionStr);
default -> false;
};
}
/**
* hasPermission(targetId, targetType, permission) の評価
* 例: @PreAuthorize("hasPermission(#documentId, 'Document', 'write')")
*/
@Override
public boolean hasPermission(
Authentication authentication,
Serializable targetId,
String targetType,
Object permission) {
if (authentication == null || targetId == null) {
return false;
}
String username = authentication.getName();
String permissionStr = permission.toString();
return switch (targetType) {
case "Document" -> {
Document doc = documentRepository.findById((Long) targetId)
.orElse(null);
yield doc != null && evaluateDocumentPermission(
username, doc, permissionStr);
}
case "Project" -> {
Project project = projectRepository.findById((Long) targetId)
.orElse(null);
yield project != null && evaluateProjectPermission(
username, project, permissionStr);
}
default -> false;
};
}
private boolean evaluateDocumentPermission(
String username, Document document, String permission) {
// 所有者は全権限を持つ
if (document.getOwner().getUsername().equals(username)) {
return true;
}
// 公開ドキュメントは読み取り可能
if ("read".equals(permission) && document.isPublic()) {
return true;
}
// 共有設定をチェック
if (document.getSharedWith().stream()
.anyMatch(share ->
share.getUser().getUsername().equals(username) &&
share.getPermissions().contains(permission))) {
return true;
}
// データベースの権限をチェック
return userPermissionService.hasResourcePermission(
username, "Document", document.getId(), permission);
}
private boolean evaluateProjectPermission(
String username, Project project, String permission) {
// プロジェクトオーナーは全権限を持つ
if (project.getOwner().getUsername().equals(username)) {
return true;
}
// プロジェクトメンバーの権限をチェック
return project.getMembers().stream()
.filter(member -> member.getUser().getUsername().equals(username))
.anyMatch(member -> member.getPermissions().contains(permission));
}
}
|
MethodSecurityへの登録#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
@Bean
static MethodSecurityExpressionHandler methodSecurityExpressionHandler(
CustomPermissionEvaluator permissionEvaluator) {
DefaultMethodSecurityExpressionHandler handler =
new DefaultMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(permissionEvaluator);
return handler;
}
}
|
サービスクラスでの使用例#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
|
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class DocumentService {
private final DocumentRepository documentRepository;
public DocumentService(DocumentRepository documentRepository) {
this.documentRepository = documentRepository;
}
// オブジェクトベースの権限チェック(取得後)
@PostAuthorize("hasPermission(returnObject, 'read')")
public Document getDocument(Long id) {
return documentRepository.findById(id)
.orElseThrow(() -> new DocumentNotFoundException(id));
}
// IDベースの権限チェック(取得前)
@PreAuthorize("hasPermission(#id, 'Document', 'write')")
public Document updateDocument(Long id, DocumentUpdateRequest request) {
Document document = documentRepository.findById(id)
.orElseThrow(() -> new DocumentNotFoundException(id));
document.setTitle(request.title());
document.setContent(request.content());
document.setUpdatedAt(Instant.now());
return documentRepository.save(document);
}
// 削除権限のチェック
@PreAuthorize("hasPermission(#id, 'Document', 'delete')")
public void deleteDocument(Long id) {
documentRepository.deleteById(id);
}
// 新規作成は認証済みユーザーなら誰でも可能
@PreAuthorize("isAuthenticated()")
public Document createDocument(DocumentCreateRequest request) {
String username = SecurityContextHolder.getContext()
.getAuthentication().getName();
User owner = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(username));
Document document = new Document();
document.setTitle(request.title());
document.setContent(request.content());
document.setOwner(owner);
document.setCreatedAt(Instant.now());
return documentRepository.save(document);
}
}
|
階層的リソースの権限伝播#
フォルダ構造のような階層的なリソースでは、親の権限が子に伝播するケースがあります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
|
@Component
public class HierarchicalPermissionEvaluator implements PermissionEvaluator {
private final FolderRepository folderRepository;
private final UserPermissionService userPermissionService;
// コンストラクタ省略
@Override
public boolean hasPermission(
Authentication authentication,
Object targetDomainObject,
Object permission) {
if (targetDomainObject instanceof Folder folder) {
return hasHierarchicalPermission(
authentication.getName(), folder, permission.toString());
}
return false;
}
@Override
public boolean hasPermission(
Authentication authentication,
Serializable targetId,
String targetType,
Object permission) {
if ("Folder".equals(targetType)) {
Folder folder = folderRepository.findById((Long) targetId)
.orElse(null);
if (folder != null) {
return hasHierarchicalPermission(
authentication.getName(), folder, permission.toString());
}
}
return false;
}
/**
* フォルダの階層をたどって権限をチェック
*/
private boolean hasHierarchicalPermission(
String username, Folder folder, String permission) {
// 現在のフォルダで権限チェック
if (hasFolderPermission(username, folder, permission)) {
return true;
}
// 親フォルダを再帰的にチェック
if (folder.getParent() != null) {
return hasHierarchicalPermission(username, folder.getParent(), permission);
}
return false;
}
private boolean hasFolderPermission(
String username, Folder folder, String permission) {
// 所有者チェック
if (folder.getOwner().getUsername().equals(username)) {
return true;
}
// 明示的な権限チェック
return userPermissionService.hasResourcePermission(
username, "Folder", folder.getId(), permission);
}
}
|
HTTPリクエストベースの動的認可#
URLパターンに対する認可もデータベースで管理できます。
カスタムAuthorizationManagerの実装#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
|
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.function.Supplier;
@Component
public class DynamicRequestAuthorizationManager
implements AuthorizationManager<RequestAuthorizationContext> {
private final UrlPermissionRepository urlPermissionRepository;
public DynamicRequestAuthorizationManager(
UrlPermissionRepository urlPermissionRepository) {
this.urlPermissionRepository = urlPermissionRepository;
}
@Override
public AuthorizationDecision check(
Supplier<Authentication> authenticationSupplier,
RequestAuthorizationContext context) {
Authentication authentication = authenticationSupplier.get();
String requestUri = context.getRequest().getRequestURI();
String method = context.getRequest().getMethod();
// データベースから該当するURL権限設定を取得
List<UrlPermission> urlPermissions = urlPermissionRepository
.findMatchingPermissions(requestUri, method);
if (urlPermissions.isEmpty()) {
// 設定がなければ許可(またはポリシーに応じて拒否)
return new AuthorizationDecision(true);
}
// いずれかの権限設定を満たすかチェック
for (UrlPermission urlPermission : urlPermissions) {
if (checkPermission(authentication, urlPermission)) {
return new AuthorizationDecision(true);
}
}
return new AuthorizationDecision(false);
}
private boolean checkPermission(
Authentication authentication,
UrlPermission urlPermission) {
if (urlPermission.isPermitAll()) {
return true;
}
if (authentication == null || !authentication.isAuthenticated()) {
return false;
}
if (urlPermission.isAuthenticated()) {
return true;
}
String requiredRole = urlPermission.getRequiredRole();
if (requiredRole != null) {
return authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(auth -> auth.equals("ROLE_" + requiredRole));
}
String requiredAuthority = urlPermission.getRequiredAuthority();
if (requiredAuthority != null) {
return authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(auth -> auth.equals(requiredAuthority));
}
return false;
}
}
|
URL権限エンティティ#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
@Entity
@Table(name = "url_permissions")
public class UrlPermission {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "url_pattern", nullable = false)
private String urlPattern; // 例: "/api/admin/**", "/api/documents/{id}"
@Column(name = "http_method")
private String httpMethod; // GET, POST, PUT, DELETE, null=all
@Column(name = "permit_all")
private boolean permitAll;
@Column(name = "authenticated")
private boolean authenticated;
@Column(name = "required_role")
private String requiredRole;
@Column(name = "required_authority")
private String requiredAuthority;
@Column(name = "priority")
private int priority; // 優先順位(低いほど優先)
// getter/setter省略
}
|
SecurityFilterChainへの適用#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
DynamicRequestAuthorizationManager authorizationManager) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
// 動的AuthorizationManagerをデフォルトルールとして適用
.anyRequest().access(authorizationManager)
)
.formLogin(form -> form
.loginPage("/login")
.permitAll()
)
.logout(logout -> logout
.permitAll()
);
return http.build();
}
}
|
条件付き認可の実装#
時間帯やユーザー状態など、動的な条件に基づく認可を実装します。
時間帯ベースの認可#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
@Component
public class TimeBasedAuthorizationManager
implements AuthorizationManager<MethodInvocation> {
@Override
public AuthorizationDecision check(
Supplier<Authentication> authentication,
MethodInvocation invocation) {
// アノテーションから営業時間設定を取得
BusinessHours annotation = invocation.getMethod()
.getAnnotation(BusinessHours.class);
if (annotation == null) {
return new AuthorizationDecision(true);
}
LocalTime now = LocalTime.now();
LocalTime start = LocalTime.parse(annotation.start());
LocalTime end = LocalTime.parse(annotation.end());
boolean withinBusinessHours = !now.isBefore(start) && !now.isAfter(end);
return new AuthorizationDecision(withinBusinessHours);
}
}
// カスタムアノテーション
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface BusinessHours {
String start() default "09:00";
String end() default "18:00";
}
// 使用例
@Service
public class TransactionService {
@BusinessHours(start = "09:00", end = "17:00")
public void processTransaction(Transaction transaction) {
// 営業時間内のみ実行可能
}
}
|
複合条件AuthorizationManager#
複数の条件を組み合わせるAuthorizationManagerを実装します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
|
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.authorization.AuthorizationManagers;
import org.springframework.security.core.Authentication;
import java.util.List;
import java.util.function.Supplier;
public class CompositeAuthorizationManager<T> implements AuthorizationManager<T> {
private final List<AuthorizationManager<T>> managers;
private final CombineMode mode;
public enum CombineMode {
ALL, // すべて満たす必要がある
ANY // いずれか1つを満たせばよい
}
public CompositeAuthorizationManager(
List<AuthorizationManager<T>> managers,
CombineMode mode) {
this.managers = managers;
this.mode = mode;
}
@Override
public AuthorizationDecision check(
Supplier<Authentication> authentication, T object) {
return switch (mode) {
case ALL -> checkAll(authentication, object);
case ANY -> checkAny(authentication, object);
};
}
private AuthorizationDecision checkAll(
Supplier<Authentication> authentication, T object) {
for (AuthorizationManager<T> manager : managers) {
AuthorizationDecision decision = manager.check(authentication, object);
if (decision == null || !decision.isGranted()) {
return new AuthorizationDecision(false);
}
}
return new AuthorizationDecision(true);
}
private AuthorizationDecision checkAny(
Supplier<Authentication> authentication, T object) {
for (AuthorizationManager<T> manager : managers) {
AuthorizationDecision decision = manager.check(authentication, object);
if (decision != null && decision.isGranted()) {
return new AuthorizationDecision(true);
}
}
return new AuthorizationDecision(false);
}
// ビルダーメソッド
public static <T> AuthorizationManager<T> allOf(
List<AuthorizationManager<T>> managers) {
return new CompositeAuthorizationManager<>(managers, CombineMode.ALL);
}
public static <T> AuthorizationManager<T> anyOf(
List<AuthorizationManager<T>> managers) {
return new CompositeAuthorizationManager<>(managers, CombineMode.ANY);
}
}
|
テスト戦略#
動的認可のテストには、複数のアプローチを組み合わせます。
単体テスト#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
|
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CustomPermissionEvaluatorTest {
@Mock
private UserPermissionService userPermissionService;
@Mock
private DocumentRepository documentRepository;
@Mock
private ProjectRepository projectRepository;
private CustomPermissionEvaluator evaluator;
private Authentication authentication;
@BeforeEach
void setUp() {
evaluator = new CustomPermissionEvaluator(
userPermissionService, documentRepository, projectRepository);
authentication = new UsernamePasswordAuthenticationToken(
"testuser",
null,
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
}
@Test
void hasPermission_ownerHasAllPermissions() {
// Given
User owner = new User();
owner.setUsername("testuser");
Document document = new Document();
document.setOwner(owner);
// When
boolean result = evaluator.hasPermission(
authentication, document, "write");
// Then
assertThat(result).isTrue();
}
@Test
void hasPermission_nonOwnerWithoutPermission_returnsFalse() {
// Given
User owner = new User();
owner.setUsername("otheruser");
Document document = new Document();
document.setId(1L);
document.setOwner(owner);
document.setSharedWith(Set.of());
when(userPermissionService.hasResourcePermission(
"testuser", "Document", 1L, "write"))
.thenReturn(false);
// When
boolean result = evaluator.hasPermission(
authentication, document, "write");
// Then
assertThat(result).isFalse();
}
@Test
void hasPermission_publicDocumentReadable() {
// Given
User owner = new User();
owner.setUsername("otheruser");
Document document = new Document();
document.setOwner(owner);
document.setPublic(true);
// When
boolean result = evaluator.hasPermission(
authentication, document, "read");
// Then
assertThat(result).isTrue();
}
}
|
統合テスト#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.jdbc.Sql;
import static org.assertj.core.api.Assertions.*;
@SpringBootTest
@Sql("/test-data.sql")
class DocumentServiceIntegrationTest {
@Autowired
private DocumentService documentService;
@Test
@WithMockUser(username = "owner")
void getDocument_asOwner_succeeds() {
Document document = documentService.getDocument(1L);
assertThat(document).isNotNull();
}
@Test
@WithMockUser(username = "stranger")
void getDocument_withoutPermission_throwsAccessDenied() {
assertThatThrownBy(() -> documentService.getDocument(1L))
.isInstanceOf(AccessDeniedException.class);
}
@Test
@WithMockUser(username = "editor")
void updateDocument_withWritePermission_succeeds() {
DocumentUpdateRequest request = new DocumentUpdateRequest(
"Updated Title", "Updated Content");
Document updated = documentService.updateDocument(1L, request);
assertThat(updated.getTitle()).isEqualTo("Updated Title");
}
@Test
@WithMockUser(username = "viewer")
void updateDocument_withReadOnlyPermission_throwsAccessDenied() {
DocumentUpdateRequest request = new DocumentUpdateRequest(
"Updated Title", "Updated Content");
assertThatThrownBy(() -> documentService.updateDocument(1L, request))
.isInstanceOf(AccessDeniedException.class);
}
}
|
テストデータ#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
-- test-data.sql
INSERT INTO users (id, username, password) VALUES
(1, 'owner', '{bcrypt}$2a$10$...'),
(2, 'editor', '{bcrypt}$2a$10$...'),
(3, 'viewer', '{bcrypt}$2a$10$...'),
(4, 'stranger', '{bcrypt}$2a$10$...');
INSERT INTO documents (id, title, content, owner_id, is_public) VALUES
(1, 'Test Document', 'Content', 1, false);
INSERT INTO resource_permissions (resource_type, resource_id, user_id, permission_name) VALUES
('Document', 1, 2, 'write'),
('Document', 1, 2, 'read'),
('Document', 1, 3, 'read');
|
パフォーマンス最適化#
動的認可はデータベースアクセスを伴うため、パフォーマンスに注意が必要です。
キャッシング戦略#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.stereotype.Service;
@Service
public class CachedPermissionService {
private final PermissionRepository permissionRepository;
@Cacheable(
value = "resourcePermissions",
key = "#username + ':' + #resourceType + ':' + #resourceId + ':' + #permission"
)
public boolean hasResourcePermission(
String username,
String resourceType,
Long resourceId,
String permission) {
// データベースクエリ
return permissionRepository.checkPermission(
username, resourceType, resourceId, permission);
}
@CacheEvict(
value = "resourcePermissions",
key = "#username + ':' + #resourceType + ':' + #resourceId + ':*'"
)
public void updatePermission(
String username,
String resourceType,
Long resourceId,
Set<String> permissions) {
// 権限更新処理
}
}
|
バッチ権限チェック#
一度に複数のリソースに対する権限をチェックする場合は、バッチ処理を検討します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
public interface ResourcePermissionRepository
extends JpaRepository<ResourcePermission, Long> {
@Query("""
SELECT rp.resourceId FROM ResourcePermission rp
WHERE rp.resourceType = :resourceType
AND rp.resourceId IN :resourceIds
AND rp.userId = :userId
AND rp.permissionName = :permission
""")
Set<Long> findAccessibleResourceIds(
@Param("resourceType") String resourceType,
@Param("resourceIds") Set<Long> resourceIds,
@Param("userId") Long userId,
@Param("permission") String permission);
}
@Service
public class BatchPermissionChecker {
public <T extends Identifiable> List<T> filterAccessible(
List<T> resources,
String resourceType,
Long userId,
String permission) {
Set<Long> resourceIds = resources.stream()
.map(Identifiable::getId)
.collect(Collectors.toSet());
Set<Long> accessibleIds = resourcePermissionRepository
.findAccessibleResourceIds(resourceType, resourceIds, userId, permission);
return resources.stream()
.filter(r -> accessibleIds.contains(r.getId()))
.toList();
}
}
|
認可の処理フロー全体像#
flowchart TB
subgraph "リクエスト処理"
A[HTTPリクエスト] --> B[SecurityFilterChain]
B --> C{DynamicRequestAuthorizationManager}
C -->|許可| D[Controller]
C -->|拒否| E[403 Forbidden]
end
subgraph "メソッドセキュリティ"
D --> F[Service Method]
F --> G{@PreAuthorize<br/>hasPermission}
G -->|許可| H[メソッド実行]
G -->|拒否| I[AccessDeniedException]
H --> J{@PostAuthorize<br/>hasPermission}
J -->|許可| K[結果返却]
J -->|拒否| I
end
subgraph "PermissionEvaluator"
G --> L[CustomPermissionEvaluator]
J --> L
L --> M{所有者チェック}
M -->|Yes| N[許可]
M -->|No| O{共有設定チェック}
O -->|Yes| N
O -->|No| P{DB権限チェック}
P -->|Yes| N
P -->|No| Q[拒否]
endまとめ#
Spring Securityの動的認可を実装することで、以下のメリットを得られます。
| 機能 |
実装方法 |
適用場面 |
| リソースベース認可 |
PermissionEvaluator |
「自分のデータのみ編集可能」 |
| 動的権限管理 |
DB連携AuthorizationManager |
再デプロイなしの権限変更 |
| 階層的権限 |
再帰的PermissionEvaluator |
フォルダ構造のアクセス制御 |
| 条件付き認可 |
カスタムAuthorizationManager |
時間帯制限、状態ベース制限 |
| URL動的認可 |
RequestAuthorizationManager |
管理画面からのエンドポイント制御 |
適切なキャッシング戦略と組み合わせることで、柔軟性とパフォーマンスを両立した認可システムを構築できます。
参考リンク#