feat #468: add permission setup wizard
This commit is contained in:
34
docs/design/468-permission-wizard/README.md
Normal file
34
docs/design/468-permission-wizard/README.md
Normal 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 테스트와 인증 화면 렌더링 확인을 통과한다.
|
||||
@@ -336,6 +336,119 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -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', () => {
|
||||
renderMarkdownViews();
|
||||
const master = document.getElementById('userRoleMaster');
|
||||
@@ -242,11 +349,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
const objectSelect = document.querySelector('select[name="objectRef"]');
|
||||
if (objectSelect) {
|
||||
objectSelect.addEventListener('change', syncRuleColumnOptions);
|
||||
objectSelect.addEventListener('change', () => {
|
||||
syncRuleColumnOptions();
|
||||
updatePermissionWizardPreview(document);
|
||||
});
|
||||
syncRuleColumnOptions();
|
||||
}
|
||||
initPermissionWizard();
|
||||
document.querySelectorAll('.rule-type-select').forEach((select) => {
|
||||
select.addEventListener('change', () => syncRuleTypeHints());
|
||||
select.addEventListener('change', () => {
|
||||
syncRuleTypeHints();
|
||||
updatePermissionWizardPreview(document);
|
||||
});
|
||||
});
|
||||
syncRuleTypeHints();
|
||||
const catalog = document.getElementById('objectCatalogSelect');
|
||||
@@ -264,15 +378,23 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
clone.querySelectorAll('input').forEach((input) => input.value = '');
|
||||
const cloneButton = clone.querySelector('[data-rule-add]');
|
||||
cloneButton.textContent = '삭제';
|
||||
cloneButton.addEventListener('click', () => clone.remove());
|
||||
cloneButton.addEventListener('click', () => {
|
||||
clone.remove();
|
||||
updatePermissionWizardPreview(document);
|
||||
});
|
||||
clone.querySelectorAll('.rule-type-select').forEach((select) => {
|
||||
select.addEventListener('change', () => syncRuleTypeHints());
|
||||
select.addEventListener('change', () => {
|
||||
syncRuleTypeHints();
|
||||
updatePermissionWizardPreview(document);
|
||||
});
|
||||
});
|
||||
list.appendChild(clone);
|
||||
syncRuleColumnOptions();
|
||||
syncRuleTypeHints(clone);
|
||||
updatePermissionWizardPreview(document);
|
||||
});
|
||||
});
|
||||
updatePermissionWizardPreview(document);
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||
|
||||
@@ -12,15 +12,65 @@
|
||||
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
|
||||
|
||||
<section class="content-band">
|
||||
<h2>권한 추가</h2>
|
||||
<form method="post" action="/permissions" class="form-grid">
|
||||
<div class="section-heading">
|
||||
<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}">
|
||||
<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>
|
||||
역할
|
||||
<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>
|
||||
</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>
|
||||
보호 객체
|
||||
<select class="form-select" name="objectRef" required>
|
||||
@@ -37,6 +87,17 @@
|
||||
</optgroup>
|
||||
</select>
|
||||
</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>
|
||||
권한 효과
|
||||
<select class="form-select" name="permissionEffect" required>
|
||||
@@ -44,7 +105,7 @@
|
||||
<option value="DENY">거부</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="span-2">
|
||||
<div>
|
||||
<div class="field-block-title">행 규칙</div>
|
||||
<div id="rowRuleList" class="rule-list">
|
||||
<div class="rule-row">
|
||||
@@ -69,11 +130,47 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="span-2">
|
||||
<div class="field-block-title">컬럼 NULL 처리 예외</div>
|
||||
<input class="form-control" name="visibleColumns" placeholder="CONTENTS">
|
||||
</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>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user