fix #467: move masking grants into permission wizard

This commit is contained in:
devmrko
2026-06-26 04:51:22 +09:00
parent 3629c84861
commit 62f8abce5f
5 changed files with 81 additions and 11 deletions

View File

@@ -26,10 +26,20 @@
## UI 반영
- ORDS 조회 Handler 대상 화면에서 객체별 컬럼 정책을 조회/수정한다.
- ORDS 조회 Handler 대상 화면에서 객체별 기본 컬럼 정책을 조회/수정한다.
- 역할 화면에서 `max_sensitivity_level`을 조회/수정한다.
- 권한 관리 wizard에서 역할이 해당 보호 객체를 조회할 때 원문 표시를 허용할 마스킹 컬럼을 선택한다.
- ORDS 검증 결과의 masked 컬럼은 `contents [CONFIDENTIAL/NULLIFY]`처럼 정책 라벨을 같이 표시한다.
## 권한 부여 시 컬럼 마스킹 처리
컬럼 마스킹의 기준 정보는 보호 객체 컬럼에 둔다. 하지만 실제 원문 표시 허용은 역할과 보호 객체를 묶는 권한에 붙인다. 따라서 운영자는 권한을 줄 때 다음 순서로 판단한다.
1. 보호 객체의 기본 컬럼 민감도/마스킹 정책을 확인한다.
2. 역할의 민감도 허용 상한을 확인한다.
3. 권한 wizard에서 해당 역할에 원문 표시를 허용할 컬럼만 선택한다.
4. 선택하지 않은 마스킹 대상 컬럼은 기본 정책대로 NULL 처리 또는 마스킹된다.
## 남은 판단
`cb_permission_column``max_sensitivity_level`의 최종 런타임 결합 방식은 다음 단계에서 결정한다. 현재는 기존 Oracle Redaction expression과 호환되도록 표시 예외 구조를 유지한다.

View File

@@ -2,6 +2,7 @@ package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionSetCommand;
import com.cloudhandson.vpdbackoffice.domain.permission.RuleCommand;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
import com.cloudhandson.vpdbackoffice.service.PermissionService;
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
import java.util.Arrays;
@@ -74,6 +75,22 @@ public class PermissionController {
.map(column -> column.columnName())
.toList()
));
var maskableColumnsByObject = objects.stream()
.collect(Collectors.toMap(
object -> object.objectId(),
object -> protectedObjectService.findColumns(object.objectId()).stream()
.filter(ProtectedColumn::sensitive)
.map(column -> column.columnName())
.toList()
));
var maskableColumnLabelsByObject = objects.stream()
.collect(Collectors.toMap(
object -> object.objectId(),
object -> protectedObjectService.findColumns(object.objectId()).stream()
.filter(ProtectedColumn::sensitive)
.map(column -> column.columnName() + " [" + column.policyLabel() + "]")
.toList()
));
long columnsAt = System.nanoTime();
var dbObjects = protectedObjectService.findDatabaseObjects();
long dbObjectsAt = System.nanoTime();
@@ -89,6 +106,8 @@ public class PermissionController {
model.addAttribute("roles", roles);
model.addAttribute("objects", objects);
model.addAttribute("columnsByObject", columnsByObject);
model.addAttribute("maskableColumnsByObject", maskableColumnsByObject);
model.addAttribute("maskableColumnLabelsByObject", maskableColumnLabelsByObject);
model.addAttribute("dbObjects", dbObjects);
model.addAttribute("permissions", permissions);
return "permissions";

View File

@@ -159,6 +159,38 @@ function renderRuleColumnOptions(columns) {
});
}
function renderMaskableColumnOptions(option) {
const list = document.querySelector('[data-maskable-column-list]');
if (!list) {
return;
}
const columns = (option?.dataset.maskableColumns || '').split(',').filter(Boolean);
const labels = (option?.dataset.maskableLabels || '').split('|').filter(Boolean);
if (!columns.length) {
list.innerHTML = '<span class="text-muted small">선택한 객체에 권한별로 허용할 마스킹 컬럼이 없습니다.</span>';
return;
}
list.innerHTML = columns.map((column, index) => (
`<button class="btn btn-sm btn-outline-secondary question-preset" type="button" data-mask-column="${column}">`
+ `${escapeHtml(labels[index] || column)}</button>`
)).join('');
list.querySelectorAll('[data-mask-column]').forEach((button) => {
button.addEventListener('click', () => {
const input = document.querySelector('input[name="visibleColumns"]');
if (!input) {
return;
}
const current = input.value.split(',').map((value) => value.trim()).filter(Boolean);
const column = button.dataset.maskColumn;
if (!current.includes(column)) {
current.push(column);
}
input.value = current.join(', ');
updatePermissionWizardPreview(document);
});
});
}
function syncRuleTypeHints(root = document) {
root.querySelectorAll('.rule-row').forEach((row) => {
const typeSelect = row.querySelector('.rule-type-select');
@@ -198,6 +230,7 @@ async function syncRuleColumnOptions() {
}
const selectedOption = objectSelect.options[objectSelect.selectedIndex];
const fallbackColumns = (selectedOption?.dataset.columns || '').split(',').filter(Boolean);
renderMaskableColumnOptions(selectedOption);
renderRuleColumnOptions(fallbackColumns);
try {
const response = await fetch(`/permissions/object-columns?objectRef=${encodeURIComponent(objectSelect.value)}`);
@@ -279,8 +312,8 @@ function updatePermissionWizardPreview(root = document) {
? `거부 규칙: ${ruleText}`
: `허용 규칙: ${ruleText}`;
const columnPolicy = visibleColumns
? `NULL 처리 예외: ${visibleColumns}`
: '민감 컬럼은 NULL 처리 대상';
? `이 권한에서 원문 표시 허용: ${visibleColumns}`
: '마스킹 대상 컬럼은 기본 정책대로 NULL/마스킹 처리';
wizard.querySelector('[data-wizard-summary="role"]').textContent = selectedText(roleSelect);
wizard.querySelector('[data-wizard-summary="object"]').textContent = selectedText(objectSelect);

View File

@@ -87,7 +87,7 @@
<tr>
<td colspan="5" class="policy-source-cell">
<details class="column-policy-panel">
<summary>컬럼 민감도/마스킹 정책</summary>
<summary>기본 컬럼 민감도/마스킹 정책</summary>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>

View File

@@ -77,7 +77,9 @@
<optgroup label="등록된 보호 객체">
<option th:each="object : ${objects}"
th:value="${'protected:' + object.objectId()}"
th:attr="data-columns=${#strings.listJoin(columnsByObject[object.objectId()], ',')}"
th:attr="data-columns=${#strings.listJoin(columnsByObject[object.objectId()], ',')},
data-maskable-columns=${#strings.listJoin(maskableColumnsByObject[object.objectId()], ',')},
data-maskable-labels=${#strings.listJoin(maskableColumnLabelsByObject[object.objectId()], '|')}"
th:text="${object.displayName()}"></option>
</optgroup>
<optgroup label="DB 스키마 객체">
@@ -136,15 +138,21 @@
<div class="wizard-panel-heading">
<span class="wizard-step-number">4</span>
<div>
<h3>컬럼 NULL 처리 예외</h3>
<p>redaction 대상 민감 컬럼 중 원문 표시를 허용할 컬럼을 입력합니다.</p>
<h3>권한별 컬럼 마스킹</h3>
<p>이 역할이 이 테이블/뷰를 조회할 때 원문 표시를 허용할 마스킹 컬럼을 선택합니다.</p>
</div>
</div>
<div class="masking-column-picker">
<div class="field-block-title">선택 가능한 마스킹 컬럼</div>
<div class="question-presets" data-maskable-column-list>
<span class="text-muted small">보호 객체를 선택하면 마스킹 대상 컬럼이 표시됩니다.</span>
</div>
</div>
<label>
NULL 처리 예외 컬럼
원문 표시 허용 컬럼
<input class="form-control" name="visibleColumns" placeholder="CONTENTS">
</label>
<p class="wizard-hint">쉼표로 여러 컬럼을 입력합니다. 비워두면 민감 컬럼은 NULL 처리 대상으로 남습니다.</p>
<p class="wizard-hint">객체의 기본 민감도/마스킹 정책은 ORDS 조회 Handler 대상에서 관리하고, 여기서는 역할 권한에 붙일 원문 표시 예외만 정합니다.</p>
</div>
<div class="wizard-panel" data-wizard-step="5">
@@ -161,7 +169,7 @@
<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>
<div><dt>권한별 컬럼 마스킹</dt><dd data-preview="columnPolicy">-</dd></div>
</dl>
</div>
</div>
@@ -187,7 +195,7 @@
<th>Effect</th>
<th>행 규칙</th>
<th>적용 필터</th>
<th>NULL 제외 컬럼</th>
<th>원문 표시 허용 컬럼</th>
<th></th>
</tr>
</thead>