diff --git a/docs/design/467-column-sensitivity-redaction/README.md b/docs/design/467-column-sensitivity-redaction/README.md index 86e45d9..ddd444a 100644 --- a/docs/design/467-column-sensitivity-redaction/README.md +++ b/docs/design/467-column-sensitivity-redaction/README.md @@ -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과 호환되도록 표시 예외 구조를 유지한다. diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java index 10c3bb5..d955ca0 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java @@ -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"; diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index cf1ccb6..0ea085a 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -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 = '선택한 객체에 권한별로 허용할 마스킹 컬럼이 없습니다.'; + return; + } + list.innerHTML = columns.map((column, index) => ( + `` + )).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); diff --git a/src/main/resources/templates/objects.html b/src/main/resources/templates/objects.html index 99b4fdb..e3d54f8 100644 --- a/src/main/resources/templates/objects.html +++ b/src/main/resources/templates/objects.html @@ -87,7 +87,7 @@