Files
sam-api/public/tenant/permission/analyze.php
hskwon cc206fdbed style: Laravel Pint 코드 포맷팅 적용
- PSR-12 스타일 가이드 준수
- 302개 파일 스타일 이슈 자동 수정
- 코드 로직 변경 없음 (포맷팅만)
2025-11-06 17:45:49 +09:00

533 lines
30 KiB
PHP

<?php
// 권한 분석 — 선택한 메뉴/액션 기준으로 효과권한(EFFECTIVE) 분석
$CURRENT_SECTION = 'permission';
include '../inc/header.php';
?>
<div class="container" style="max-width:1280px; margin-top:24px;">
<div class="card shadow-sm">
<div class="card-header d-flex flex-wrap gap-2 align-items-center">
<strong>권한 분석</strong>
<select class="form-select form-select-sm" id="actionSelect" style="width:160px;">
<option value="read">읽기(read)</option>
<option value="create">쓰기(create)</option>
<option value="update">수정(update)</option>
<option value="delete">삭제(delete)</option>
<option value="approve">결재(approve)</option>
</select>
<input class="form-control form-control-sm" id="keyword" placeholder="사용자/부서/역할 검색" style="width:260px;">
<button class="btn btn-sm btn-outline-secondary" id="btnRecalc">권한 재계산</button>
<div class="ms-auto d-flex gap-2">
<button class="btn btn-sm btn-outline-success" id="btnExport">CSV 내보내기</button>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<!-- 좌: 메뉴 트리 -->
<div class="col-md-5">
<div class="border rounded p-2">
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="small text-muted">분석 메뉴</div>
<div class="input-group input-group-sm" style="max-width:280px;">
<input type="text" class="form-control" id="menuSearch" placeholder="메뉴/코드 검색">
<button class="btn btn-outline-secondary" id="btnMenuSearch">검색</button>
<button class="btn btn-outline-secondary" id="btnMenuReset">초기화</button>
</div>
<!-- (기존) 메뉴 검색 input-group 바로 뒤에 추가 -->
<div class="form-check form-check-sm ms-2">
<input class="form-check-input" type="checkbox" id="highlightAllowed">
<label class="form-check-label small" for="highlightAllowed">접근가능</label>
</div>
</div>
<div class="table-responsive" style="max-height:64vh; overflow:auto;">
<table class="table table-sm align-middle" id="menuTable">
<thead class="table-light">
<tr>
<th style="width:60%;">메뉴</th>
<th>코드</th>
</tr>
</thead>
<tbody><!-- PermissionMenu로 렌더 --></tbody>
</table>
</div>
</div>
</div>
<!-- 우: 분석 결과 -->
<div class="col-md-7">
<div class="border rounded p-2">
<div class="d-flex align-items-center justify-content-between mb-2">
<div>
<div class="small text-muted">선택 메뉴</div>
<div id="selMenuTitle" class="fw-semibold">-</div>
</div>
<div class="small text-muted">
결론 규칙: <code>ALLOW = 부서 OR 역할 OR (개인 ALLOW)</code> → <code>개인 DENY 최우선</code>
</div>
</div>
<ul class="nav nav-pills mb-2" role="tablist">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tabAllow">접근 가능</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabDeny">접근 불가</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabWhy">근거/상세</button></li>
<!-- 탭 버튼들 (기존 3개 뒤에 추가) -->
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabUser">사용자 역추적</button></li>
</ul>
<div class="tab-content">
<!-- 접근 가능 -->
<div class="tab-pane fade show active" id="tabAllow">
<div class="table-responsive" style="max-height:28vh; overflow:auto;">
<table class="table table-sm align-middle" id="tblAllow">
<thead class="table-light">
<tr><th style="width:30%;">사용자</th><th>부서</th><th>역할</th><th>개인모드</th><th>최종</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- 접근 불가 -->
<div class="tab-pane fade" id="tabDeny">
<div class="table-responsive" style="max-height:28vh; overflow:auto;">
<table class="table table-sm align-middle" id="tblDeny">
<thead class="table-light">
<tr><th style="width:30%;">사용자</th><th>부서</th><th>역할</th><th>개인모드</th><th>사유</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- 근거/상세 -->
<div class="tab-pane fade" id="tabWhy">
<div class="table-responsive" style="max-height:28vh; overflow:auto;">
<table class="table table-sm align-middle" id="tblWhy">
<thead class="table-light">
<tr><th style="width:24%;">사용자</th><th>부서 허용</th><th>역할 허용</th><th>개인 허용</th><th>개인 DENY</th><th>최종</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="small text-muted">* “개인 DENY”가 하나라도 있으면 최종 DENY (최우선)</div>
</div>
<!-- 탭 콘텐츠 (아래 블록을 기존 tab-content 맨 아래에 추가) -->
<div class="tab-pane fade" id="tabUser">
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<select class="form-select form-select-sm" id="userTraceSelect" style="width:260px;">
<!-- 샘플: USERS 배열과 동일하게 -->
<option value="101">권혁성(kevin)</option>
<option value="102">김슬기(sally)</option>
<option value="103">이민수(minsu)</option>
</select>
<select class="form-select form-select-sm" id="userTraceAction" style="width:160px;">
<option value="read">읽기(read)</option>
<option value="create">쓰기(create)</option>
<option value="update">수정(update)</option>
<option value="delete">삭제(delete)</option>
<option value="approve">결재(approve)</option>
</select>
<div class="form-check ms-2">
<input class="form-check-input" type="checkbox" id="onlyAllow">
<label class="form-check-label" for="onlyAllow">ALLOW만 보기</label>
</div>
<div class="ms-auto d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" id="btnTraceExport">CSV</button>
</div>
</div>
<div class="table-responsive" style="max-height:50vh; overflow:auto;">
<table class="table table-sm align-middle" id="tblUserTrace">
<thead class="table-light">
<tr>
<th style="width:40%;">메뉴</th>
<th>코드</th>
<th class="text-center">최종</th>
<th class="text-center">부서</th>
<th class="text-center">역할</th>
<th class="text-center">개인 ALLOW</th>
<th class="text-center">개인 DENY</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div> <!-- row -->
</div>
</div>
</div>
<style>
.menu-name { font-size:14px; font-weight:500; }
.menu-url { color:#6c757d; font-size:12px;}
.indent { font-family: ui-monospace, Menlo, Consolas, monospace; color:#6c757d; }
#menuTable tbody tr.active { background: #eef3ff; }
.badge-inherit{ background:#6c757d; }
.badge-allow{ background:#0d6efd; }
.badge-deny{ background:#dc3545; }
/* ALLOW 행은 왼쪽 라인 + 은은한 배경, DENY는 살짝 흐리게 */
#menuTable tbody tr.allow-row {
background: #f3f8ff;
border-left: 4px solid #0d6efd;
}
#menuTable tbody tr.deny-dim {
opacity: .45;
}
</style>
<script src="/tenant/assets/js/permission_menu.js"></script>
<script>
// ================== 샘플 데이터 (실전: AJAX로 교체) ==================
const MENU_DATA = [
{id:1,parent_id:null,title:'대시보드',code:'dashboard',path:'/tenant/member/dashboard.php',source:'system'},
{id:2,parent_id:null,title:'수주',code:'order',path:null,source:'system'},
{id:21,parent_id:2,title:'수주 관리',code:'order.manage',path:'/tenant/order/manage.php',source:'system'},
{id:22,parent_id:2,title:'수주 등록/수정',code:'order.edit',path:'/tenant/order/edit.php',source:'system'},
{id:3,parent_id:null,title:'생산',code:'production',path:null,source:'system'},
{id:31,parent_id:3,title:'작업 지시',code:'production.wi',path:'/tenant/production/work_instruction.php',source:'workflow'},
{id:32,parent_id:3,title:'스크린 작업',code:'production.screen',path:'/tenant/production/screen_work.php',source:'workflow'},
{id:5,parent_id:null,title:'게시판',code:'board',path:null,source:'system'},
{id:51,parent_id:5,title:'공지사항',code:'board.notice',path:'/tenant/board/notice_list.php',source:'board'},
];
const TENANT_USE = {dashboard:true, order:true,'order.manage':true,'order.edit':false,
production:true,'production.wi':true,'production.screen':true,
board:true,'board.notice':true};
// 조직/역할/유저 샘플
const DEPARTMENTS = [{id:201,name:'개발팀'},{id:202,name:'영업팀'}];
const ROLES = [{id:301,name:'최고관리자'},{id:302,name:'일반관리자'},{id:303,name:'직원'}];
const USERS = [
{id:101, name:'권혁성', uid:'kevin', dept_id:201, role_ids:[301,302]},
{id:102, name:'김슬기', uid:'sally', dept_id:201, role_ids:[303]},
{id:103, name:'이민수', uid:'minsu', dept_id:202, role_ids:[303]},
];
// 부서/역할 권한 정의(샘플) — 실제는 DB에서 로드
const DEPT_PERMS = {
// dept_id: { code: {read,create,update,delete,approve} }
201: { 'order.manage': {read:true,create:true,update:true,delete:false,approve:false},
'production.wi':{read:true,create:true,update:false,delete:false,approve:false},
'dashboard': {read:true,create:false,update:false,delete:false,approve:false},
'board.notice': {read:true,create:false,update:false,delete:false,approve:false} },
202: { 'order.manage': {read:true,create:false,update:false,delete:false,approve:false},
'dashboard': {read:true,create:false,update:false,delete:false,approve:false} }
};
const ROLE_PERMS = {
// role_id: { code: {...} }
301: { 'order.manage':{read:true,create:true,update:true,delete:true,approve:true},
'production': {read:true,create:false,update:false,delete:false,approve:false} },
302: { 'production.wi':{read:true,create:true,update:true,delete:false,approve:false} },
303: { 'board.notice':{read:true,create:true,update:true,delete:false,approve:false} }
};
// 개인 예외(샘플)
// 모드: 사용자별 전역 모드 (INHERIT/ALLOW/DENY)
const USER_MODE = { 101:'ALLOW', 102:'INHERIT', 103:'DENY' };
// 개인 허용(ALLOW 모드일 때만 의미) — user_id: { code: {...} }
const USER_PERMS = {
101: { 'production.screen': {read:true,create:true,update:false,delete:false,approve:false} }
};
// 개인 DENY(선택사항: 메뉴별 차단) — user_id: Set(menu_code)
const USER_DENY = {
102: new Set(['order.manage']) // 예: 상속으로 되더라도 이 메뉴만 차단
};
// ================== 공통 트리 세팅 + 좌측 메뉴 렌더 ==================
PermissionMenu.setData(MENU_DATA, TENANT_USE);
function renderMenuList(){
const rows = PermissionMenu.buildRows((node)=>{
return `<td class="text-muted">${node.code}</td>`;
});
document.querySelector('#menuTable tbody').innerHTML = rows;
}
renderMenuList();
let selectedMenuCode = null;
function selectFirstMenuIfNeeded(){
if (!selectedMenuCode){
const first = document.querySelector('#menuTable tbody tr');
if (first){ first.classList.add('active'); selectedMenuCode = first.dataset.code; updateSelectedInfo(); runAnalyze(); }
}
}
function updateSelectedInfo(){
const tr = document.querySelector('#menuTable tbody tr.active');
const title = tr ? tr.querySelector('td:first-child').innerText.trim() : '-';
document.querySelector('#selMenuTitle').innerText = title;
}
// 메뉴 선택
document.querySelector('#menuTable').addEventListener('click', (e)=>{
const tr = e.target.closest('tr'); if(!tr) return;
document.querySelectorAll('#menuTable tbody tr').forEach(x=> x.classList.remove('active'));
tr.classList.add('active');
selectedMenuCode = tr.dataset.code;
updateSelectedInfo();
runAnalyze();
});
// 메뉴 검색
document.querySelector('#btnMenuSearch').addEventListener('click', ()=>{
const q = document.querySelector('#menuSearch').value.trim().toLowerCase();
document.querySelectorAll('#menuTable tbody tr').forEach(tr=>{
const name = tr.querySelector('td:first-child').innerText.toLowerCase();
const code = tr.dataset.code.toLowerCase();
tr.style.display = (q==='' || name.includes(q) || code.includes(q)) ? '' : 'none';
});
});
document.querySelector('#btnMenuReset').addEventListener('click', ()=>{
document.querySelector('#menuSearch').value=''; document.querySelectorAll('#menuTable tbody tr').forEach(tr=> tr.style.display='');
});
// ================== 분석 로직 ==================
function hasDeptAllow(userId, code, action){
const user = USERS.find(u=>u.id==userId); if(!user) return false;
const map = DEPT_PERMS[user.dept_id]||{};
return !!(map[code] && map[code][action]);
}
function hasRoleAllow(userId, code, action){
const user = USERS.find(u=>u.id==userId); if(!user) return false;
return user.role_ids.some(rid => !!(ROLE_PERMS[rid] && ROLE_PERMS[rid][code] && ROLE_PERMS[rid][code][action]));
}
function hasUserAllow(userId, code, action){
const mode = USER_MODE[userId] || 'INHERIT';
if (mode!=='ALLOW') return false;
const up = USER_PERMS[userId]||{};
return !!(up[code] && up[code][action]);
}
function hasUserDeny(userId, code){
// 전역 DENY 우선
if ((USER_MODE[userId]||'INHERIT')==='DENY') return true;
// 메뉴별 DENY
return !!(USER_DENY[userId] && USER_DENY[userId].has(code));
}
function effective(userId, code, action){
// 개인 DENY 최우선
if (hasUserDeny(userId, code)) return {allow:false, reason:'개인 DENY'};
const allow = hasDeptAllow(userId, code, action) || hasRoleAllow(userId, code, action) || hasUserAllow(userId, code, action);
return {allow, reason: allow?'ALLOW':'NO MATCH'};
}
function runAnalyze(){
if (!selectedMenuCode){ selectFirstMenuIfNeeded(); return; }
const action = document.querySelector('#actionSelect').value;
const kw = document.querySelector('#keyword').value.trim().toLowerCase();
const rowsAllow=[], rowsDeny=[], rowsWhy=[];
USERS.forEach(u=>{
const name = `${u.name}(${u.uid})`;
if (kw && !name.toLowerCase().includes(kw)) return;
const dept = (DEPARTMENTS.find(d=>d.id===u.dept_id)||{}).name || '-';
const roleNames = u.role_ids.map(id => (ROLES.find(r=>r.id===id)||{}).name).filter(Boolean).join(', ') || '-';
const mode = USER_MODE[u.id] || 'INHERIT';
const deptOk = hasDeptAllow(u.id, selectedMenuCode, action);
const roleOk = hasRoleAllow(u.id, selectedMenuCode, action);
const userOk = hasUserAllow(u.id, selectedMenuCode, action);
const uDeny = hasUserDeny(u.id, selectedMenuCode);
const eff = effective(u.id, selectedMenuCode, action);
const modeBadge = mode==='INHERIT' ? '<span class="badge badge-inherit">INHERIT</span>' :
mode==='ALLOW' ? '<span class="badge badge-allow">ALLOW</span>' :
'<span class="badge badge-deny">DENY</span>';
const finalBadge = eff.allow ? '<span class="badge bg-primary">ALLOW</span>' :
'<span class="badge bg-danger">DENY</span>';
// WHY 표
rowsWhy.push(`<tr>
<td>${name}</td>
<td>${deptOk?'Y':''}</td>
<td>${roleOk?'Y':''}</td>
<td>${userOk?'Y':''}</td>
<td>${uDeny?'Y':''}</td>
<td>${eff.allow? 'ALLOW':'DENY'}</td>
</tr>`);
if (eff.allow){
rowsAllow.push(`<tr>
<td>${name}</td><td>${dept}</td><td>${roleNames}</td><td>${modeBadge}</td><td>${finalBadge}</td>
</tr>`);
} else {
const reason = uDeny ? '개인 DENY' : '권한 없음';
rowsDeny.push(`<tr>
<td>${name}</td><td>${dept}</td><td>${roleNames}</td><td>${modeBadge}</td><td>${reason}</td>
</tr>`);
}
});
document.querySelector('#tblAllow tbody').innerHTML = rowsAllow.join('') || `<tr><td colspan="5" class="text-center text-muted">해당 없음</td></tr>`;
document.querySelector('#tblDeny tbody').innerHTML = rowsDeny.join('') || `<tr><td colspan="5" class="text-center text-muted">해당 없음</td></tr>`;
document.querySelector('#tblWhy tbody').innerHTML = rowsWhy.join('') || `<tr><td colspan="6" class="text-center text-muted">해당 없음</td></tr>`;
}
// 액션/검색/재계산
document.querySelector('#actionSelect').addEventListener('change', runAnalyze);
document.querySelector('#keyword').addEventListener('keyup', (e)=>{ if(e.key==='Enter') runAnalyze(); });
document.querySelector('#btnRecalc').addEventListener('click', runAnalyze);
// CSV 내보내기(접근 가능 탭 기준 + WHY 탭 함께)
document.querySelector('#btnExport').addEventListener('click', ()=>{
const rows = [['tab','user','dept','roles','mode','final','dept_allow','role_allow','user_allow','user_deny']];
USERS.forEach(u=>{
const dept = (DEPARTMENTS.find(d=>d.id===u.dept_id)||{}).name || '-';
const roleNames = u.role_ids.map(id => (ROLES.find(r=>r.id===id)||{}).name).filter(Boolean).join('|') || '-';
const mode = USER_MODE[u.id] || 'INHERIT';
const a = document.querySelector('#actionSelect').value;
const code = selectedMenuCode || '';
const deptOk = hasDeptAllow(u.id, code, a);
const roleOk = hasRoleAllow(u.id, code, a);
const userOk = hasUserAllow(u.id, code, a);
const uDeny = hasUserDeny(u.id, code);
const eff = effective(u.id, code, a);
rows.push(['result', `${u.name}(${u.uid})`, dept, roleNames, mode, eff.allow?'ALLOW':'DENY', deptOk?'Y':'', roleOk?'Y':'', userOk?'Y':'', uDeny?'Y':'']);
});
const csv = rows.map(r=> r.map(x=> `"${String(x).replace(/"/g,'""')}"`).join(',')).join('\n');
const blob = new Blob([csv], {type:'text/csv;charset=utf-8;'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'permission_analysis.csv'; a.click();
URL.revokeObjectURL(url);
});
// 최초 선택/분석
selectFirstMenuIfNeeded();
// ---------- 사용자 역추적 ----------
function traceEffectiveForUser(userId, code, action){
// anlyze 탭에서 쓰던 로직 재사용
const deptOk = hasDeptAllow(userId, code, action);
const roleOk = hasRoleAllow(userId, code, action);
const userOk = hasUserAllow(userId, code, action);
const uDeny = hasUserDeny(userId, code);
const allow = !uDeny && (deptOk || roleOk || userOk);
return {allow, deptOk, roleOk, userOk, uDeny};
}
function renderUserTrace(){
const userId = parseInt(document.getElementById('userTraceSelect').value,10);
const action = document.getElementById('userTraceAction').value;
const onlyAllow = document.getElementById('onlyAllow').checked;
const rows = [];
// PermissionMenu.activeCodes() 순회해서 메뉴별 결과 뽑기
const { maps } = PermissionMenu;
const codes = PermissionMenu.activeCodes();
const { byCode } = maps();
codes.forEach(code=>{
const node = byCode[code];
if (!node) return;
const r = traceEffectiveForUser(userId, code, action);
if (onlyAllow && !r.allow) return;
const finalBadge = r.allow ? '<span class="badge bg-primary">ALLOW</span>'
: '<span class="badge bg-danger">DENY</span>';
rows.push(`
<tr>
<td><span class="menu-name">${node.title}</span>
${node.source==='workflow' ? '<span class="badge bg-warning text-dark ms-1">workflow</span>':''}
${node.source==='board' ? '<span class="badge bg-success ms-1">board</span>':''}
${node.path? `<span class="menu-url ms-2 text-muted">${node.path}</span>`:''}
</td>
<td class="text-muted">${node.code}</td>
<td class="text-center">${finalBadge}</td>
<td class="text-center">${r.deptOk?'Y':''}</td>
<td class="text-center">${r.roleOk?'Y':''}</td>
<td class="text-center">${r.userOk?'Y':''}</td>
<td class="text-center">${r.uDeny?'Y':''}</td>
</tr>
`);
});
const tbody = document.querySelector('#tblUserTrace tbody');
tbody.innerHTML = rows.join('') || `<tr><td colspan="7" class="text-center text-muted">해당 없음</td></tr>`;
}
// 이벤트 바인딩
['userTraceSelect','userTraceAction','onlyAllow'].forEach(id=>{
const el = document.getElementById(id);
if (el) el.addEventListener('change', renderUserTrace);
});
// CSV 내보내기
document.getElementById('btnTraceExport').addEventListener('click', ()=>{
const userId = parseInt(document.getElementById('userTraceSelect').value,10);
const action = document.getElementById('userTraceAction').value;
const user = USERS.find(u=>u.id===userId);
const rows = [['menu','code','final','dept','role','user_allow','user_deny','action',`user:${user?.name||''}(${user?.uid||''})`]];
const { maps } = PermissionMenu; const { byCode } = maps();
PermissionMenu.activeCodes().forEach(code=>{
const node = byCode[code]; if (!node) return;
const r = traceEffectiveForUser(userId, code, action);
rows.push([
node.title, code, r.allow?'ALLOW':'DENY',
r.deptOk?'Y':'', r.roleOk?'Y':'', r.userOk?'Y':'', r.uDeny?'Y':'', action
]);
});
const csv = rows.map(r=> r.map(x=>`"${String(x).replace(/"/g,'""')}"`).join(',')).join('\n');
const blob = new Blob([csv], {type:'text/csv;charset=utf-8;'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'user_trace.csv'; a.click();
URL.revokeObjectURL(url);
});
// 처음 들어오면 한 번 렌더
renderUserTrace();
// 좌측 트리에 허용 하이라이트 적용
function updateMenuHighlight() {
const chk = document.getElementById('highlightAllowed');
const userSel = document.getElementById('userTraceSelect');
const actSel = document.getElementById('userTraceAction');
if (!chk || !userSel || !actSel) return;
const on = chk.checked;
const userId = parseInt(userSel.value, 10);
const action = actSel.value;
document.querySelectorAll('#menuTable tbody tr').forEach(tr=>{
tr.classList.remove('allow-row','deny-dim');
if (!on) return; // 꺼져 있으면 리셋만
const code = tr.dataset.code;
// analyze 탭에서 쓰던 판단식 재사용
const r = effective(userId, code, action); // {allow:boolean}
if (r.allow) tr.classList.add('allow-row');
else tr.classList.add('deny-dim');
});
}
// 탭/선택 변경 시 갱신
['highlightAllowed','userTraceSelect','userTraceAction'].forEach(id=>{
const el = document.getElementById(id);
if (el) el.addEventListener('change', updateMenuHighlight);
});
// 최초 렌더/재렌더 후에도 호출
document.addEventListener('DOMContentLoaded', updateMenuHighlight);
// 좌측 메뉴 다시 그릴 때도 호출 (이미 있는 렌더 뒤쪽에 한 줄만 추가)
const _origRenderMenuList = renderMenuList;
renderMenuList = function() {
_origRenderMenuList();
updateMenuHighlight();
};
// 사용자 역추적 테이블 재계산 후에도 한 번 더 (이미 만든 함수 끝에 한 줄 추가)
const _origRenderUserTrace = renderUserTrace;
renderUserTrace = function() {
_origRenderUserTrace();
updateMenuHighlight();
};
</script>
<?php include '../inc/footer.php'; ?>