[UX] #605 make permission wizard safety review actionable
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
# VPD Backoffice 전 페이지 페르소나 UX 리뷰와 개선 로드맵
|
# VPD Backoffice 전 페이지 페르소나 UX 리뷰와 개선 로드맵
|
||||||
|
|
||||||
> **상태**: Review complete · UX-1 in progress
|
> **상태**: Review complete · UX-1/UX-2 implementation in progress
|
||||||
> **작성**: [AI] UX Facilitator · **최종수정**: 2026-06-30
|
> **작성**: [AI] UX Facilitator · **최종수정**: 2026-06-30
|
||||||
> **추적성** — Redmine: UX audit 상위 이슈 등록 예정 · 선행 개선: #575 대시보드, #576 기본 검증 사용자명
|
> **추적성** — Redmine: #577 · 하위 구현: #575 대시보드, #576 기본 검증 사용자명, #599 설정, #605 권한 관리
|
||||||
|
|
||||||
## 1. 리뷰 방법
|
## 1. 리뷰 방법
|
||||||
|
|
||||||
@@ -75,3 +75,15 @@ UX-1부터 시작한다. 기존 데이터 모델과 VPD 로직은 바꾸지 않
|
|||||||
- 유효 권한 매트릭스를 사용자 중심 탭형 구조로 변경했다. 기본 화면은 선택한 사용자 한 명의 직접 역할, 그룹 상속, 최종 역할, 보호 객체, 권한 수를 보여 준다.
|
- 유효 권한 매트릭스를 사용자 중심 탭형 구조로 변경했다. 기본 화면은 선택한 사용자 한 명의 직접 역할, 그룹 상속, 최종 역할, 보호 객체, 권한 수를 보여 준다.
|
||||||
- 그룹/역할 전체 표는 제거하지 않고 보조 탭으로 이동했다.
|
- 그룹/역할 전체 표는 제거하지 않고 보조 탭으로 이동했다.
|
||||||
- 복합 Thymeleaf 속성과 layout fragment를 실제로 처리하는 `EffectiveMatrixTemplateRenderTest`를 추가해 UI template parse 오류를 빌드에서 검출한다.
|
- 복합 Thymeleaf 속성과 layout fragment를 실제로 처리하는 `EffectiveMatrixTemplateRenderTest`를 추가해 UI template parse 오류를 빌드에서 검출한다.
|
||||||
|
|
||||||
|
## 7. UX-2 진행 기록 — 권한 생성 안전성 (#605)
|
||||||
|
|
||||||
|
권한 관리 화면은 5단계 구조가 있었지만, 이전에는 다음 단계와 단계 표시를 눌러 필수 선택을 건너뛸 수 있었다. `ALL`과 조건 규칙을 섞으면 서버가 저장 시 거부했기 때문에, 사용자는 긴 입력 뒤에야 실패를 알게 됐다.
|
||||||
|
|
||||||
|
- 역할과 보호 객체에 빈 선택지를 두어 적용 주체와 대상을 명시적으로 고르게 한다.
|
||||||
|
- 다음/단계 이동/저장 시 1~3단계를 검증한다. 빠진 역할·객체·비교 값, 중복 규칙, `ALL`과 조건 규칙의 혼용은 해당 단계에서 이유를 보여 주고 이동을 막는다.
|
||||||
|
- 상단 요약에 직접 사용자·그룹·그룹 상속 사용자 수와 저장 준비 상태를 고정한다.
|
||||||
|
- 최종 검토에 `전체 행 허용/거부` 또는 조건부 영향 문장과 되돌리기 방법을 표시한다. 마지막 권한을 삭제하면 보호 객체가 비활성화될 수 있음을 저장 전에 안내한다.
|
||||||
|
- 브라우저 검증을 우회한 POST도 역할 누락·객체 형식·행 규칙 오류를 flash 오류로 돌려준다. 서버의 `PermissionService` 검증은 그대로 최종 방어선으로 유지한다.
|
||||||
|
|
||||||
|
검증 범위는 `GuidedFlowTemplateTest`, `PermissionServiceTest`, 전체 Maven 테스트 및 배포 후 `/permissions` HTTP 확인이다.
|
||||||
|
|||||||
@@ -195,15 +195,19 @@ public class PermissionController {
|
|||||||
|
|
||||||
@PostMapping("/permissions")
|
@PostMapping("/permissions")
|
||||||
public String save(
|
public String save(
|
||||||
@RequestParam long roleId,
|
@RequestParam(required = false) Long roleId,
|
||||||
@RequestParam String objectRef,
|
@RequestParam(required = false) String objectRef,
|
||||||
@RequestParam(defaultValue = "ALLOW") String permissionEffect,
|
@RequestParam(defaultValue = "ALLOW") String permissionEffect,
|
||||||
@RequestParam(required = false) List<String> ruleColumn,
|
@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) List<String> ruleValue,
|
||||||
@RequestParam(required = false) String visibleColumns,
|
@RequestParam(required = false) String visibleColumns,
|
||||||
RedirectAttributes redirectAttributes
|
RedirectAttributes redirectAttributes
|
||||||
) {
|
) {
|
||||||
|
try {
|
||||||
|
if (roleId == null) {
|
||||||
|
throw new AppException("권한을 적용할 역할을 선택하세요.");
|
||||||
|
}
|
||||||
long objectId = resolveObjectId(objectRef);
|
long objectId = resolveObjectId(objectRef);
|
||||||
permissionService.savePermissionSet(new PermissionSetCommand(
|
permissionService.savePermissionSet(new PermissionSetCommand(
|
||||||
roleId,
|
roleId,
|
||||||
@@ -214,6 +218,9 @@ public class PermissionController {
|
|||||||
splitColumns(visibleColumns)
|
splitColumns(visibleColumns)
|
||||||
));
|
));
|
||||||
redirectAttributes.addFlashAttribute("message", "권한을 저장했습니다.");
|
redirectAttributes.addFlashAttribute("message", "권한을 저장했습니다.");
|
||||||
|
} catch (AppException | IllegalArgumentException exception) {
|
||||||
|
redirectAttributes.addFlashAttribute("error", exception.getMessage());
|
||||||
|
}
|
||||||
return "redirect:/permissions";
|
return "redirect:/permissions";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -531,6 +531,11 @@ body {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
color: var(--rw-muted);
|
||||||
|
margin: .2rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
.wizard-progress {
|
.wizard-progress {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -584,6 +589,15 @@ body {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wizard-validation {
|
||||||
|
background: #fff4e5;
|
||||||
|
border: 1px solid #e7a84c;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #7a4600;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: .75rem .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.wizard-panel {
|
.wizard-panel {
|
||||||
border: 1px solid var(--rw-border);
|
border: 1px solid var(--rw-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -615,6 +629,18 @@ body {
|
|||||||
margin: .15rem 0 0;
|
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 {
|
.wizard-step-number {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: var(--rw-primary-soft);
|
background: var(--rw-primary-soft);
|
||||||
|
|||||||
@@ -770,6 +770,10 @@ function updatePermissionWizardPreview(root = document) {
|
|||||||
const rules = collectWizardRules(wizard);
|
const rules = collectWizardRules(wizard);
|
||||||
const predicates = collectWizardPredicates(wizard);
|
const predicates = collectWizardPredicates(wizard);
|
||||||
const ruleText = rules.length ? rules.join(', ') : '행 규칙 없음';
|
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 directUsers = splitList(roleOption?.dataset.directUsers || '');
|
||||||
const groups = splitList(roleOption?.dataset.groups || '');
|
const groups = splitList(roleOption?.dataset.groups || '');
|
||||||
const groupUsers = splitList(roleOption?.dataset.groupUsers || '');
|
const groupUsers = splitList(roleOption?.dataset.groupUsers || '');
|
||||||
@@ -790,11 +794,23 @@ function updatePermissionWizardPreview(root = document) {
|
|||||||
? '마스킹 대상 컬럼 없음'
|
? '마스킹 대상 컬럼 없음'
|
||||||
: (nullColumns.length ? `NULL 처리: ${nullColumns.join(', ')}` : '선택한 마스킹 컬럼 모두 원문 표시 허용');
|
: (nullColumns.length ? `NULL 처리: ${nullColumns.join(', ')}` : '선택한 마스킹 컬럼 모두 원문 표시 허용');
|
||||||
const affectedPrincipals = `직접 사용자 ${directUsers.length}명 / 그룹 ${groups.length}개 / 그룹 상속 사용자 ${groupUsers.length}명`;
|
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="role"]').textContent = selectedText(roleSelect);
|
||||||
wizard.querySelector('[data-wizard-summary="object"]').textContent = selectedText(objectSelect);
|
wizard.querySelector('[data-wizard-summary="object"]').textContent = selectedText(objectSelect);
|
||||||
wizard.querySelector('[data-wizard-summary="effect"]').textContent = effect;
|
wizard.querySelector('[data-wizard-summary="effect"]').textContent = effect;
|
||||||
wizard.querySelector('[data-wizard-summary="rules"]').textContent = ruleText;
|
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, 'role', selectedText(roleSelect));
|
||||||
setWizardPreview(wizard, 'sensitivity', roleOption?.dataset.maxSensitivity || 'PUBLIC');
|
setWizardPreview(wizard, 'sensitivity', roleOption?.dataset.maxSensitivity || 'PUBLIC');
|
||||||
@@ -810,6 +826,84 @@ function updatePermissionWizardPreview(root = document) {
|
|||||||
setWizardPreview(wizard, 'predicatePreview', predicateText);
|
setWizardPreview(wizard, 'predicatePreview', predicateText);
|
||||||
setWizardPreview(wizard, 'columnPolicy', columnPolicy);
|
setWizardPreview(wizard, 'columnPolicy', columnPolicy);
|
||||||
setWizardPreview(wizard, 'nullPolicy', nullPolicy);
|
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) {
|
function activatePermissionWizardStep(wizard, step) {
|
||||||
@@ -819,8 +913,10 @@ function activatePermissionWizardStep(wizard, step) {
|
|||||||
panels.forEach((panel) => {
|
panels.forEach((panel) => {
|
||||||
panel.classList.toggle('active', Number(panel.dataset.wizardStep) === nextStep);
|
panel.classList.toggle('active', Number(panel.dataset.wizardStep) === nextStep);
|
||||||
});
|
});
|
||||||
document.querySelectorAll('[data-wizard-target]').forEach((button) => {
|
permissionWizardIndicators(wizard).forEach((button) => {
|
||||||
button.classList.toggle('active', Number(button.dataset.wizardTarget) === nextStep);
|
const active = Number(button.dataset.wizardTarget) === nextStep;
|
||||||
|
button.classList.toggle('active', active);
|
||||||
|
button.setAttribute('aria-current', active ? 'step' : 'false');
|
||||||
});
|
});
|
||||||
wizard.dataset.currentStep = String(nextStep);
|
wizard.dataset.currentStep = String(nextStep);
|
||||||
const prev = wizard.querySelector('[data-wizard-prev]');
|
const prev = wizard.querySelector('[data-wizard-prev]');
|
||||||
@@ -838,6 +934,27 @@ function activatePermissionWizardStep(wizard, step) {
|
|||||||
updatePermissionWizardPreview(document);
|
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() {
|
function initPermissionWizard() {
|
||||||
const wizard = document.querySelector('[data-permission-wizard]');
|
const wizard = document.querySelector('[data-permission-wizard]');
|
||||||
if (!wizard) {
|
if (!wizard) {
|
||||||
@@ -845,16 +962,34 @@ function initPermissionWizard() {
|
|||||||
}
|
}
|
||||||
wizard.dataset.currentStep = wizard.dataset.currentStep || '1';
|
wizard.dataset.currentStep = wizard.dataset.currentStep || '1';
|
||||||
wizard.querySelector('[data-wizard-prev]')?.addEventListener('click', () => {
|
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', () => {
|
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) => {
|
permissionWizardIndicators(wizard).forEach((button) => {
|
||||||
button.addEventListener('click', () => activatePermissionWizardStep(wizard, Number(button.dataset.wizardTarget)));
|
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'));
|
activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,10 @@
|
|||||||
|
|
||||||
<section class="content-band">
|
<section class="content-band">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h2>권한 추가 Wizard</h2>
|
<div>
|
||||||
|
<h2>새 권한 규칙</h2>
|
||||||
|
<p class="section-description">저장 전 적용 대상과 행 범위를 검토한 뒤 VPD 권한을 반영합니다.</p>
|
||||||
|
</div>
|
||||||
<div class="wizard-progress" aria-label="권한 추가 단계">
|
<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 active" type="button" data-wizard-target="1">1 역할</button>
|
||||||
<button class="wizard-step-indicator" type="button" data-wizard-target="2">2 객체</button>
|
<button class="wizard-step-indicator" type="button" data-wizard-target="2">2 객체</button>
|
||||||
@@ -31,6 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<form method="post" action="/permissions" class="permission-wizard" data-permission-wizard>
|
<form method="post" action="/permissions" class="permission-wizard" data-permission-wizard>
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<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">
|
<aside class="wizard-summary" aria-live="polite">
|
||||||
<div>
|
<div>
|
||||||
<span>역할</span>
|
<span>역할</span>
|
||||||
@@ -48,6 +52,14 @@
|
|||||||
<span>행 규칙</span>
|
<span>행 규칙</span>
|
||||||
<strong data-wizard-summary="rules">ALL</strong>
|
<strong data-wizard-summary="rules">ALL</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>적용 대상</span>
|
||||||
|
<strong data-wizard-summary="affected">역할 선택 후 확인</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>저장 상태</span>
|
||||||
|
<strong data-wizard-summary="readiness">역할과 객체를 선택하세요</strong>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="wizard-panel active" data-wizard-step="1">
|
<div class="wizard-panel active" data-wizard-step="1">
|
||||||
@@ -61,6 +73,7 @@
|
|||||||
<label>
|
<label>
|
||||||
역할
|
역할
|
||||||
<select class="form-select" name="roleId" required>
|
<select class="form-select" name="roleId" required>
|
||||||
|
<option value="" selected disabled>권한을 적용할 역할 선택</option>
|
||||||
<option th:each="role : ${roles}"
|
<option th:each="role : ${roles}"
|
||||||
th:value="${role.roleId()}"
|
th:value="${role.roleId()}"
|
||||||
th:text="${role.roleName()}"
|
th:text="${role.roleName()}"
|
||||||
@@ -95,6 +108,7 @@
|
|||||||
<label>
|
<label>
|
||||||
보호 객체
|
보호 객체
|
||||||
<select class="form-select" name="objectRef" required>
|
<select class="form-select" name="objectRef" required>
|
||||||
|
<option value="" selected disabled>권한을 적용할 보호 객체 선택</option>
|
||||||
<optgroup label="등록된 보호 객체">
|
<optgroup label="등록된 보호 객체">
|
||||||
<option th:each="object : ${objects}"
|
<option th:each="object : ${objects}"
|
||||||
th:value="${'protected:' + object.objectId()}"
|
th:value="${'protected:' + object.objectId()}"
|
||||||
@@ -142,6 +156,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<div>
|
<div>
|
||||||
<div class="field-block-title">행 규칙</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 id="rowRuleList" class="rule-list">
|
||||||
<div class="rule-row">
|
<div class="rule-row">
|
||||||
<select class="form-select rule-column-select" name="ruleColumn">
|
<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>VPD predicate 예상</dt><dd data-preview="predicatePreview">-</dd></div>
|
||||||
<div><dt>권한별 컬럼 마스킹</dt><dd data-preview="columnPolicy">-</dd></div>
|
<div><dt>권한별 컬럼 마스킹</dt><dd data-preview="columnPolicy">-</dd></div>
|
||||||
<div><dt>NULL 처리 예상</dt><dd data-preview="nullPolicy">-</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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -85,11 +85,20 @@ class GuidedFlowTemplateTest {
|
|||||||
@Test
|
@Test
|
||||||
void permissionWizardExplainsTagRulesAndOrSemantics() throws IOException {
|
void permissionWizardExplainsTagRulesAndOrSemantics() throws IOException {
|
||||||
String html = template("permissions.html");
|
String html = template("permissions.html");
|
||||||
|
String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js"));
|
||||||
|
|
||||||
assertThat(html)
|
assertThat(html)
|
||||||
.contains("value=\"TAG\">특정 기술 태그")
|
.contains("value=\"TAG\">특정 기술 태그")
|
||||||
.contains("TECH_TAG")
|
.contains("TECH_TAG")
|
||||||
.contains("태그를 여러 개 추가하면");
|
.contains("태그를 여러 개 추가하면")
|
||||||
|
.contains("data-wizard-validation")
|
||||||
|
.contains("다른 조건 규칙은 한 권한에 함께 저장할 수 없습니다")
|
||||||
|
.contains("저장 영향")
|
||||||
|
.contains("되돌리기");
|
||||||
|
assertThat(javascript)
|
||||||
|
.contains("validatePermissionWizardStep")
|
||||||
|
.contains("ALL 규칙은 다른 조건 규칙과 함께 저장할 수 없습니다")
|
||||||
|
.contains("movePermissionWizard");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user