Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package kr.devslab.kit.access;

/**
* One grant path through which a user currently holds a permission.
*
* <p>{@code roleCode} is always present (every permission is held via a
* role). {@code groupCode} is non-null when the role reached the user
* through a group membership, and null when the role is bound to the
* user directly.
*
* <p>A single {@code permissionCode} can yield multiple grants — the
* same user may hold the same permission both directly through a role
* and indirectly via a group. Callers should treat the collection as
* the full provenance list for that permission.
*/
public record PermissionGrant(
String permissionCode,
String roleCode,
String groupCode
) {

/**
* Compact, human-readable rendering used by the admin UI's permission
* tester: {@code "role:ADMIN"} for direct grants,
* {@code "group:eng-team>role:ADMIN"} for grants reached through a
* group.
*/
public String describe() {
if (groupCode == null) {
return "role:" + roleCode;
}
return "group:" + groupCode + ">role:" + roleCode;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package kr.devslab.kit.access.core.repository;

import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
Expand All @@ -24,4 +25,43 @@ WHERE rp.role_id IN (
)
""", nativeQuery = true)
Set<String> findCodesForUserId(@Param("userId") UUID userId);

/**
* Return every grant path that lands on the given (user, permission code)
* pair: each row is {permission_code, role_code, group_code (nullable)}.
*
* <p>Two unioned branches:
*
* <ul>
* <li>{@code platform_user_role} — the user holds the role directly;
* {@code group_code} is NULL.</li>
* <li>{@code platform_user_group} → {@code platform_group_role} — the
* user is a member of a group that holds the role; {@code group_code}
* carries the group's code.</li>
* </ul>
*
* <p>{@code Object[]} columns: index 0 = permission code, 1 = role code,
* 2 = group code (null for direct grants). The caller maps to
* {@code PermissionGrant}.
*/
@Query(value = """
SELECT p.code AS permission_code, r.code AS role_code, NULL AS group_code
FROM platform_permission p
JOIN platform_role_permission rp ON rp.permission_id = p.id
JOIN platform_role r ON r.id = rp.role_id
JOIN platform_user_role ur ON ur.role_id = r.id
WHERE ur.user_id = :userId AND p.code = :permissionCode
UNION ALL
SELECT p.code AS permission_code, r.code AS role_code, g.code AS group_code
FROM platform_permission p
JOIN platform_role_permission rp ON rp.permission_id = p.id
JOIN platform_role r ON r.id = rp.role_id
JOIN platform_group_role gr ON gr.role_id = r.id
JOIN platform_group g ON g.id = gr.group_id
JOIN platform_user_group ug ON ug.group_id = g.id
WHERE ug.user_id = :userId AND p.code = :permissionCode
""", nativeQuery = true)
List<Object[]> findGrantRowsForUserAndPermission(
@Param("userId") UUID userId,
@Param("permissionCode") String permissionCode);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package kr.devslab.kit.access.core.service;

import java.util.List;
import java.util.UUID;
import kr.devslab.kit.access.PermissionGrant;
import kr.devslab.kit.access.core.repository.JpaPlatformPermissionRepository;
import org.springframework.transaction.annotation.Transactional;

/**
* Returns the grant path(s) by which a user currently holds a given
* permission — used by the admin UI's diagnostics page to explain
* <em>why</em> a permission check resolved the way it did.
*
* <p>Lives next to {@link DefaultPermissionChecker} but is separate
* so the {@code PermissionChecker} contract stays thin (the checker
* answers yes/no on the hot path; this service is for human-facing
* explanations and isn't on the hot path).
*/
public class PermissionGrantQueryService {

private final JpaPlatformPermissionRepository permissionRepository;

public PermissionGrantQueryService(JpaPlatformPermissionRepository permissionRepository) {
this.permissionRepository = permissionRepository;
}

@Transactional(readOnly = true)
public List<PermissionGrant> findGrantsFor(UUID userId, String permissionCode) {
if (userId == null || permissionCode == null || permissionCode.isBlank()) {
return List.of();
}
return permissionRepository.findGrantRowsForUserAndPermission(userId, permissionCode).stream()
.map(row -> new PermissionGrant(
(String) row[0],
(String) row[1],
(String) row[2]))
.toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import kr.devslab.kit.access.PermissionGrant;
import kr.devslab.kit.access.core.repository.JpaPlatformPermissionRepository;
import kr.devslab.kit.access.core.service.PermissionGrantQueryService;
import kr.devslab.kit.admin.AdminApiPaths;
import kr.devslab.kit.core.id.TenantId;
import kr.devslab.kit.identity.AccountLoginException;
Expand Down Expand Up @@ -36,19 +38,22 @@ public class DiagnosticsController {
private final LocalLoginService loginService;
private final JpaPlatformUserAccountRepository userRepo;
private final JpaPlatformPermissionRepository permissionRepo;
private final PermissionGrantQueryService grantQueryService;
private final JpaPlatformMenuRepository menuRepo;
private final MenuTreeBuilder menuTreeBuilder;

public DiagnosticsController(
LocalLoginService loginService,
JpaPlatformUserAccountRepository userRepo,
JpaPlatformPermissionRepository permissionRepo,
PermissionGrantQueryService grantQueryService,
JpaPlatformMenuRepository menuRepo,
MenuTreeBuilder menuTreeBuilder
) {
this.loginService = loginService;
this.userRepo = userRepo;
this.permissionRepo = permissionRepo;
this.grantQueryService = grantQueryService;
this.menuRepo = menuRepo;
this.menuTreeBuilder = menuTreeBuilder;
}
Expand All @@ -70,10 +75,16 @@ public LoginTestResponse loginTest(@Valid @RequestBody LoginTestRequest req) {
public PermissionCheckResponse permissionCheck(@Valid @RequestBody PermissionCheckRequest req) {
Set<String> codes = permissionRepo.findCodesForUserId(req.userId());
boolean has = codes.contains(req.permissionCode());
// Surface the matched code under matchedVia so the UI can render it.
// The detailed "which role / which group granted it" path is queued
// as a follow-up once the access SPI exposes that detail.
List<String> matchedVia = has ? List.of(req.permissionCode()) : List.of();
// Resolve every grant path the user has on this permission so the UI
// can show "you got this via role:ADMIN" or "via group:eng-team>role:READ".
// Empty when has == false; can be multi-row when the user holds the
// permission both directly and through a group.
List<String> matchedVia = has
? grantQueryService.findGrantsFor(req.userId(), req.permissionCode()).stream()
.map(PermissionGrant::describe)
.distinct()
.toList()
: List.of();
return new PermissionCheckResponse(has, matchedVia);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import kr.devslab.kit.access.core.service.GroupMembershipService;
import kr.devslab.kit.access.core.service.GroupRoleService;
import kr.devslab.kit.access.core.service.GroupService;
import kr.devslab.kit.access.core.service.PermissionGrantQueryService;
import kr.devslab.kit.access.core.service.RolePermissionService;
import kr.devslab.kit.access.core.service.UserRoleService;
import kr.devslab.kit.access.policy.Policy;
Expand Down Expand Up @@ -86,4 +87,12 @@ public PermissionChecker permissionChecker(
) {
return new DefaultPermissionChecker(currentUserProvider, permissionRepository, policyEvaluator);
}

@Bean
@ConditionalOnMissingBean
public PermissionGrantQueryService permissionGrantQueryService(
JpaPlatformPermissionRepository permissionRepository
) {
return new PermissionGrantQueryService(permissionRepository);
}
}
Loading