[UX] #605 make permission wizard safety review actionable

This commit is contained in:
devmrko
2026-06-30 20:39:54 +09:00
parent 1766696ab4
commit f162851bc6
7 changed files with 289 additions and 25 deletions

View File

@@ -195,25 +195,32 @@ public class PermissionController {
@PostMapping("/permissions")
public String save(
@RequestParam long roleId,
@RequestParam String objectRef,
@RequestParam(required = false) Long roleId,
@RequestParam(required = false) String objectRef,
@RequestParam(defaultValue = "ALLOW") String permissionEffect,
@RequestParam(required = false) List<String> ruleColumn,
@RequestParam List<String> ruleType,
@RequestParam(required = false) List<String> ruleType,
@RequestParam(required = false) List<String> ruleValue,
@RequestParam(required = false) String visibleColumns,
RedirectAttributes redirectAttributes
) {
long objectId = resolveObjectId(objectRef);
permissionService.savePermissionSet(new PermissionSetCommand(
roleId,
objectId,
"SELECT",
permissionEffect,
buildRules(ruleColumn, ruleType, ruleValue),
splitColumns(visibleColumns)
));
redirectAttributes.addFlashAttribute("message", "권한을 저장했습니다.");
try {
if (roleId == null) {
throw new AppException("권한을 적용할 역할을 선택하세요.");
}
long objectId = resolveObjectId(objectRef);
permissionService.savePermissionSet(new PermissionSetCommand(
roleId,
objectId,
"SELECT",
permissionEffect,
buildRules(ruleColumn, ruleType, ruleValue),
splitColumns(visibleColumns)
));
redirectAttributes.addFlashAttribute("message", "권한을 저장했습니다.");
} catch (AppException | IllegalArgumentException exception) {
redirectAttributes.addFlashAttribute("error", exception.getMessage());
}
return "redirect:/permissions";
}

View File

@@ -531,6 +531,11 @@ body {
min-width: 0;
}
.section-description {
color: var(--rw-muted);
margin: .2rem 0 0;
}
.wizard-progress {
display: flex;
flex-wrap: wrap;
@@ -584,6 +589,15 @@ body {
overflow-wrap: anywhere;
}
.wizard-validation {
background: #fff4e5;
border: 1px solid #e7a84c;
border-radius: 8px;
color: #7a4600;
font-weight: 700;
padding: .75rem .9rem;
}
.wizard-panel {
border: 1px solid var(--rw-border);
border-radius: 8px;
@@ -615,6 +629,18 @@ body {
margin: .15rem 0 0;
}
.rule-safety-note {
background: var(--rw-primary-soft);
border-left: 3px solid var(--rw-primary);
color: var(--rw-text);
margin: .5rem 0 .75rem;
padding: .65rem .75rem;
}
.policy-preview-emphasis {
border-color: var(--rw-primary) !important;
}
.wizard-step-number {
align-items: center;
background: var(--rw-primary-soft);

View File

@@ -770,6 +770,10 @@ function updatePermissionWizardPreview(root = document) {
const rules = collectWizardRules(wizard);
const predicates = collectWizardPredicates(wizard);
const ruleText = rules.length ? rules.join(', ') : '행 규칙 없음';
const hasRole = Boolean(roleSelect?.value);
const hasObject = Boolean(objectSelect?.value);
const hasAllRule = rules.includes('ALL');
const hasAllRuleConflict = hasAllRule && rules.length > 1;
const directUsers = splitList(roleOption?.dataset.directUsers || '');
const groups = splitList(roleOption?.dataset.groups || '');
const groupUsers = splitList(roleOption?.dataset.groupUsers || '');
@@ -790,11 +794,23 @@ function updatePermissionWizardPreview(root = document) {
? '마스킹 대상 컬럼 없음'
: (nullColumns.length ? `NULL 처리: ${nullColumns.join(', ')}` : '선택한 마스킹 컬럼 모두 원문 표시 허용');
const affectedPrincipals = `직접 사용자 ${directUsers.length}명 / 그룹 ${groups.length}개 / 그룹 상속 사용자 ${groupUsers.length}`;
const readiness = !hasRole
? '역할을 선택하세요'
: !hasObject
? '보호 객체를 선택하세요'
: hasAllRuleConflict
? 'ALL 규칙을 단독으로 정리하세요'
: '저장 전 검토 가능';
const saveGuard = hasAllRule
? `${effect === 'DENY' ? '전체 행 거부' : '전체 행 허용'}입니다. ${affectedPrincipals}에게 영향을 줄 수 있습니다.`
: `${effect === 'DENY' ? '선택 조건의 행을 거부' : '선택 조건의 행만 허용'}합니다. 저장 후 결과 확인에서 실제 VPD predicate를 검증하세요.`;
wizard.querySelector('[data-wizard-summary="role"]').textContent = selectedText(roleSelect);
wizard.querySelector('[data-wizard-summary="object"]').textContent = selectedText(objectSelect);
wizard.querySelector('[data-wizard-summary="effect"]').textContent = effect;
wizard.querySelector('[data-wizard-summary="rules"]').textContent = ruleText;
wizard.querySelector('[data-wizard-summary="affected"]').textContent = hasRole ? affectedPrincipals : '역할 선택 후 확인';
wizard.querySelector('[data-wizard-summary="readiness"]').textContent = readiness;
setWizardPreview(wizard, 'role', selectedText(roleSelect));
setWizardPreview(wizard, 'sensitivity', roleOption?.dataset.maxSensitivity || 'PUBLIC');
@@ -810,6 +826,84 @@ function updatePermissionWizardPreview(root = document) {
setWizardPreview(wizard, 'predicatePreview', predicateText);
setWizardPreview(wizard, 'columnPolicy', columnPolicy);
setWizardPreview(wizard, 'nullPolicy', nullPolicy);
setWizardPreview(wizard, 'saveGuard', hasRole && hasObject ? saveGuard : '역할과 보호 객체를 선택하면 저장 영향을 계산합니다.');
}
function permissionWizardIndicators(wizard) {
return wizard.closest('.content-band')?.querySelectorAll('[data-wizard-target]') || [];
}
function showPermissionWizardValidation(wizard, message) {
const validation = wizard.querySelector('[data-wizard-validation]');
if (!validation) {
return;
}
validation.textContent = message;
validation.hidden = false;
}
function clearPermissionWizardValidation(wizard) {
const validation = wizard.querySelector('[data-wizard-validation]');
if (validation) {
validation.hidden = true;
validation.textContent = '';
}
}
function permissionWizardRuleValidationMessage(wizard) {
const rows = Array.from(wizard.querySelectorAll('.rule-row'));
if (!rows.length) {
return '행 규칙을 하나 이상 추가하세요.';
}
const values = rows.map((row) => ({
column: row.querySelector('[name="ruleColumn"]')?.value || '',
type: row.querySelector('[name="ruleType"]')?.value || '',
value: row.querySelector('[name="ruleValue"]')?.value.trim() || ''
}));
if (values.some((rule) => rule.type === 'ALL') && values.length > 1) {
return 'ALL 규칙은 다른 조건 규칙과 함께 저장할 수 없습니다. ALL만 남기거나 ALL을 삭제하세요.';
}
const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO', 'TAG'];
const seen = new Set();
for (const [index, rule] of values.entries()) {
const position = index + 1;
if (!rule.type) {
return `${position}번째 행 규칙 유형을 선택하세요.`;
}
if (['=', '!='].includes(rule.type) && !rule.column) {
return `${position}번째 ${rule.type} 규칙에는 비교할 컬럼이 필요합니다.`;
}
if (valueRequired.includes(rule.type) && !rule.value) {
return `${position}번째 ${rule.type} 규칙의 값을 입력하세요.`;
}
if (rule.type === 'TAG' && rule.value && !/^[A-Za-z0-9_-]+$/.test(rule.value)) {
return `${position}번째 TAG 값은 영문·숫자, '_' 또는 '-'만 사용할 수 있습니다.`;
}
const signature = `${rule.column}:${rule.type}:${rule.value.toUpperCase()}`;
if (seen.has(signature)) {
return `${position}번째 행 규칙이 앞의 규칙과 중복됩니다.`;
}
seen.add(signature);
}
return '';
}
function validatePermissionWizardStep(wizard, step) {
if (step === 1 && !wizard.querySelector('[name="roleId"]')?.value) {
return '권한을 적용할 역할을 선택하세요.';
}
if (step === 2 && !wizard.querySelector('[name="objectRef"]')?.value) {
return '권한을 적용할 보호 객체를 선택하세요.';
}
if (step === 3) {
return permissionWizardRuleValidationMessage(wizard);
}
return '';
}
function focusPermissionWizardStep(wizard, step) {
const panel = wizard.querySelector(`[data-wizard-step="${step}"]`);
panel?.querySelector('select, input, button')?.focus();
}
function activatePermissionWizardStep(wizard, step) {
@@ -819,8 +913,10 @@ function activatePermissionWizardStep(wizard, step) {
panels.forEach((panel) => {
panel.classList.toggle('active', Number(panel.dataset.wizardStep) === nextStep);
});
document.querySelectorAll('[data-wizard-target]').forEach((button) => {
button.classList.toggle('active', Number(button.dataset.wizardTarget) === nextStep);
permissionWizardIndicators(wizard).forEach((button) => {
const active = Number(button.dataset.wizardTarget) === nextStep;
button.classList.toggle('active', active);
button.setAttribute('aria-current', active ? 'step' : 'false');
});
wizard.dataset.currentStep = String(nextStep);
const prev = wizard.querySelector('[data-wizard-prev]');
@@ -838,6 +934,27 @@ function activatePermissionWizardStep(wizard, step) {
updatePermissionWizardPreview(document);
}
function movePermissionWizard(wizard, targetStep) {
const currentStep = Number(wizard.dataset.currentStep || '1');
if (targetStep <= currentStep) {
clearPermissionWizardValidation(wizard);
activatePermissionWizardStep(wizard, targetStep);
return true;
}
for (let step = 1; step < targetStep; step += 1) {
const message = validatePermissionWizardStep(wizard, step);
if (message) {
activatePermissionWizardStep(wizard, step);
showPermissionWizardValidation(wizard, message);
focusPermissionWizardStep(wizard, step);
return false;
}
}
clearPermissionWizardValidation(wizard);
activatePermissionWizardStep(wizard, targetStep);
return true;
}
function initPermissionWizard() {
const wizard = document.querySelector('[data-permission-wizard]');
if (!wizard) {
@@ -845,16 +962,34 @@ function initPermissionWizard() {
}
wizard.dataset.currentStep = wizard.dataset.currentStep || '1';
wizard.querySelector('[data-wizard-prev]')?.addEventListener('click', () => {
activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1') - 1);
movePermissionWizard(wizard, Number(wizard.dataset.currentStep || '1') - 1);
});
wizard.querySelector('[data-wizard-next]')?.addEventListener('click', () => {
activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1') + 1);
movePermissionWizard(wizard, Number(wizard.dataset.currentStep || '1') + 1);
});
document.querySelectorAll('[data-wizard-target]').forEach((button) => {
button.addEventListener('click', () => activatePermissionWizardStep(wizard, Number(button.dataset.wizardTarget)));
permissionWizardIndicators(wizard).forEach((button) => {
button.addEventListener('click', () => movePermissionWizard(wizard, Number(button.dataset.wizardTarget)));
});
wizard.addEventListener('input', () => {
clearPermissionWizardValidation(wizard);
updatePermissionWizardPreview(document);
});
wizard.addEventListener('change', () => {
clearPermissionWizardValidation(wizard);
updatePermissionWizardPreview(document);
});
wizard.addEventListener('submit', (event) => {
for (let step = 1; step <= 3; step += 1) {
const message = validatePermissionWizardStep(wizard, step);
if (message) {
event.preventDefault();
activatePermissionWizardStep(wizard, step);
showPermissionWizardValidation(wizard, message);
focusPermissionWizardStep(wizard, step);
return;
}
}
});
wizard.addEventListener('input', () => updatePermissionWizardPreview(document));
wizard.addEventListener('change', () => updatePermissionWizardPreview(document));
activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1'));
}

View File

@@ -20,7 +20,10 @@
<section class="content-band">
<div class="section-heading">
<h2>권한 추가 Wizard</h2>
<div>
<h2>새 권한 규칙</h2>
<p class="section-description">저장 전 적용 대상과 행 범위를 검토한 뒤 VPD 권한을 반영합니다.</p>
</div>
<div class="wizard-progress" aria-label="권한 추가 단계">
<button class="wizard-step-indicator active" type="button" data-wizard-target="1">1 역할</button>
<button class="wizard-step-indicator" type="button" data-wizard-target="2">2 객체</button>
@@ -31,6 +34,7 @@
</div>
<form method="post" action="/permissions" class="permission-wizard" data-permission-wizard>
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<div class="wizard-validation" data-wizard-validation role="alert" hidden></div>
<aside class="wizard-summary" aria-live="polite">
<div>
<span>역할</span>
@@ -48,6 +52,14 @@
<span>행 규칙</span>
<strong data-wizard-summary="rules">ALL</strong>
</div>
<div>
<span>적용 대상</span>
<strong data-wizard-summary="affected">역할 선택 후 확인</strong>
</div>
<div>
<span>저장 상태</span>
<strong data-wizard-summary="readiness">역할과 객체를 선택하세요</strong>
</div>
</aside>
<div class="wizard-panel active" data-wizard-step="1">
@@ -61,6 +73,7 @@
<label>
역할
<select class="form-select" name="roleId" required>
<option value="" selected disabled>권한을 적용할 역할 선택</option>
<option th:each="role : ${roles}"
th:value="${role.roleId()}"
th:text="${role.roleName()}"
@@ -95,6 +108,7 @@
<label>
보호 객체
<select class="form-select" name="objectRef" required>
<option value="" selected disabled>권한을 적용할 보호 객체 선택</option>
<optgroup label="등록된 보호 객체">
<option th:each="object : ${objects}"
th:value="${'protected:' + object.objectId()}"
@@ -142,6 +156,7 @@
</label>
<div>
<div class="field-block-title">행 규칙</div>
<p class="rule-safety-note"><strong>주의:</strong> <code>ALL</code>은 전체 행을 뜻합니다. <code>ALL</code>과 다른 조건 규칙은 한 권한에 함께 저장할 수 없습니다.</p>
<div id="rowRuleList" class="rule-list">
<div class="rule-row">
<select class="form-select rule-column-select" name="ruleColumn">
@@ -216,6 +231,8 @@
<div><dt>VPD predicate 예상</dt><dd data-preview="predicatePreview">-</dd></div>
<div><dt>권한별 컬럼 마스킹</dt><dd data-preview="columnPolicy">-</dd></div>
<div><dt>NULL 처리 예상</dt><dd data-preview="nullPolicy">-</dd></div>
<div class="policy-preview-emphasis"><dt>저장 영향</dt><dd data-preview="saveGuard">-</dd></div>
<div><dt>되돌리기</dt><dd>저장 후 아래 권한 목록에서 삭제할 수 있습니다. 이 객체의 마지막 권한을 삭제하면 보호 객체가 비활성화될 수 있습니다.</dd></div>
</dl>
</div>
</div>

View File

@@ -85,11 +85,20 @@ class GuidedFlowTemplateTest {
@Test
void permissionWizardExplainsTagRulesAndOrSemantics() throws IOException {
String html = template("permissions.html");
String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js"));
assertThat(html)
.contains("value=\"TAG\">특정 기술 태그")
.contains("TECH_TAG")
.contains("태그를 여러 개 추가하면");
.contains("태그를 여러 개 추가하면")
.contains("data-wizard-validation")
.contains("다른 조건 규칙은 한 권한에 함께 저장할 수 없습니다")
.contains("저장 영향")
.contains("되돌리기");
assertThat(javascript)
.contains("validatePermissionWizardStep")
.contains("ALL 규칙은 다른 조건 규칙과 함께 저장할 수 없습니다")
.contains("movePermissionWizard");
}
@Test

View File

@@ -0,0 +1,58 @@
package com.cloudhandson.vpdbackoffice.web;
import static org.assertj.core.api.Assertions.assertThat;
import com.cloudhandson.vpdbackoffice.domain.permission.AppRole;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.DatabaseObjectOption;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.templateresolver.FileTemplateResolver;
class PermissionTemplateRenderTest {
@Test
void rendersThePermissionSafetyReviewWithLayoutFragments() {
var resolver = new FileTemplateResolver();
resolver.setPrefix(Path.of("src/main/resources/templates").toAbsolutePath() + "/");
resolver.setSuffix(".html");
resolver.setTemplateMode("HTML");
resolver.setCacheable(false);
var engine = new SpringTemplateEngine();
engine.setTemplateResolver(resolver);
var role = new AppRole(30L, "ALL_DOC_ROLE", "전체 문서 역할", "CONFIDENTIAL");
var object = new ProtectedObject(3L, "ADMIN", "CB_VECTOR_SEARCH_DOCUMENTS", "vector/search", "Y");
var context = new Context(Locale.KOREAN);
context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token"));
context.setVariable("roles", List.of(role));
context.setVariable("objects", List.of(object));
context.setVariable("columnsByObject", Map.of(3L, List.of("CHUNK_TEXT", "TECH_TAG")));
context.setVariable("maskableColumnsByObject", Map.of(3L, List.of("CHUNK_TEXT")));
context.setVariable("maskableColumnLabelsByObject", Map.of(3L, List.of("CHUNK_TEXT [CONFIDENTIAL/NULLIFY]")));
context.setVariable("directUsersByRole", Map.of(30L, List.of("김어드민")));
context.setVariable("groupsByRole", Map.of(30L, List.of("OPS / 운영")));
context.setVariable("groupUsersByRole", Map.of(30L, List.of("박파이넨스")));
context.setVariable("dbObjects", List.of(new DatabaseObjectOption("ADMIN", "BOARD_POSTS", "TABLE")));
context.setVariable("permissions", List.of());
context.setVariable("lastPermissionByPermissionId", Map.of());
String rendered = engine.process("permissions", context);
assertThat(rendered)
.contains("권한을 적용할 역할 선택")
.contains("권한을 적용할 보호 객체 선택")
.contains("data-wizard-validation")
.contains("저장 영향")
.contains("되돌리기")
.contains("김어드민");
}
private record CsrfFixture(String parameterName, String token) {
}
}