feat #468: add permission setup wizard

This commit is contained in:
devmrko
2026-06-26 04:38:20 +09:00
parent 4961083f06
commit 3629c84861
4 changed files with 427 additions and 61 deletions

View File

@@ -0,0 +1,34 @@
# #468 권한 등록 Wizard
## 배경
권한 등록은 역할, 보호 객체, 허용/거부, 행 규칙, 컬럼 redaction 예외를 한 번에 설정해야 한다. 기존 단일 폼은 빠르게 입력할 수 있지만 신규 운영자가 순서를 놓치기 쉽고, 저장 전에 최종 정책 의미를 확인하기 어렵다.
## 결정
- 서버 저장 API(`/permissions`)는 유지한다.
- 권한 추가 영역만 단계형 wizard UI로 재구성한다.
- 기존 권한 목록은 그대로 유지한다.
- 단계는 다음 순서로 고정한다.
1. 역할 선택
2. 보호 객체 선택
3. 권한 효과와 행 규칙
4. 컬럼 NULL 처리 예외
5. 저장 전 검토
- 저장 전 검토 단계에서 effective policy preview를 표시한다.
## Preview 규칙
- `ALLOW + ALL`: 모든 행 조회 허용
- `ALLOW + 조건`: 조건에 맞는 행만 조회 허용
- `DENY + ALL`: 해당 역할의 객체 조회 거부
- `DENY + 조건`: 조건에 맞는 행을 제외
- 컬럼 예외가 비어 있으면 민감 컬럼은 NULL 처리 대상으로 설명한다.
- 컬럼 예외가 있으면 해당 컬럼을 NULL 처리 예외로 표시한다.
## 완료 기준
- `/permissions`에서 단계 이동과 요약 갱신이 동작한다.
- 기존 저장 POST parameter 이름은 유지한다.
- 행 규칙 컬럼 동적 조회와 규칙 추가/삭제가 계속 동작한다.
- Maven 테스트와 인증 화면 렌더링 확인을 통과한다.

View File

@@ -336,6 +336,119 @@ body {
grid-template-columns: 1fr 1fr 1fr auto; grid-template-columns: 1fr 1fr 1fr auto;
} }
.permission-wizard {
display: grid;
gap: 1rem;
}
.wizard-progress {
display: flex;
flex-wrap: wrap;
gap: .4rem;
}
.wizard-step-indicator {
background: var(--rw-surface-muted);
border: 1px solid var(--rw-border);
border-radius: 999px;
color: var(--rw-muted);
font-size: .82rem;
font-weight: 700;
min-height: 2rem;
padding: .3rem .7rem;
}
.wizard-step-indicator.active {
background: var(--rw-primary);
border-color: var(--rw-primary);
color: #fff;
}
.wizard-summary {
display: grid;
gap: .75rem;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.wizard-summary > div,
.policy-preview dl > div {
background: var(--rw-surface-muted);
border: 1px solid var(--rw-border);
border-radius: 8px;
padding: .75rem;
}
.wizard-summary span,
.policy-preview dt {
color: var(--rw-muted);
display: block;
font-size: .78rem;
font-weight: 700;
}
.wizard-summary strong,
.policy-preview dd {
display: block;
margin: .25rem 0 0;
overflow-wrap: anywhere;
}
.wizard-panel {
border: 1px solid var(--rw-border);
border-radius: 8px;
display: none;
padding: 1rem;
}
.wizard-panel.active {
display: grid;
gap: 1rem;
}
.wizard-panel-heading {
align-items: flex-start;
display: flex;
gap: .75rem;
}
.wizard-panel-heading h3 {
font-size: 1rem;
font-weight: 700;
margin: 0;
}
.wizard-panel-heading p,
.wizard-hint {
color: var(--rw-muted);
margin: .15rem 0 0;
}
.wizard-step-number {
align-items: center;
background: var(--rw-primary-soft);
border-radius: 999px;
color: var(--rw-primary);
display: inline-flex;
font-weight: 800;
height: 2rem;
justify-content: center;
min-width: 2rem;
}
.wizard-actions {
align-items: center;
display: flex;
gap: .5rem;
justify-content: flex-end;
}
.policy-preview dl {
display: grid;
gap: .75rem;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
margin: 0;
}
.question-presets { .question-presets {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -233,6 +233,113 @@ async function syncObjectCatalogSelection() {
} }
} }
function selectedText(select) {
if (!select || select.selectedIndex < 0) {
return '-';
}
return select.options[select.selectedIndex].textContent.trim();
}
function collectWizardRules(root) {
return Array.from(root.querySelectorAll('.rule-row')).map((row) => {
const column = row.querySelector('[name="ruleColumn"]')?.value || '';
const type = row.querySelector('[name="ruleType"]')?.value || '';
const value = row.querySelector('[name="ruleValue"]')?.value || '';
const displayColumn = column || {
MY_DEPT: 'DEPT_CODE',
SELF: 'OWNER_EMP_NO',
DEPT: 'DEPT_CODE',
EMP_NO: 'OWNER_EMP_NO',
ALL: ''
}[type] || '';
if (type === 'ALL') {
return 'ALL';
}
if (['MY_DEPT', 'SELF'].includes(type)) {
return `${displayColumn} ${type}`;
}
return [displayColumn, type, value].filter(Boolean).join(' ');
}).filter(Boolean);
}
function updatePermissionWizardPreview(root = document) {
const wizard = root.querySelector('[data-permission-wizard]');
if (!wizard) {
return;
}
const roleSelect = wizard.querySelector('[name="roleId"]');
const objectSelect = wizard.querySelector('[name="objectRef"]');
const effectSelect = wizard.querySelector('[name="permissionEffect"]');
const visibleColumns = wizard.querySelector('[name="visibleColumns"]')?.value.trim() || '';
const roleOption = roleSelect?.options[roleSelect.selectedIndex];
const effect = effectSelect?.value || 'ALLOW';
const rules = collectWizardRules(wizard);
const ruleText = rules.length ? rules.join(', ') : '행 규칙 없음';
const rowPolicy = effect === 'DENY'
? `거부 규칙: ${ruleText}`
: `허용 규칙: ${ruleText}`;
const columnPolicy = visibleColumns
? `NULL 처리 예외: ${visibleColumns}`
: '민감 컬럼은 NULL 처리 대상';
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-preview="role"]').textContent = selectedText(roleSelect);
wizard.querySelector('[data-preview="sensitivity"]').textContent = roleOption?.dataset.maxSensitivity || 'PUBLIC';
wizard.querySelector('[data-preview="object"]').textContent = selectedText(objectSelect);
wizard.querySelector('[data-preview="rowPolicy"]').textContent = rowPolicy;
wizard.querySelector('[data-preview="columnPolicy"]').textContent = columnPolicy;
}
function activatePermissionWizardStep(wizard, step) {
const panels = Array.from(wizard.querySelectorAll('[data-wizard-step]'));
const maxStep = panels.length;
const nextStep = Math.max(1, Math.min(step, maxStep));
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);
});
wizard.dataset.currentStep = String(nextStep);
const prev = wizard.querySelector('[data-wizard-prev]');
const next = wizard.querySelector('[data-wizard-next]');
const submit = wizard.querySelector('[data-wizard-submit]');
if (prev) {
prev.disabled = nextStep === 1;
}
if (next) {
next.hidden = nextStep === maxStep;
}
if (submit) {
submit.hidden = nextStep !== maxStep;
}
updatePermissionWizardPreview(document);
}
function initPermissionWizard() {
const wizard = document.querySelector('[data-permission-wizard]');
if (!wizard) {
return;
}
wizard.dataset.currentStep = wizard.dataset.currentStep || '1';
wizard.querySelector('[data-wizard-prev]')?.addEventListener('click', () => {
activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1') - 1);
});
wizard.querySelector('[data-wizard-next]')?.addEventListener('click', () => {
activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1') + 1);
});
document.querySelectorAll('[data-wizard-target]').forEach((button) => {
button.addEventListener('click', () => activatePermissionWizardStep(wizard, Number(button.dataset.wizardTarget)));
});
wizard.addEventListener('input', () => updatePermissionWizardPreview(document));
wizard.addEventListener('change', () => updatePermissionWizardPreview(document));
activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1'));
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
renderMarkdownViews(); renderMarkdownViews();
const master = document.getElementById('userRoleMaster'); const master = document.getElementById('userRoleMaster');
@@ -242,11 +349,18 @@ document.addEventListener('DOMContentLoaded', () => {
} }
const objectSelect = document.querySelector('select[name="objectRef"]'); const objectSelect = document.querySelector('select[name="objectRef"]');
if (objectSelect) { if (objectSelect) {
objectSelect.addEventListener('change', syncRuleColumnOptions); objectSelect.addEventListener('change', () => {
syncRuleColumnOptions();
updatePermissionWizardPreview(document);
});
syncRuleColumnOptions(); syncRuleColumnOptions();
} }
initPermissionWizard();
document.querySelectorAll('.rule-type-select').forEach((select) => { document.querySelectorAll('.rule-type-select').forEach((select) => {
select.addEventListener('change', () => syncRuleTypeHints()); select.addEventListener('change', () => {
syncRuleTypeHints();
updatePermissionWizardPreview(document);
});
}); });
syncRuleTypeHints(); syncRuleTypeHints();
const catalog = document.getElementById('objectCatalogSelect'); const catalog = document.getElementById('objectCatalogSelect');
@@ -264,15 +378,23 @@ document.addEventListener('DOMContentLoaded', () => {
clone.querySelectorAll('input').forEach((input) => input.value = ''); clone.querySelectorAll('input').forEach((input) => input.value = '');
const cloneButton = clone.querySelector('[data-rule-add]'); const cloneButton = clone.querySelector('[data-rule-add]');
cloneButton.textContent = '삭제'; cloneButton.textContent = '삭제';
cloneButton.addEventListener('click', () => clone.remove()); cloneButton.addEventListener('click', () => {
clone.remove();
updatePermissionWizardPreview(document);
});
clone.querySelectorAll('.rule-type-select').forEach((select) => { clone.querySelectorAll('.rule-type-select').forEach((select) => {
select.addEventListener('change', () => syncRuleTypeHints()); select.addEventListener('change', () => {
syncRuleTypeHints();
updatePermissionWizardPreview(document);
});
}); });
list.appendChild(clone); list.appendChild(clone);
syncRuleColumnOptions(); syncRuleColumnOptions();
syncRuleTypeHints(clone); syncRuleTypeHints(clone);
updatePermissionWizardPreview(document);
}); });
}); });
updatePermissionWizardPreview(document);
}); });
document.body.addEventListener('htmx:afterSwap', (event) => { document.body.addEventListener('htmx:afterSwap', (event) => {

View File

@@ -12,15 +12,65 @@
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div> <div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
<section class="content-band"> <section class="content-band">
<h2>권한 추가</h2> <div class="section-heading">
<form method="post" action="/permissions" class="form-grid"> <h2>권한 추가 Wizard</h2>
<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>
<button class="wizard-step-indicator" type="button" data-wizard-target="3">3 행 규칙</button>
<button class="wizard-step-indicator" type="button" data-wizard-target="4">4 컬럼</button>
<button class="wizard-step-indicator" type="button" data-wizard-target="5">5 검토</button>
</div>
</div>
<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}">
<aside class="wizard-summary" aria-live="polite">
<div>
<span>역할</span>
<strong data-wizard-summary="role">선택 전</strong>
</div>
<div>
<span>객체</span>
<strong data-wizard-summary="object">선택 전</strong>
</div>
<div>
<span>효과</span>
<strong data-wizard-summary="effect">ALLOW</strong>
</div>
<div>
<span>행 규칙</span>
<strong data-wizard-summary="rules">ALL</strong>
</div>
</aside>
<div class="wizard-panel active" data-wizard-step="1">
<div class="wizard-panel-heading">
<span class="wizard-step-number">1</span>
<div>
<h3>역할 선택</h3>
<p>권한을 부여하거나 거부할 역할을 선택합니다.</p>
</div>
</div>
<label> <label>
역할 역할
<select class="form-select" name="roleId" required> <select class="form-select" name="roleId" required>
<option th:each="role : ${roles}" th:value="${role.roleId()}" th:text="${role.roleName()}"></option> <option th:each="role : ${roles}"
th:value="${role.roleId()}"
th:text="${role.roleName()}"
th:attr="data-max-sensitivity=${role.maxSensitivityLevel()}"></option>
</select> </select>
</label> </label>
<p class="wizard-hint">선택한 역할의 민감도 허용 상한도 preview에 반영됩니다.</p>
</div>
<div class="wizard-panel" data-wizard-step="2">
<div class="wizard-panel-heading">
<span class="wizard-step-number">2</span>
<div>
<h3>보호 객체 선택</h3>
<p>등록된 보호 객체 또는 DB 스키마 객체를 선택합니다.</p>
</div>
</div>
<label> <label>
보호 객체 보호 객체
<select class="form-select" name="objectRef" required> <select class="form-select" name="objectRef" required>
@@ -37,6 +87,17 @@
</optgroup> </optgroup>
</select> </select>
</label> </label>
<p class="wizard-hint">DB 스키마 객체를 선택하면 저장 시 보호 객체가 자동 등록됩니다.</p>
</div>
<div class="wizard-panel" data-wizard-step="3">
<div class="wizard-panel-heading">
<span class="wizard-step-number">3</span>
<div>
<h3>권한 효과와 행 규칙</h3>
<p>허용/거부 방향과 VPD 행 필터 조건을 설정합니다.</p>
</div>
</div>
<label> <label>
권한 효과 권한 효과
<select class="form-select" name="permissionEffect" required> <select class="form-select" name="permissionEffect" required>
@@ -44,7 +105,7 @@
<option value="DENY">거부</option> <option value="DENY">거부</option>
</select> </select>
</label> </label>
<div class="span-2"> <div>
<div class="field-block-title">행 규칙</div> <div class="field-block-title">행 규칙</div>
<div id="rowRuleList" class="rule-list"> <div id="rowRuleList" class="rule-list">
<div class="rule-row"> <div class="rule-row">
@@ -69,11 +130,47 @@
</div> </div>
</div> </div>
</div> </div>
<div class="span-2">
<div class="field-block-title">컬럼 NULL 처리 예외</div>
<input class="form-control" name="visibleColumns" placeholder="CONTENTS">
</div> </div>
<button class="btn btn-primary" type="submit">저장</button>
<div class="wizard-panel" data-wizard-step="4">
<div class="wizard-panel-heading">
<span class="wizard-step-number">4</span>
<div>
<h3>컬럼 NULL 처리 예외</h3>
<p>redaction 대상 민감 컬럼 중 원문 표시를 허용할 컬럼을 입력합니다.</p>
</div>
</div>
<label>
NULL 처리 예외 컬럼
<input class="form-control" name="visibleColumns" placeholder="CONTENTS">
</label>
<p class="wizard-hint">쉼표로 여러 컬럼을 입력합니다. 비워두면 민감 컬럼은 NULL 처리 대상으로 남습니다.</p>
</div>
<div class="wizard-panel" data-wizard-step="5">
<div class="wizard-panel-heading">
<span class="wizard-step-number">5</span>
<div>
<h3>저장 전 검토</h3>
<p>저장될 권한과 예상 effective policy를 확인합니다.</p>
</div>
</div>
<div class="policy-preview" data-policy-preview>
<dl>
<div><dt>역할</dt><dd data-preview="role">-</dd></div>
<div><dt>민감도 상한</dt><dd data-preview="sensitivity">-</dd></div>
<div><dt>보호 객체</dt><dd data-preview="object">-</dd></div>
<div><dt>행 접근</dt><dd data-preview="rowPolicy">-</dd></div>
<div><dt>컬럼 정책</dt><dd data-preview="columnPolicy">-</dd></div>
</dl>
</div>
</div>
<div class="wizard-actions">
<button class="btn btn-outline-secondary" type="button" data-wizard-prev disabled>이전</button>
<button class="btn btn-outline-primary" type="button" data-wizard-next>다음</button>
<button class="btn btn-primary" type="submit" data-wizard-submit hidden>저장</button>
</div>
</form> </form>
</section> </section>