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 --> I

AuthorizationManagerインターフェースは非常にシンプルです。

 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 管理画面からのエンドポイント制御

適切なキャッシング戦略と組み合わせることで、柔軟性とパフォーマンスを両立した認可システムを構築できます。

参考リンク