Consolidate data access control backoffice updates

This commit is contained in:
devmrko
2026-07-13 23:06:23 +09:00
parent 403298d474
commit e18b30feab
181 changed files with 11571 additions and 954 deletions

View File

@@ -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();
});