fix #493: add effective access matrix

This commit is contained in:
devmrko
2026-06-26 14:14:22 +09:00
parent 210e0bd4d0
commit c8326f954d
12 changed files with 805 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
# Redmine #493 - 그룹/사용자/역할 Effective Matrix 설계
## 프로젝트 개요
VPD Backoffice는 Oracle Database VPD/ORDS 기능을 백오피스 권한 테이블로 제어하기 위한 Spring Boot 관리 도구다. 백오피스 사용자는 DB schema user가 아니라 Bearer Token으로 식별되는 애플리케이션 사용자이며, ORDS handler가 사용자 컨텍스트를 설정하면 VPD policy function이 사용자/그룹/역할/권한 테이블을 기준으로 행과 컬럼 접근을 제한한다.
## 목표
운영자가 사용자, 그룹, 역할 사이의 권한 상속 결과를 한 화면에서 확인할 수 있게 한다.
## 문제
- 사용자 화면은 직접 역할만 보여준다.
- 그룹 화면은 그룹 사용자와 그룹 역할이 분리되어 있어 상속 결과를 한 번에 보기 어렵다.
- 역할 화면은 역할이 어느 사용자/그룹에 영향을 주는지 보이지 않는다.
- 신규 운영자가 DB user와 백오피스 application user의 차이를 혼동할 수 있다.
## 설계
- `권한 관리 > 유효 권한` 메뉴와 `/effective-matrix` 화면을 추가한다.
- 신규 DB 테이블 없이 기존 조회 결과를 서비스에서 집계한다.
- 사용자 기준:
- 직접 역할
- 소속 그룹
- 그룹 상속 역할
- 최종 effective roles
- 연결 권한 수와 보호 객체 목록
- 그룹 기준:
- 포함 사용자
- 그룹에 부여된 역할
- 그룹 역할이 제공하는 권한 수와 보호 객체 목록
- 역할 기준:
- 직접 부여 사용자
- 역할이 부여된 그룹
- 그룹을 통해 영향을 받는 사용자
- 연결 권한 수와 보호 객체 목록
- 각 기준에는 select 필터를 둬서 한 항목만 빠르게 확인할 수 있게 한다.
- 빈 상태에는 등록 순서를 안내한다.
## 완료 기준
- `/effective-matrix`에서 사용자 선택 시 직접 역할과 그룹 상속 역할이 분리되어 보인다.
- 그룹 선택 시 포함 사용자, 부여 역할, 연결 권한 수가 한 화면에 보인다.
- 역할 선택 시 직접 사용자, 연결 그룹, 그룹 상속 사용자가 보인다.
- 메뉴에서 접근할 수 있다.
- 모바일 390px에서 가로 overflow가 없다.
## 검증
- `mvn test`
- Playwright:
- `/effective-matrix` desktop/mobile 렌더링
- 사용자/그룹/역할 select 변경 시 table filter 동작
- 모바일 overflow 없음

View File

@@ -0,0 +1,11 @@
package com.cloudhandson.vpdbackoffice.domain.effective;
import java.util.List;
public record EffectiveMatrixView(
List<UserEffectiveAccessView> users,
List<GroupEffectiveAccessView> groups,
List<RoleEffectiveImpactView> roles,
int permissionCount
) {
}

View File

@@ -0,0 +1,15 @@
package com.cloudhandson.vpdbackoffice.domain.effective;
import java.util.List;
public record GroupEffectiveAccessView(
long groupId,
String groupCode,
String groupName,
boolean active,
List<String> users,
List<String> roles,
int permissionCount,
List<String> objectNames
) {
}

View File

@@ -0,0 +1,16 @@
package com.cloudhandson.vpdbackoffice.domain.effective;
import java.util.List;
public record RoleEffectiveImpactView(
long roleId,
String roleName,
String maxSensitivityLevel,
List<String> directUsers,
List<String> groups,
List<String> inheritedUsers,
List<String> affectedUsers,
int permissionCount,
List<String> objectNames
) {
}

View File

@@ -0,0 +1,18 @@
package com.cloudhandson.vpdbackoffice.domain.effective;
import java.util.List;
public record UserEffectiveAccessView(
long userId,
String username,
String empNo,
String deptCode,
boolean active,
List<String> directRoles,
List<String> groups,
List<String> inheritedRoles,
List<String> effectiveRoles,
int permissionCount,
List<String> objectNames
) {
}

View File

@@ -0,0 +1,296 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.effective.EffectiveMatrixView;
import com.cloudhandson.vpdbackoffice.domain.effective.GroupEffectiveAccessView;
import com.cloudhandson.vpdbackoffice.domain.effective.RoleEffectiveImpactView;
import com.cloudhandson.vpdbackoffice.domain.effective.UserEffectiveAccessView;
import com.cloudhandson.vpdbackoffice.domain.group.AppGroup;
import com.cloudhandson.vpdbackoffice.domain.group.GroupRoleView;
import com.cloudhandson.vpdbackoffice.domain.group.GroupUserView;
import com.cloudhandson.vpdbackoffice.domain.permission.AppRole;
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionView;
import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
import com.cloudhandson.vpdbackoffice.domain.user.UserRoleView;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Service;
@Service
public class EffectiveMatrixService {
private final UserService userService;
private final GroupService groupService;
private final PermissionService permissionService;
public EffectiveMatrixService(
UserService userService,
GroupService groupService,
PermissionService permissionService
) {
this.userService = userService;
this.groupService = groupService;
this.permissionService = permissionService;
}
public EffectiveMatrixView matrix() {
List<AppUser> users = userService.findAll();
List<AppGroup> groups = groupService.findAll();
List<AppRole> roles = permissionService.findRoles();
List<UserRoleView> userRoles = userService.findUserRoles();
List<GroupUserView> groupUsers = groupService.findGroupUsers();
List<GroupRoleView> groupRoles = groupService.findGroupRoles();
List<PermissionView> permissions = permissionService.findPermissionViews();
Map<Long, Set<Long>> directRoleIdsByUser = directRoleIdsByUser(userRoles);
Map<Long, List<String>> directRoleNamesByUser = directRoleNamesByUser(userRoles);
Map<Long, Set<Long>> groupIdsByUser = groupIdsByUser(groupUsers);
Map<Long, List<String>> groupNamesByUser = groupNamesByUser(groupUsers);
Map<Long, List<String>> userNamesByGroup = userNamesByGroup(groupUsers);
Map<Long, Set<Long>> roleIdsByGroup = roleIdsByGroup(groupRoles);
Map<Long, List<String>> roleNamesByGroup = roleNamesByGroup(groupRoles);
Map<Long, List<String>> groupNamesByRole = groupNamesByRole(groupRoles);
Map<Long, List<String>> directUserNamesByRole = directUserNamesByRole(userRoles);
Map<Long, List<PermissionView>> permissionsByRole = permissionsByRole(permissions);
Map<Long, String> roleNameById = roleNameById(roles);
List<UserEffectiveAccessView> userViews = users.stream()
.map(user -> userView(
user,
directRoleIdsByUser,
directRoleNamesByUser,
groupIdsByUser,
groupNamesByUser,
roleIdsByGroup,
roleNameById,
permissionsByRole
))
.toList();
List<GroupEffectiveAccessView> groupViews = groups.stream()
.map(group -> groupView(group, userNamesByGroup, roleIdsByGroup, roleNamesByGroup, permissionsByRole))
.toList();
List<RoleEffectiveImpactView> roleViews = roles.stream()
.map(role -> roleView(role, directUserNamesByRole, groupNamesByRole, groupUsers, groupRoles, permissionsByRole))
.toList();
return new EffectiveMatrixView(userViews, groupViews, roleViews, permissions.size());
}
private UserEffectiveAccessView userView(
AppUser user,
Map<Long, Set<Long>> directRoleIdsByUser,
Map<Long, List<String>> directRoleNamesByUser,
Map<Long, Set<Long>> groupIdsByUser,
Map<Long, List<String>> groupNamesByUser,
Map<Long, Set<Long>> roleIdsByGroup,
Map<Long, String> roleNameById,
Map<Long, List<PermissionView>> permissionsByRole
) {
Set<Long> directRoleIds = directRoleIdsByUser.getOrDefault(user.userId(), Set.of());
Set<Long> inheritedRoleIds = new LinkedHashSet<>();
for (Long groupId : groupIdsByUser.getOrDefault(user.userId(), Set.of())) {
inheritedRoleIds.addAll(roleIdsByGroup.getOrDefault(groupId, Set.of()));
}
Set<Long> effectiveRoleIds = new LinkedHashSet<>(directRoleIds);
effectiveRoleIds.addAll(inheritedRoleIds);
return new UserEffectiveAccessView(
user.userId(),
user.username(),
user.empNo(),
user.deptCode(),
user.active(),
directRoleNamesByUser.getOrDefault(user.userId(), List.of()),
groupNamesByUser.getOrDefault(user.userId(), List.of()),
names(inheritedRoleIds, roleNameById),
names(effectiveRoleIds, roleNameById),
permissionCount(effectiveRoleIds, permissionsByRole),
objectNames(effectiveRoleIds, permissionsByRole)
);
}
private GroupEffectiveAccessView groupView(
AppGroup group,
Map<Long, List<String>> userNamesByGroup,
Map<Long, Set<Long>> roleIdsByGroup,
Map<Long, List<String>> roleNamesByGroup,
Map<Long, List<PermissionView>> permissionsByRole
) {
Set<Long> roleIds = roleIdsByGroup.getOrDefault(group.groupId(), Set.of());
return new GroupEffectiveAccessView(
group.groupId(),
group.groupCode(),
group.groupName(),
group.active(),
userNamesByGroup.getOrDefault(group.groupId(), List.of()),
roleNamesByGroup.getOrDefault(group.groupId(), List.of()),
permissionCount(roleIds, permissionsByRole),
objectNames(roleIds, permissionsByRole)
);
}
private RoleEffectiveImpactView roleView(
AppRole role,
Map<Long, List<String>> directUserNamesByRole,
Map<Long, List<String>> groupNamesByRole,
List<GroupUserView> groupUsers,
List<GroupRoleView> groupRoles,
Map<Long, List<PermissionView>> permissionsByRole
) {
Set<Long> roleIds = Set.of(role.roleId());
Set<String> inheritedUsers = new LinkedHashSet<>();
Set<Long> roleGroupIds = new LinkedHashSet<>();
for (GroupRoleView groupRole : groupRoles) {
if (groupRole.roleId() == role.roleId()) {
roleGroupIds.add(groupRole.groupId());
}
}
for (GroupUserView groupUser : groupUsers) {
if (roleGroupIds.contains(groupUser.groupId())) {
inheritedUsers.add(groupUser.username());
}
}
Set<String> affectedUsers = new LinkedHashSet<>(directUserNamesByRole.getOrDefault(role.roleId(), List.of()));
affectedUsers.addAll(inheritedUsers);
return new RoleEffectiveImpactView(
role.roleId(),
role.roleName(),
role.maxSensitivityLevel(),
directUserNamesByRole.getOrDefault(role.roleId(), List.of()),
groupNamesByRole.getOrDefault(role.roleId(), List.of()),
new ArrayList<>(inheritedUsers),
new ArrayList<>(affectedUsers),
permissionCount(roleIds, permissionsByRole),
objectNames(roleIds, permissionsByRole)
);
}
private Map<Long, Set<Long>> directRoleIdsByUser(List<UserRoleView> userRoles) {
Map<Long, Set<Long>> result = new LinkedHashMap<>();
for (UserRoleView view : userRoles) {
result.computeIfAbsent(view.userId(), key -> new LinkedHashSet<>()).add(view.roleId());
}
return result;
}
private Map<Long, List<String>> directRoleNamesByUser(List<UserRoleView> userRoles) {
Map<Long, Set<String>> result = new LinkedHashMap<>();
for (UserRoleView view : userRoles) {
result.computeIfAbsent(view.userId(), key -> new LinkedHashSet<>()).add(view.roleName());
}
return toListMap(result);
}
private Map<Long, Set<Long>> groupIdsByUser(List<GroupUserView> groupUsers) {
Map<Long, Set<Long>> result = new LinkedHashMap<>();
for (GroupUserView view : groupUsers) {
result.computeIfAbsent(view.userId(), key -> new LinkedHashSet<>()).add(view.groupId());
}
return result;
}
private Map<Long, List<String>> groupNamesByUser(List<GroupUserView> groupUsers) {
Map<Long, Set<String>> result = new LinkedHashMap<>();
for (GroupUserView view : groupUsers) {
result.computeIfAbsent(view.userId(), key -> new LinkedHashSet<>()).add(groupLabel(view.groupCode(), view.groupName()));
}
return toListMap(result);
}
private Map<Long, List<String>> userNamesByGroup(List<GroupUserView> groupUsers) {
Map<Long, Set<String>> result = new LinkedHashMap<>();
for (GroupUserView view : groupUsers) {
result.computeIfAbsent(view.groupId(), key -> new LinkedHashSet<>()).add(view.username());
}
return toListMap(result);
}
private Map<Long, Set<Long>> roleIdsByGroup(List<GroupRoleView> groupRoles) {
Map<Long, Set<Long>> result = new LinkedHashMap<>();
for (GroupRoleView view : groupRoles) {
result.computeIfAbsent(view.groupId(), key -> new LinkedHashSet<>()).add(view.roleId());
}
return result;
}
private Map<Long, List<String>> roleNamesByGroup(List<GroupRoleView> groupRoles) {
Map<Long, Set<String>> result = new LinkedHashMap<>();
for (GroupRoleView view : groupRoles) {
result.computeIfAbsent(view.groupId(), key -> new LinkedHashSet<>()).add(view.roleName());
}
return toListMap(result);
}
private Map<Long, List<String>> groupNamesByRole(List<GroupRoleView> groupRoles) {
Map<Long, Set<String>> result = new LinkedHashMap<>();
for (GroupRoleView view : groupRoles) {
result.computeIfAbsent(view.roleId(), key -> new LinkedHashSet<>()).add(groupLabel(view.groupCode(), view.groupName()));
}
return toListMap(result);
}
private Map<Long, List<String>> directUserNamesByRole(List<UserRoleView> userRoles) {
Map<Long, Set<String>> result = new LinkedHashMap<>();
for (UserRoleView view : userRoles) {
result.computeIfAbsent(view.roleId(), key -> new LinkedHashSet<>()).add(view.username());
}
return toListMap(result);
}
private Map<Long, List<PermissionView>> permissionsByRole(List<PermissionView> permissions) {
Map<Long, List<PermissionView>> result = new LinkedHashMap<>();
for (PermissionView permission : permissions) {
result.computeIfAbsent(permission.roleId(), key -> new ArrayList<>()).add(permission);
}
return result;
}
private Map<Long, String> roleNameById(List<AppRole> roles) {
Map<Long, String> result = new LinkedHashMap<>();
for (AppRole role : roles) {
result.put(role.roleId(), role.roleName());
}
return result;
}
private List<String> names(Set<Long> ids, Map<Long, String> nameById) {
return ids.stream()
.map(nameById::get)
.filter(name -> name != null && !name.isBlank())
.toList();
}
private int permissionCount(Set<Long> roleIds, Map<Long, List<PermissionView>> permissionsByRole) {
return roleIds.stream()
.mapToInt(roleId -> permissionsByRole.getOrDefault(roleId, List.of()).size())
.sum();
}
private List<String> objectNames(Set<Long> roleIds, Map<Long, List<PermissionView>> permissionsByRole) {
Set<String> result = new LinkedHashSet<>();
for (Long roleId : roleIds) {
for (PermissionView permission : permissionsByRole.getOrDefault(roleId, List.of())) {
result.add(permission.objectName() + " / " + permission.permissionEffect());
}
}
return new ArrayList<>(result);
}
private Map<Long, List<String>> toListMap(Map<Long, Set<String>> source) {
Map<Long, List<String>> result = new LinkedHashMap<>();
source.forEach((key, value) -> result.put(key, new ArrayList<>(value)));
return result;
}
private String groupLabel(String groupCode, String groupName) {
return groupCode + " / " + groupName;
}
}

View File

@@ -0,0 +1,31 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.effective.EffectiveMatrixView;
import com.cloudhandson.vpdbackoffice.service.EffectiveMatrixService;
import java.util.List;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class EffectiveMatrixController {
private final EffectiveMatrixService matrixService;
public EffectiveMatrixController(EffectiveMatrixService matrixService) {
this.matrixService = matrixService;
}
@GetMapping("/effective-matrix")
public String matrix(Model model) {
try {
model.addAttribute("matrix", matrixService.matrix());
} catch (DataAccessException e) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(e);
model.addAttribute("matrix", new EffectiveMatrixView(List.of(), List.of(), List.of(), 0));
model.addAttribute("runtimeError", message);
}
return "effective-matrix";
}
}

View File

@@ -587,6 +587,57 @@ body {
margin-bottom: 0; margin-bottom: 0;
} }
.matrix-summary {
display: grid;
gap: .75rem;
grid-template-columns: repeat(auto-fit, minmax(min(140px, 100%), 1fr));
margin-bottom: 1rem;
}
.matrix-summary > div {
background: var(--rw-surface-muted);
border: 1px solid var(--rw-border);
border-radius: 8px;
padding: .85rem;
}
.matrix-summary span {
color: var(--rw-muted);
display: block;
font-size: .78rem;
font-weight: 800;
text-transform: uppercase;
}
.matrix-summary strong {
display: block;
font-size: 1.6rem;
line-height: 1.1;
margin-top: .25rem;
}
.setup-steps {
color: var(--rw-muted);
margin: 0;
padding-left: 1.25rem;
}
.setup-steps li + li {
margin-top: .25rem;
}
.matrix-filter {
color: var(--rw-muted);
font-size: .82rem;
font-weight: 700;
min-width: min(280px, 100%);
}
.matrix-list {
max-width: 28rem;
overflow-wrap: anywhere;
}
.policy-apply-flow { .policy-apply-flow {
align-items: center; align-items: center;
display: flex; display: flex;

View File

@@ -486,6 +486,38 @@ function initTokenContextPreviews() {
}); });
} }
function filterEffectiveMatrixTable(select) {
const table = document.getElementById(select.dataset.effectiveFilter || '');
if (!table) {
return;
}
const value = select.value || '';
table.querySelectorAll('.effective-empty-row').forEach((row) => row.remove());
let visibleRows = 0;
table.querySelectorAll('tbody tr[data-effective-id]').forEach((row) => {
const visible = !value || row.dataset.effectiveId === value;
row.hidden = !visible;
if (visible) {
visibleRows += 1;
}
});
if (value && visibleRows === 0) {
const body = table.querySelector('tbody');
const columnCount = table.querySelectorAll('thead th').length || 1;
const row = document.createElement('tr');
row.className = 'effective-empty-row';
row.innerHTML = `<td colspan="${columnCount}" class="text-muted">${table.dataset.emptyMessage || '선택한 항목이 없습니다.'}</td>`;
body?.appendChild(row);
}
}
function initEffectiveMatrixFilters() {
document.querySelectorAll('[data-effective-filter]').forEach((select) => {
select.addEventListener('change', () => filterEffectiveMatrixTable(select));
filterEffectiveMatrixTable(select);
});
}
function sqlLiteral(value) { function sqlLiteral(value) {
return `'${String(value || '').replaceAll("'", "''")}'`; return `'${String(value || '').replaceAll("'", "''")}'`;
} }
@@ -791,6 +823,7 @@ document.addEventListener('DOMContentLoaded', () => {
filterGroupDetail(select.id); filterGroupDetail(select.id);
}); });
initTokenContextPreviews(); initTokenContextPreviews();
initEffectiveMatrixFilters();
const objectSelect = document.querySelector('select[name="objectRef"]'); const objectSelect = document.querySelector('select[name="objectRef"]');
if (objectSelect) { if (objectSelect) {
objectSelect.addEventListener('change', () => { objectSelect.addEventListener('change', () => {

View File

@@ -0,0 +1,201 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('유효 권한 매트릭스')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>유효 권한 매트릭스</h1>
<p>백오피스 사용자, 그룹, 역할, 권한의 최종 상속 결과를 확인합니다.</p>
</div>
<div class="alert alert-warning" th:if="${runtimeError}">
<strong th:text="${runtimeError.title()}">DB 연결 설정이 필요합니다.</strong>
<span th:text="${runtimeError.message()}">message</span>
<div th:if="${runtimeError.showSupportCommand()}">
<code>./run.sh backoffice-support</code>
</div>
</div>
<section th:replace="~{fragments/layout :: architectureStrip('permission')}"></section>
<section class="content-band">
<div class="section-heading">
<div>
<h2>권한 해석 기준</h2>
<p class="section-subtitle">여기의 사용자는 Oracle DB schema user가 아니라 Bearer Token으로 식별되는 백오피스 application user입니다.</p>
</div>
<a class="btn btn-sm rw-btn-secondary" href="/permissions">권한 관리로 이동</a>
</div>
<div class="matrix-summary">
<div>
<span>사용자</span>
<strong th:text="${#lists.size(matrix.users())}">0</strong>
</div>
<div>
<span>그룹</span>
<strong th:text="${#lists.size(matrix.groups())}">0</strong>
</div>
<div>
<span>역할</span>
<strong th:text="${#lists.size(matrix.roles())}">0</strong>
</div>
<div>
<span>권한</span>
<strong th:text="${matrix.permissionCount()}">0</strong>
</div>
</div>
<ol class="setup-steps">
<li>사용자 관리에서 application user를 등록합니다.</li>
<li>그룹 관리에서 사용자를 그룹에 넣고 그룹에 역할을 부여합니다.</li>
<li>권한 관리에서 역할에 TABLE/VIEW 행 규칙과 컬럼 NULL 정책을 부여합니다.</li>
<li>토큰을 발급하면 ORDS handler가 token user context를 설정하고 VPD가 이 권한을 적용합니다.</li>
</ol>
</section>
<section class="content-band">
<div class="section-heading">
<div>
<h2>사용자 기준</h2>
<p class="section-subtitle">직접 역할과 그룹을 통해 상속된 역할을 분리해서 봅니다.</p>
</div>
<label class="matrix-filter">
사용자
<select class="form-select form-select-sm" data-effective-filter="user-effective-table">
<option value="">전체 사용자</option>
<option th:each="row : ${matrix.users()}" th:value="${row.userId()}" th:text="${row.username()}"></option>
</select>
</label>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle" id="user-effective-table" data-empty-message="선택한 사용자에 대한 유효 권한이 없습니다. 사용자 등록, 그룹 배정, 역할 부여 순서로 확인하세요.">
<thead>
<tr>
<th>사용자</th>
<th>직접 역할</th>
<th>소속 그룹</th>
<th>그룹 상속 역할</th>
<th>최종 역할</th>
<th>권한</th>
<th>보호 객체</th>
</tr>
</thead>
<tbody>
<tr th:each="row : ${matrix.users()}" th:attr="data-effective-id=${row.userId()}">
<td>
<strong th:text="${row.username()}">agent_hr</strong>
<div class="text-muted small" th:text="${row.empNo() + ' / ' + row.deptCode()}">E100 / HR</div>
<span class="badge" th:classappend="${row.active()} ? ' text-bg-success' : ' text-bg-secondary'"
th:text="${row.active()} ? 'ACTIVE APP USER' : 'INACTIVE APP USER'">ACTIVE</span>
</td>
<td th:text="${#lists.isEmpty(row.directRoles()) ? '-' : #strings.listJoin(row.directRoles(), ', ')}">-</td>
<td th:text="${#lists.isEmpty(row.groups()) ? '-' : #strings.listJoin(row.groups(), ', ')}">-</td>
<td th:text="${#lists.isEmpty(row.inheritedRoles()) ? '-' : #strings.listJoin(row.inheritedRoles(), ', ')}">-</td>
<td><strong th:text="${#lists.isEmpty(row.effectiveRoles()) ? '-' : #strings.listJoin(row.effectiveRoles(), ', ')}">-</strong></td>
<td><span class="badge text-bg-secondary" th:text="${row.permissionCount()}">0</span></td>
<td class="matrix-list" th:text="${#lists.isEmpty(row.objectNames()) ? '권한 객체 없음' : #strings.listJoin(row.objectNames(), ', ')}">objects</td>
</tr>
<tr th:if="${#lists.isEmpty(matrix.users())}">
<td colspan="7" class="text-muted">등록된 사용자가 없습니다. 사용자 관리에서 application user를 먼저 등록하세요.</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="content-band">
<div class="section-heading">
<div>
<h2>그룹 기준</h2>
<p class="section-subtitle">그룹에 속한 사용자와 그룹에 부여된 역할이 제공하는 권한을 봅니다.</p>
</div>
<label class="matrix-filter">
그룹
<select class="form-select form-select-sm" data-effective-filter="group-effective-table">
<option value="">전체 그룹</option>
<option th:each="row : ${matrix.groups()}" th:value="${row.groupId()}" th:text="${row.groupCode() + ' / ' + row.groupName()}"></option>
</select>
</label>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle" id="group-effective-table" data-empty-message="선택한 그룹의 사용자/역할 매핑이 없습니다. 그룹 사용자와 그룹 역할을 먼저 등록하세요.">
<thead>
<tr>
<th>그룹</th>
<th>포함 사용자</th>
<th>부여 역할</th>
<th>권한</th>
<th>보호 객체</th>
</tr>
</thead>
<tbody>
<tr th:each="row : ${matrix.groups()}" th:attr="data-effective-id=${row.groupId()}">
<td>
<code th:text="${row.groupCode()}">GROUP</code>
<div th:text="${row.groupName()}">그룹명</div>
<span class="badge" th:classappend="${row.active()} ? ' text-bg-success' : ' text-bg-secondary'"
th:text="${row.active()} ? 'ACTIVE' : 'INACTIVE'">ACTIVE</span>
</td>
<td th:text="${#lists.isEmpty(row.users()) ? '사용자 없음' : #strings.listJoin(row.users(), ', ')}">users</td>
<td th:text="${#lists.isEmpty(row.roles()) ? '역할 없음' : #strings.listJoin(row.roles(), ', ')}">roles</td>
<td><span class="badge text-bg-secondary" th:text="${row.permissionCount()}">0</span></td>
<td class="matrix-list" th:text="${#lists.isEmpty(row.objectNames()) ? '권한 객체 없음' : #strings.listJoin(row.objectNames(), ', ')}">objects</td>
</tr>
<tr th:if="${#lists.isEmpty(matrix.groups())}">
<td colspan="5" class="text-muted">등록된 그룹이 없습니다. 그룹 관리에서 그룹을 먼저 생성하세요.</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="content-band">
<div class="section-heading">
<div>
<h2>역할 기준</h2>
<p class="section-subtitle">역할이 직접 사용자와 그룹 사용자에게 어떤 영향을 주는지 봅니다.</p>
</div>
<label class="matrix-filter">
역할
<select class="form-select form-select-sm" data-effective-filter="role-effective-table">
<option value="">전체 역할</option>
<option th:each="row : ${matrix.roles()}" th:value="${row.roleId()}" th:text="${row.roleName()}"></option>
</select>
</label>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle" id="role-effective-table" data-empty-message="선택한 역할의 사용자/그룹/권한 연결이 없습니다. 사용자 역할, 그룹 역할, 권한 등록을 확인하세요.">
<thead>
<tr>
<th>역할</th>
<th>직접 사용자</th>
<th>연결 그룹</th>
<th>그룹 상속 사용자</th>
<th>영향 사용자</th>
<th>권한</th>
<th>보호 객체</th>
</tr>
</thead>
<tbody>
<tr th:each="row : ${matrix.roles()}" th:attr="data-effective-id=${row.roleId()}">
<td>
<strong th:text="${row.roleName()}">ROLE</strong>
<div class="text-muted small">민감도 <span th:text="${row.maxSensitivityLevel()}">PUBLIC</span></div>
</td>
<td th:text="${#lists.isEmpty(row.directUsers()) ? '직접 사용자 없음' : #strings.listJoin(row.directUsers(), ', ')}">users</td>
<td th:text="${#lists.isEmpty(row.groups()) ? '그룹 없음' : #strings.listJoin(row.groups(), ', ')}">groups</td>
<td th:text="${#lists.isEmpty(row.inheritedUsers()) ? '상속 사용자 없음' : #strings.listJoin(row.inheritedUsers(), ', ')}">group users</td>
<td><strong th:text="${#lists.isEmpty(row.affectedUsers()) ? '-' : #strings.listJoin(row.affectedUsers(), ', ')}">affected</strong></td>
<td><span class="badge text-bg-secondary" th:text="${row.permissionCount()}">0</span></td>
<td class="matrix-list" th:text="${#lists.isEmpty(row.objectNames()) ? '권한 객체 없음' : #strings.listJoin(row.objectNames(), ', ')}">objects</td>
</tr>
<tr th:if="${#lists.isEmpty(matrix.roles())}">
<td colspan="7" class="text-muted">등록된 역할이 없습니다. 역할 관리에서 역할을 먼저 생성하세요.</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>

View File

@@ -21,6 +21,7 @@
<a class="nav-link" href="/users">사용자</a> <a class="nav-link" href="/users">사용자</a>
<a class="nav-link" href="/groups">그룹</a> <a class="nav-link" href="/groups">그룹</a>
<a class="nav-link" href="/roles">역할</a> <a class="nav-link" href="/roles">역할</a>
<a class="nav-link" href="/effective-matrix">유효 권한</a>
<a class="nav-link" href="/permissions">권한</a> <a class="nav-link" href="/permissions">권한</a>
<a class="nav-link" href="/tokens">토큰</a> <a class="nav-link" href="/tokens">토큰</a>
</div> </div>

View File

@@ -0,0 +1,78 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import com.cloudhandson.vpdbackoffice.domain.effective.EffectiveMatrixView;
import com.cloudhandson.vpdbackoffice.domain.group.AppGroup;
import com.cloudhandson.vpdbackoffice.domain.group.GroupRoleView;
import com.cloudhandson.vpdbackoffice.domain.group.GroupUserView;
import com.cloudhandson.vpdbackoffice.domain.permission.AppRole;
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionView;
import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
import com.cloudhandson.vpdbackoffice.domain.user.UserRoleView;
import java.util.List;
import org.junit.jupiter.api.Test;
class EffectiveMatrixServiceTest {
@Test
void combinesDirectAndGroupInheritedRoles() {
EffectiveMatrixService service = new EffectiveMatrixService(
new UserService(null, null) {
@Override
public List<AppUser> findAll() {
return List.of(new AppUser(1L, "agent_ops", "E100", "OPS", "N", "Y"));
}
@Override
public List<UserRoleView> findUserRoles() {
return List.of(new UserRoleView(1L, "agent_ops", 10L, "DIRECT_ROLE"));
}
},
new GroupService(null, null) {
@Override
public List<AppGroup> findAll() {
return List.of(new AppGroup(100L, "OPS_GROUP", "운영그룹", null, "Y"));
}
@Override
public List<GroupUserView> findGroupUsers() {
return List.of(new GroupUserView(100L, "OPS_GROUP", "운영그룹", 1L, "agent_ops"));
}
@Override
public List<GroupRoleView> findGroupRoles() {
return List.of(new GroupRoleView(100L, "OPS_GROUP", "운영그룹", 20L, "GROUP_ROLE"));
}
},
new PermissionService(null, null, null) {
@Override
public List<AppRole> findRoles() {
return List.of(
new AppRole(10L, "DIRECT_ROLE", null, "PUBLIC"),
new AppRole(20L, "GROUP_ROLE", null, "INTERNAL")
);
}
@Override
public List<PermissionView> findPermissionViews() {
return List.of(
new PermissionView(1000L, 10L, "DIRECT_ROLE", 1L, "BOARD_POSTS", "SELECT", "ALLOW", "ALL", null, "ALL ROWS", "모든 민감 컬럼 NULL 처리"),
new PermissionView(1001L, 20L, "GROUP_ROLE", 2L, "BOARD_ASSIGNMENTS", "SELECT", "ALLOW", "ALL", null, "ALL ROWS", "모든 민감 컬럼 NULL 처리")
);
}
}
);
EffectiveMatrixView matrix = service.matrix();
assertThat(matrix.users()).hasSize(1);
assertThat(matrix.users().getFirst().directRoles()).containsExactly("DIRECT_ROLE");
assertThat(matrix.users().getFirst().inheritedRoles()).containsExactly("GROUP_ROLE");
assertThat(matrix.users().getFirst().effectiveRoles()).containsExactly("DIRECT_ROLE", "GROUP_ROLE");
assertThat(matrix.users().getFirst().permissionCount()).isEqualTo(2);
assertThat(matrix.groups().getFirst().users()).containsExactly("agent_ops");
assertThat(matrix.groups().getFirst().roles()).containsExactly("GROUP_ROLE");
assertThat(matrix.roles().get(1).affectedUsers()).containsExactly("agent_ops");
}
}