Consolidate data access control backoffice updates
This commit is contained in:
@@ -5,6 +5,67 @@ document.body.addEventListener('htmx:responseError', (event) => {
|
||||
}
|
||||
});
|
||||
|
||||
const READ_ONLY_POST_ALLOWLIST = new Set([
|
||||
'/login',
|
||||
'/logout',
|
||||
'/probe',
|
||||
'/vector-knowledge/search',
|
||||
'/security-sql-scripts/explanation',
|
||||
'/mcp-chatbot',
|
||||
'/mcp-client-demo',
|
||||
'/mcp-reasoning',
|
||||
]);
|
||||
|
||||
function normalizeActionPath(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return new URL(value, window.location.origin).pathname;
|
||||
} catch (_error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function isReadOnlyMode() {
|
||||
return document.querySelector('meta[name="backoffice-read-only"]')?.content === 'true';
|
||||
}
|
||||
|
||||
function isFormMutation(form) {
|
||||
const method = (form.getAttribute('method') || 'get').toLowerCase();
|
||||
const hxPost = form.getAttribute('hx-post');
|
||||
if (hxPost) {
|
||||
return !READ_ONLY_POST_ALLOWLIST.has(normalizeActionPath(hxPost));
|
||||
}
|
||||
if (method !== 'post') {
|
||||
return false;
|
||||
}
|
||||
return !READ_ONLY_POST_ALLOWLIST.has(normalizeActionPath(form.getAttribute('action')));
|
||||
}
|
||||
|
||||
function disableReadOnlyMutationForms() {
|
||||
if (!isReadOnlyMode()) {
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll('form').forEach((form) => {
|
||||
if (!isFormMutation(form)) {
|
||||
return;
|
||||
}
|
||||
if (!form.previousElementSibling?.matches?.('[data-readonly-mutation-notice]')) {
|
||||
const notice = document.createElement('div');
|
||||
notice.className = 'alert alert-secondary readonly-mutation-notice';
|
||||
notice.setAttribute('data-readonly-mutation-notice', 'true');
|
||||
notice.textContent = '읽기 전용 계정은 이 변경 작업을 실행할 수 없습니다.';
|
||||
form.parentNode?.insertBefore(notice, form);
|
||||
}
|
||||
form.classList.add('readonly-mutation-disabled');
|
||||
form.setAttribute('aria-disabled', 'true');
|
||||
form.querySelectorAll('input:not([type="hidden"]), select, textarea, button').forEach((control) => {
|
||||
control.disabled = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resetFilterToggle(button, target) {
|
||||
if (!button || !target) {
|
||||
return;
|
||||
@@ -324,60 +385,6 @@ 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(', ');
|
||||
renderSelectedVisibleColumns();
|
||||
updatePermissionWizardPreview(document);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderSelectedVisibleColumns() {
|
||||
const input = document.querySelector('input[name="visibleColumns"]');
|
||||
const list = document.querySelector('[data-selected-visible-columns]');
|
||||
if (!input || !list) {
|
||||
return;
|
||||
}
|
||||
const columns = input.value.split(',').map((value) => value.trim().toUpperCase()).filter(Boolean);
|
||||
list.innerHTML = columns.map((column) => (
|
||||
`<button type="button" class="badge text-bg-secondary border-0 me-1 mb-1" data-remove-visible-column="${column}">`
|
||||
+ `${escapeHtml(column)} ×</button>`
|
||||
)).join('');
|
||||
list.querySelectorAll('[data-remove-visible-column]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const remove = button.dataset.removeVisibleColumn;
|
||||
input.value = columns.filter((column) => column !== remove).join(', ');
|
||||
renderSelectedVisibleColumns();
|
||||
updatePermissionWizardPreview(document);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function syncRuleTypeHints(root = document) {
|
||||
root.querySelectorAll('.rule-row').forEach((row) => {
|
||||
const typeSelect = row.querySelector('.rule-type-select');
|
||||
@@ -440,7 +447,6 @@ async function syncRuleColumnOptions() {
|
||||
renderRuleColumnOptions(columns);
|
||||
updatePermissionWizardPreview(document);
|
||||
};
|
||||
renderMaskableColumnOptions(selectedOption);
|
||||
applyColumns(fallbackColumns);
|
||||
try {
|
||||
const response = await fetch(`/permissions/object-columns?objectRef=${encodeURIComponent(objectSelect.value)}`);
|
||||
@@ -700,6 +706,54 @@ function sqlLiteral(value) {
|
||||
return `'${String(value || '').replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
function permissionRuleBusinessLabel(type, column, value) {
|
||||
const displayColumn = column || {
|
||||
MY_DEPT: 'DEPT_CODE',
|
||||
SELF: 'OWNER_EMP_NO',
|
||||
DEPT: 'DEPT_CODE',
|
||||
EMP_NO: 'OWNER_EMP_NO',
|
||||
TAG: 'TECH_TAG',
|
||||
TOKEN_SUBJECT: 'USER_ID',
|
||||
OWN_CONTRACT: 'FC_ID',
|
||||
CHANNEL_CONTRACT: 'FC_CHANNEL',
|
||||
OWN_CUSTOMER: 'CUST_ID/CONTRACT_NO',
|
||||
CHANNEL_CUSTOMER: 'CUST_ID/CONTRACT_NO',
|
||||
STATIC_SQL: '',
|
||||
ALL: ''
|
||||
}[type] || '선택 컬럼';
|
||||
if (type === 'ALL') {
|
||||
return '전체 행';
|
||||
}
|
||||
if (type === 'TOKEN_SUBJECT') {
|
||||
return `토큰 이해관계자 본인 행 (${displayColumn})`;
|
||||
}
|
||||
if (type === 'OWN_CONTRACT') {
|
||||
return `담당 설계사 본인 계약 (${displayColumn})`;
|
||||
}
|
||||
if (type === 'CHANNEL_CONTRACT') {
|
||||
return `토큰 사용자 채널 계약 (${displayColumn})`;
|
||||
}
|
||||
if (type === 'OWN_CUSTOMER') {
|
||||
return `담당 설계사 본인 계약에 연결된 고객/청구/외부보유 (${displayColumn})`;
|
||||
}
|
||||
if (type === 'CHANNEL_CUSTOMER') {
|
||||
return `토큰 사용자 채널 계약에 연결된 고객/청구/외부보유 (${displayColumn})`;
|
||||
}
|
||||
if (type === 'STATIC_SQL') {
|
||||
return `정적 SQL 조건: ${value || '조건식 미입력'}`;
|
||||
}
|
||||
if (type === 'MY_DEPT') {
|
||||
return `내 부서 행 (${displayColumn})`;
|
||||
}
|
||||
if (type === 'SELF') {
|
||||
return `내 사번/소유자 행 (${displayColumn})`;
|
||||
}
|
||||
if (type === 'TAG') {
|
||||
return `태그 조건 ${displayColumn} contains ${value || '-'}`;
|
||||
}
|
||||
return [displayColumn, type, value].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function collectWizardRules(root) {
|
||||
return Array.from(root.querySelectorAll('.rule-row')).map((row) => {
|
||||
const column = row.querySelector('[name="ruleColumn"]')?.value || '';
|
||||
@@ -721,25 +775,7 @@ function collectWizardRules(root) {
|
||||
STATIC_SQL: '',
|
||||
ALL: ''
|
||||
}[type] || '';
|
||||
if (type === 'ALL') {
|
||||
return 'ALL';
|
||||
}
|
||||
if (type === 'STATIC_SQL') {
|
||||
return `정적 SQL: ${value}`;
|
||||
}
|
||||
if (['MY_DEPT', 'SELF'].includes(type)) {
|
||||
return `${displayColumn} ${type}`;
|
||||
}
|
||||
if (['STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL'].includes(type)) {
|
||||
return `${displayColumn} ${type} 역할=${value}`;
|
||||
}
|
||||
if (['TOKEN_SUBJECT', 'OWN_CONTRACT', 'CHANNEL_CONTRACT', 'OWN_CUSTOMER', 'CHANNEL_CUSTOMER'].includes(type)) {
|
||||
return `${displayColumn} ${type}`;
|
||||
}
|
||||
if (type === 'TAG') {
|
||||
return `${displayColumn} TAG ${value.toUpperCase()}`;
|
||||
}
|
||||
return [displayColumn, type, value].filter(Boolean).join(' ');
|
||||
return permissionRuleBusinessLabel(type, displayColumn, value);
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -821,7 +857,6 @@ function updatePermissionWizardPreview(root = document) {
|
||||
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 = selectedOption(roleSelect);
|
||||
const objectOption = selectedOption(objectSelect);
|
||||
const effect = effectSelect?.value || 'ALLOW';
|
||||
@@ -837,20 +872,15 @@ function updatePermissionWizardPreview(root = document) {
|
||||
const groupUsers = splitList(roleOption?.dataset.groupUsers || '');
|
||||
const objectColumns = splitList(objectOption?.dataset.columns || '', ',');
|
||||
const maskableColumns = splitList(objectOption?.dataset.maskableColumns || '', ',');
|
||||
const visibleColumnList = splitList(visibleColumns, ',').map((column) => column.toUpperCase());
|
||||
const nullColumns = maskableColumns.filter((column) => !visibleColumnList.includes(column.toUpperCase()));
|
||||
const rowPolicy = effect === 'DENY'
|
||||
? `거부 규칙: ${ruleText}`
|
||||
: `허용 규칙: ${ruleText}`;
|
||||
const predicateText = effect === 'DENY'
|
||||
? (rules.includes('ALL') ? 'DENY ALL: 1 = 0' : `DENY 후보: NOT (${predicates.join(' AND ') || '조건 없음'})`)
|
||||
: `ALLOW 후보: ${predicates.join(' AND ') || '조건 없음'}`;
|
||||
const columnPolicy = visibleColumns
|
||||
? `이 권한에서 원문 표시 허용: ${visibleColumns}`
|
||||
: '마스킹 대상 컬럼은 기본 정책대로 NULL/마스킹 처리';
|
||||
const nullPolicy = !maskableColumns.length
|
||||
? '마스킹 대상 컬럼 없음'
|
||||
: (nullColumns.length ? `NULL 처리: ${nullColumns.join(', ')}` : '선택한 마스킹 컬럼 모두 원문 표시 허용');
|
||||
const columnPolicy = maskableColumns.length
|
||||
? `ASO 마스킹 후보: ${maskableColumns.join(', ')}. 실제 원문/마스킹은 컬럼 마스킹 화면에서 설정합니다.`
|
||||
: '이 행 접근 규칙은 행만 제어합니다. 컬럼 원문/마스킹은 컬럼 마스킹에서 관리합니다.';
|
||||
const affectedPrincipals = `직접 사용자 ${directUsers.length}명 / 그룹 ${groups.length}개 / 그룹 상속 사용자 ${groupUsers.length}명`;
|
||||
const readiness = !hasRole
|
||||
? '역할을 선택하세요'
|
||||
@@ -883,7 +913,6 @@ function updatePermissionWizardPreview(root = document) {
|
||||
setWizardPreview(wizard, 'rowPolicy', rowPolicy);
|
||||
setWizardPreview(wizard, 'predicatePreview', predicateText);
|
||||
setWizardPreview(wizard, 'columnPolicy', columnPolicy);
|
||||
setWizardPreview(wizard, 'nullPolicy', nullPolicy);
|
||||
setWizardPreview(wizard, 'saveGuard', hasRole && hasObject ? saveGuard : '역할과 보호 객체를 선택하면 저장 영향을 계산합니다.');
|
||||
}
|
||||
|
||||
@@ -1185,12 +1214,72 @@ function initVpdTargetFilters() {
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function initMaskingExceptionPreview() {
|
||||
const preview = document.querySelector('[data-masking-exception-preview]');
|
||||
const columnSelect = document.querySelector('select[name="columnId"]');
|
||||
const userSelect = document.querySelector('select[name="userId"]');
|
||||
if (!preview || !columnSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const empty = preview.querySelector('[data-masking-preview-empty]');
|
||||
const details = preview.querySelector('[data-masking-preview-details]');
|
||||
const target = preview.querySelector('[data-masking-preview-target]');
|
||||
const masked = preview.querySelector('[data-masking-preview-masked]');
|
||||
const unmasked = preview.querySelector('[data-masking-preview-unmasked]');
|
||||
const context = preview.querySelector('[data-masking-preview-context]');
|
||||
|
||||
const update = () => {
|
||||
const option = columnSelect.options[columnSelect.selectedIndex];
|
||||
if (!option?.value) {
|
||||
if (empty) {
|
||||
empty.hidden = false;
|
||||
}
|
||||
if (details) {
|
||||
details.hidden = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const selectedUser = userSelect?.options[userSelect.selectedIndex];
|
||||
const userLabel = selectedUser?.dataset.userLabel || '선택한 사용자';
|
||||
const columnTarget = option.dataset.target || option.textContent?.trim() || '선택한 컬럼';
|
||||
const template = option.dataset.template || '컬럼 마스킹 규칙';
|
||||
const result = option.dataset.result || '마스킹된 값';
|
||||
const asoFunction = option.dataset.asoFunction || 'DBMS_REDACT';
|
||||
const contextName = option.dataset.context || 'MR_<column_id>';
|
||||
|
||||
if (empty) {
|
||||
empty.hidden = true;
|
||||
}
|
||||
if (details) {
|
||||
details.hidden = false;
|
||||
}
|
||||
if (target) {
|
||||
target.textContent = `${columnTarget} · ${template} (${asoFunction})`;
|
||||
}
|
||||
if (masked) {
|
||||
masked.textContent = `${columnTarget} 값은 ${result}로 반환됩니다.`;
|
||||
}
|
||||
if (unmasked) {
|
||||
unmasked.textContent = `${userLabel}은(는) 행 접근 정책이 허용한 행에서 원래 ${columnTarget} 값을 봅니다.`;
|
||||
}
|
||||
if (context) {
|
||||
context.textContent = `CB_AGENT_CTX.${contextName} = Y이면 원문, 기본값 또는 N이면 ${asoFunction}으로 마스킹합니다.`;
|
||||
}
|
||||
};
|
||||
|
||||
columnSelect.addEventListener('change', update);
|
||||
userSelect?.addEventListener('change', update);
|
||||
update();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initPersistentMenus();
|
||||
initQuestionPresets();
|
||||
initCopyButtons();
|
||||
renderMarkdownViews();
|
||||
initVpdTargetFilters();
|
||||
initMaskingExceptionPreview();
|
||||
const master = document.getElementById('userRoleMaster');
|
||||
if (master) {
|
||||
master.addEventListener('change', filterUserRoleDetail);
|
||||
@@ -1219,8 +1308,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
});
|
||||
syncRuleTypeHints();
|
||||
document.querySelector('input[name="visibleColumns"]')?.addEventListener('input', renderSelectedVisibleColumns);
|
||||
renderSelectedVisibleColumns();
|
||||
const catalog = document.getElementById('objectCatalogSelect');
|
||||
if (catalog) {
|
||||
catalog.addEventListener('change', syncObjectCatalogSelection);
|
||||
@@ -1261,6 +1348,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
});
|
||||
updatePermissionWizardPreview(document);
|
||||
disableReadOnlyMutationForms();
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:beforeRequest', (event) => {
|
||||
@@ -1284,4 +1372,5 @@ document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||
button.classList.add('active');
|
||||
}
|
||||
renderMarkdownViews(event.detail.target || document);
|
||||
disableReadOnlyMutationForms();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user