feat(auth): RBAC 오퍼레이션 기반 2차원 권한 시스템 구현
리소스 가시성(READ/HIDE) 단일 차원에서 리소스 × 오퍼레이션(RCUD) 2차원 권한 모델로 전환하여 세밀한 CRUD 권한 제어 지원. - DB: AUTH_PERM에 OPER_CD 컬럼 추가, 마이그레이션 004 작성 - DB: AUTH_PERM_TREE 리소스 트리 테이블 추가 (마이그레이션 003) - Backend: permResolver 2차원 권한 해석 엔진 (상속 + 오퍼레이션) - Backend: requirePermission 미들웨어 신규 (리소스×오퍼레이션 검증) - Backend: authService permissions → Record<string, string[]> 반환 - Backend: roleService/roleRouter OPER_CD 지원 API - Backend: Helmet CORP 설정 (sendBeacon cross-origin 허용) - Frontend: authStore.hasPermission(resource, operation?) 하위 호환 확장 - Frontend: PermissionsPanel 역할탭 + RCUD 4열 매트릭스 UI 전면 재작성 - Frontend: sendBeacon API_BASE_URL 절대경로 전환 - Docs: COMMON-GUIDE 권한 체계 + CRUD API 규칙 문서화 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
c727afd1ba
커밋
8657190578
@ -1,11 +1,13 @@
|
|||||||
import type { Request, Response, NextFunction } from 'express'
|
import type { Request, Response, NextFunction } from 'express'
|
||||||
import { verifyToken, getTokenFromCookie } from './jwtProvider.js'
|
import { verifyToken, getTokenFromCookie } from './jwtProvider.js'
|
||||||
import type { JwtPayload } from './jwtProvider.js'
|
import type { JwtPayload } from './jwtProvider.js'
|
||||||
|
import { getUserInfo } from './authService.js'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace Express {
|
namespace Express {
|
||||||
interface Request {
|
interface Request {
|
||||||
user?: JwtPayload
|
user?: JwtPayload
|
||||||
|
resolvedPermissions?: Record<string, string[]>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -43,3 +45,43 @@ export function requireRole(...roles: string[]) {
|
|||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리소스 + 오퍼레이션 기반 권한 검사 미들웨어.
|
||||||
|
*
|
||||||
|
* OPER_CD는 HTTP Method가 아닌 비즈니스 의미로 결정한다.
|
||||||
|
* 오퍼레이션 미지정 시 기본 'READ'.
|
||||||
|
*
|
||||||
|
* 사용 예:
|
||||||
|
* router.post('/notice/list', requirePermission('board:notice', 'READ'), handler)
|
||||||
|
* router.post('/notice/create', requirePermission('board:notice', 'CREATE'), handler)
|
||||||
|
* router.post('/notice/update', requirePermission('board:notice', 'UPDATE'), handler)
|
||||||
|
* router.post('/notice/delete', requirePermission('board:notice', 'DELETE'), handler)
|
||||||
|
*/
|
||||||
|
export function requirePermission(resource: string, operation: string = 'READ') {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ error: '인증이 필요합니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// req에 캐싱된 permissions 재사용 (요청당 1회만 DB 조회)
|
||||||
|
if (!req.resolvedPermissions) {
|
||||||
|
const userInfo = await getUserInfo(req.user.sub)
|
||||||
|
req.resolvedPermissions = userInfo.permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedOps = req.resolvedPermissions[resource]
|
||||||
|
if (allowedOps && allowedOps.includes(operation)) {
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(403).json({ error: '접근 권한이 없습니다.' })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[auth] 권한 확인 오류:', err)
|
||||||
|
res.status(500).json({ error: '권한 확인 중 오류가 발생했습니다.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import bcrypt from 'bcrypt'
|
|||||||
import { authPool } from '../db/authDb.js'
|
import { authPool } from '../db/authDb.js'
|
||||||
import { signToken, setTokenCookie } from './jwtProvider.js'
|
import { signToken, setTokenCookie } from './jwtProvider.js'
|
||||||
import type { Response } from 'express'
|
import type { Response } from 'express'
|
||||||
|
import { resolvePermissions, makePermKey, grantedSetToRecord } from '../roles/permResolver.js'
|
||||||
|
import { getPermTreeNodes } from '../roles/roleService.js'
|
||||||
|
|
||||||
const MAX_FAIL_COUNT = 5
|
const MAX_FAIL_COUNT = 5
|
||||||
const SALT_ROUNDS = 10
|
const SALT_ROUNDS = 10
|
||||||
@ -24,7 +26,7 @@ interface AuthUserInfo {
|
|||||||
rank: string | null
|
rank: string | null
|
||||||
org: { sn: number; name: string; abbr: string } | null
|
org: { sn: number; name: string; abbr: string } | null
|
||||||
roles: string[]
|
roles: string[]
|
||||||
permissions: string[]
|
permissions: Record<string, string[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(
|
export async function login(
|
||||||
@ -127,9 +129,9 @@ export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
|
|||||||
|
|
||||||
const row = userResult.rows[0]
|
const row = userResult.rows[0]
|
||||||
|
|
||||||
// 역할 조회
|
// 역할 조회 (ROLE_SN + ROLE_CD)
|
||||||
const rolesResult = await authPool.query(
|
const rolesResult = await authPool.query(
|
||||||
`SELECT r.ROLE_CD as role_cd
|
`SELECT r.ROLE_SN as role_sn, r.ROLE_CD as role_cd
|
||||||
FROM AUTH_USER_ROLE ur
|
FROM AUTH_USER_ROLE ur
|
||||||
JOIN AUTH_ROLE r ON ur.ROLE_SN = r.ROLE_SN
|
JOIN AUTH_ROLE r ON ur.ROLE_SN = r.ROLE_SN
|
||||||
WHERE ur.USER_ID = $1`,
|
WHERE ur.USER_ID = $1`,
|
||||||
@ -137,17 +139,63 @@ export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const roles = rolesResult.rows.map((r: { role_cd: string }) => r.role_cd)
|
const roles = rolesResult.rows.map((r: { role_cd: string }) => r.role_cd)
|
||||||
|
const roleSns = rolesResult.rows.map((r: { role_sn: number }) => r.role_sn)
|
||||||
|
|
||||||
// 권한 조회 (역할 기반)
|
// 트리 기반 resolved permissions (리소스 × 오퍼레이션)
|
||||||
const permsResult = await authPool.query(
|
let permissions: Record<string, string[]>
|
||||||
`SELECT DISTINCT p.RSRC_CD as rsrc_cd
|
try {
|
||||||
FROM AUTH_PERM p
|
const treeNodes = await getPermTreeNodes()
|
||||||
JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN
|
|
||||||
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`,
|
|
||||||
[userId]
|
|
||||||
)
|
|
||||||
|
|
||||||
const permissions = permsResult.rows.map((p: { rsrc_cd: string }) => p.rsrc_cd)
|
if (treeNodes.length > 0) {
|
||||||
|
// AUTH_PERM_TREE가 존재 → 트리 기반 resolve
|
||||||
|
const explicitPermsResult = await authPool.query(
|
||||||
|
`SELECT ROLE_SN as role_sn, RSRC_CD as rsrc_cd, OPER_CD as oper_cd, GRANT_YN as grant_yn
|
||||||
|
FROM AUTH_PERM WHERE ROLE_SN = ANY($1)`,
|
||||||
|
[roleSns]
|
||||||
|
)
|
||||||
|
|
||||||
|
const explicitPermsPerRole = new Map<number, Map<string, boolean>>()
|
||||||
|
for (const sn of roleSns) {
|
||||||
|
explicitPermsPerRole.set(sn, new Map())
|
||||||
|
}
|
||||||
|
for (const p of explicitPermsResult.rows) {
|
||||||
|
const roleMap = explicitPermsPerRole.get(p.role_sn)
|
||||||
|
if (roleMap) {
|
||||||
|
const key = makePermKey(p.rsrc_cd, p.oper_cd)
|
||||||
|
roleMap.set(key, p.grant_yn === 'Y')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const granted = resolvePermissions(treeNodes, explicitPermsPerRole)
|
||||||
|
permissions = grantedSetToRecord(granted)
|
||||||
|
} else {
|
||||||
|
// AUTH_PERM_TREE 미존재 (마이그레이션 전) → 기존 플랫 방식 fallback
|
||||||
|
const permsResult = await authPool.query(
|
||||||
|
`SELECT DISTINCT p.RSRC_CD as rsrc_cd
|
||||||
|
FROM AUTH_PERM p
|
||||||
|
JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN
|
||||||
|
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`,
|
||||||
|
[userId]
|
||||||
|
)
|
||||||
|
permissions = {}
|
||||||
|
for (const p of permsResult.rows) {
|
||||||
|
permissions[p.rsrc_cd] = ['READ']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// AUTH_PERM_TREE 테이블 미존재 시 fallback
|
||||||
|
const permsResult = await authPool.query(
|
||||||
|
`SELECT DISTINCT p.RSRC_CD as rsrc_cd
|
||||||
|
FROM AUTH_PERM p
|
||||||
|
JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN
|
||||||
|
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`,
|
||||||
|
[userId]
|
||||||
|
)
|
||||||
|
permissions = {}
|
||||||
|
for (const p of permsResult.rows) {
|
||||||
|
permissions[p.rsrc_cd] = ['READ']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.user_id,
|
id: row.user_id,
|
||||||
|
|||||||
197
backend/src/roles/permResolver.ts
Normal file
197
backend/src/roles/permResolver.ts
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* 트리 구조 기반 권한 해석(Resolution) 유틸.
|
||||||
|
*
|
||||||
|
* 2차원 모델: 리소스 트리(상속) × 오퍼레이션(RCUD, 플랫)
|
||||||
|
*
|
||||||
|
* 규칙:
|
||||||
|
* 1. 부모 리소스의 READ가 N → 자식의 모든 오퍼레이션 강제 N
|
||||||
|
* 2. 해당 (RSRC_CD, OPER_CD) 명시적 레코드 있으면 → 그 값 사용
|
||||||
|
* 3. 명시적 레코드 없으면 → 부모의 같은 OPER_CD 상속
|
||||||
|
* 4. 최상위까지 없으면 → 기본 N (거부)
|
||||||
|
*
|
||||||
|
* 키 형식: "rsrcCode::operCd" (더블콜론으로 리소스와 오퍼레이션 구분)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const OPERATIONS = ['READ', 'CREATE', 'UPDATE', 'DELETE'] as const
|
||||||
|
export type OperationCode = (typeof OPERATIONS)[number]
|
||||||
|
|
||||||
|
export interface PermTreeNode {
|
||||||
|
code: string
|
||||||
|
parentCode: string | null
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
icon: string | null
|
||||||
|
level: number
|
||||||
|
sortOrder: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 리소스::오퍼레이션 키 생성 */
|
||||||
|
export function makePermKey(rsrcCode: string, operCd: string): string {
|
||||||
|
return `${rsrcCode}::${operCd}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 키에서 리소스 코드와 오퍼레이션 코드 분리 */
|
||||||
|
export function parsePermKey(key: string): { rsrcCode: string; operCd: string } {
|
||||||
|
const idx = key.indexOf('::')
|
||||||
|
return {
|
||||||
|
rsrcCode: key.substring(0, idx),
|
||||||
|
operCd: key.substring(idx + 2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트리 노드 + 역할별 명시적 권한 → granted된 "rsrc::oper" Set 반환.
|
||||||
|
* 다중 역할: 역할별 resolve 후 OR (하나라도 Y면 Y).
|
||||||
|
*/
|
||||||
|
export function resolvePermissions(
|
||||||
|
treeNodes: PermTreeNode[],
|
||||||
|
explicitPermsPerRole: Map<number, Map<string, boolean>>,
|
||||||
|
): Set<string> {
|
||||||
|
const granted = new Set<string>()
|
||||||
|
|
||||||
|
const nodeMap = new Map<string, PermTreeNode>()
|
||||||
|
for (const node of treeNodes) {
|
||||||
|
nodeMap.set(node.code, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, explicitPerms] of explicitPermsPerRole) {
|
||||||
|
const roleResolved = resolveForSingleRole(treeNodes, nodeMap, explicitPerms)
|
||||||
|
for (const key of roleResolved) {
|
||||||
|
granted.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return granted
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 역할에 대한 권한 해석.
|
||||||
|
*/
|
||||||
|
function resolveForSingleRole(
|
||||||
|
treeNodes: PermTreeNode[],
|
||||||
|
nodeMap: Map<string, PermTreeNode>,
|
||||||
|
explicitPerms: Map<string, boolean>,
|
||||||
|
): Set<string> {
|
||||||
|
const effective = new Map<string, boolean>()
|
||||||
|
|
||||||
|
// 레벨 순(0→1→2→...)으로 처리하여 부모 → 자식 순서 보장
|
||||||
|
const sorted = [...treeNodes].sort((a, b) => a.level - b.level || a.sortOrder - b.sortOrder)
|
||||||
|
|
||||||
|
for (const node of sorted) {
|
||||||
|
// READ 먼저 resolve (CUD는 READ 결과에 의존)
|
||||||
|
resolveNodeOper(node, 'READ', explicitPerms, effective)
|
||||||
|
|
||||||
|
// CUD resolve
|
||||||
|
for (const oper of OPERATIONS) {
|
||||||
|
if (oper === 'READ') continue
|
||||||
|
resolveNodeOper(node, oper, explicitPerms, effective)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const granted = new Set<string>()
|
||||||
|
for (const [key, value] of effective) {
|
||||||
|
if (value) granted.add(key)
|
||||||
|
}
|
||||||
|
return granted
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개별 노드 × 오퍼레이션의 effective 값 계산.
|
||||||
|
*/
|
||||||
|
function resolveNodeOper(
|
||||||
|
node: PermTreeNode,
|
||||||
|
operCd: string,
|
||||||
|
explicitPerms: Map<string, boolean>,
|
||||||
|
effective: Map<string, boolean>,
|
||||||
|
): void {
|
||||||
|
const key = makePermKey(node.code, operCd)
|
||||||
|
if (effective.has(key)) return
|
||||||
|
|
||||||
|
const explicit = explicitPerms.get(key)
|
||||||
|
|
||||||
|
if (node.parentCode === null) {
|
||||||
|
// 최상위: 명시적 값 또는 기본 거부
|
||||||
|
effective.set(key, explicit ?? false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부모의 READ 확인 (접근 게이트)
|
||||||
|
const parentReadKey = makePermKey(node.parentCode, 'READ')
|
||||||
|
const parentReadEffective = effective.get(parentReadKey)
|
||||||
|
|
||||||
|
if (parentReadEffective === false) {
|
||||||
|
// 부모 READ 차단 → 모든 오퍼레이션 강제 차단
|
||||||
|
effective.set(key, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 명시적 값 있으면 사용
|
||||||
|
if (explicit !== undefined) {
|
||||||
|
effective.set(key, explicit)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부모의 같은 오퍼레이션 상속
|
||||||
|
const parentOperKey = makePermKey(node.parentCode, operCd)
|
||||||
|
const parentOperEffective = effective.get(parentOperKey)
|
||||||
|
effective.set(key, parentOperEffective ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resolved Set → Record<rsrcCode, operCd[]> 변환 (API 반환용).
|
||||||
|
*/
|
||||||
|
export function grantedSetToRecord(granted: Set<string>): Record<string, string[]> {
|
||||||
|
const result: Record<string, string[]> = {}
|
||||||
|
for (const key of granted) {
|
||||||
|
const { rsrcCode, operCd } = parsePermKey(key)
|
||||||
|
if (!result[rsrcCode]) result[rsrcCode] = []
|
||||||
|
result[rsrcCode].push(operCd)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플랫 노드 배열 → 트리 구조 변환 (프론트엔드 UI용).
|
||||||
|
*/
|
||||||
|
export interface PermTreeResponse {
|
||||||
|
code: string
|
||||||
|
parentCode: string | null
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
icon: string | null
|
||||||
|
level: number
|
||||||
|
sortOrder: number
|
||||||
|
children: PermTreeResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPermTree(nodes: PermTreeNode[]): PermTreeResponse[] {
|
||||||
|
const nodeMap = new Map<string, PermTreeResponse>()
|
||||||
|
const roots: PermTreeResponse[] = []
|
||||||
|
|
||||||
|
const sorted = [...nodes].sort((a, b) => a.level - b.level || a.sortOrder - b.sortOrder)
|
||||||
|
|
||||||
|
for (const node of sorted) {
|
||||||
|
const treeNode: PermTreeResponse = {
|
||||||
|
code: node.code,
|
||||||
|
parentCode: node.parentCode,
|
||||||
|
name: node.name,
|
||||||
|
description: node.description,
|
||||||
|
icon: node.icon,
|
||||||
|
level: node.level,
|
||||||
|
sortOrder: node.sortOrder,
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
|
nodeMap.set(node.code, treeNode)
|
||||||
|
|
||||||
|
if (node.parentCode === null) {
|
||||||
|
roots.push(treeNode)
|
||||||
|
} else {
|
||||||
|
const parent = nodeMap.get(node.parentCode)
|
||||||
|
if (parent) {
|
||||||
|
parent.children.push(treeNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots
|
||||||
|
}
|
||||||
@ -1,13 +1,24 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
||||||
import { AuthError } from '../auth/authService.js'
|
import { AuthError } from '../auth/authService.js'
|
||||||
import { listRolesWithPermissions, createRole, updateRole, deleteRole, updatePermissions, updateRoleDefault } from './roleService.js'
|
import { listRolesWithPermissions, createRole, updateRole, deleteRole, updatePermissions, updateRoleDefault, getPermTree } from './roleService.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
router.use(requireAuth)
|
router.use(requireAuth)
|
||||||
router.use(requireRole('ADMIN'))
|
router.use(requireRole('ADMIN'))
|
||||||
|
|
||||||
|
// GET /api/roles/perm-tree — 권한 트리 구조 조회
|
||||||
|
router.get('/perm-tree', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const tree = await getPermTree()
|
||||||
|
res.json(tree)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[roles] 권한 트리 조회 오류:', err)
|
||||||
|
res.status(500).json({ error: '권한 트리 조회 중 오류가 발생했습니다.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// GET /api/roles
|
// GET /api/roles
|
||||||
router.get('/', async (_req, res) => {
|
router.get('/', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
@ -76,6 +87,7 @@ router.delete('/:id', async (req, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// PUT /api/roles/:id/permissions
|
// PUT /api/roles/:id/permissions
|
||||||
|
// 요청: { permissions: [{ resourceCode, operationCode, granted }] }
|
||||||
router.put('/:id/permissions', async (req, res) => {
|
router.put('/:id/permissions', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const roleSn = Number(req.params.id)
|
const roleSn = Number(req.params.id)
|
||||||
@ -86,6 +98,13 @@ router.put('/:id/permissions', async (req, res) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const p of permissions) {
|
||||||
|
if (!p.resourceCode || !p.operationCode || typeof p.granted !== 'boolean') {
|
||||||
|
res.status(400).json({ error: '각 권한에는 resourceCode, operationCode, granted가 필요합니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await updatePermissions(roleSn, permissions)
|
await updatePermissions(roleSn, permissions)
|
||||||
res.json({ success: true })
|
res.json({ success: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -1,13 +1,34 @@
|
|||||||
import { authPool } from '../db/authDb.js'
|
import { authPool } from '../db/authDb.js'
|
||||||
import { AuthError } from '../auth/authService.js'
|
import { AuthError } from '../auth/authService.js'
|
||||||
|
import { type PermTreeNode, buildPermTree, type PermTreeResponse } from './permResolver.js'
|
||||||
const PERM_RESOURCE_CODES = [
|
|
||||||
'prediction', 'hns', 'rescue', 'reports', 'aerial',
|
|
||||||
'assets', 'scat', 'incidents', 'board', 'weather', 'admin',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const PROTECTED_ROLE_CODES = ['ADMIN']
|
const PROTECTED_ROLE_CODES = ['ADMIN']
|
||||||
|
|
||||||
|
/** AUTH_PERM_TREE에서 level 0 리소스 코드를 동적 조회 */
|
||||||
|
async function getTopLevelResourceCodes(): Promise<string[]> {
|
||||||
|
const result = await authPool.query(
|
||||||
|
`SELECT RSRC_CD FROM AUTH_PERM_TREE WHERE RSRC_LEVEL = 0 AND USE_YN = 'Y' ORDER BY SORT_ORD`
|
||||||
|
)
|
||||||
|
return result.rows.map((r: { rsrc_cd: string }) => r.rsrc_cd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AUTH_PERM_TREE 전체 노드 조회 */
|
||||||
|
export async function getPermTreeNodes(): Promise<PermTreeNode[]> {
|
||||||
|
const result = await authPool.query(
|
||||||
|
`SELECT RSRC_CD as code, PARENT_CD as "parentCode", RSRC_NM as name,
|
||||||
|
RSRC_DESC as description, ICON as icon, RSRC_LEVEL as level, SORT_ORD as "sortOrder"
|
||||||
|
FROM AUTH_PERM_TREE WHERE USE_YN = 'Y'
|
||||||
|
ORDER BY RSRC_LEVEL, SORT_ORD`
|
||||||
|
)
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 트리 구조로 변환하여 반환 (프론트엔드 UI용) */
|
||||||
|
export async function getPermTree(): Promise<PermTreeResponse[]> {
|
||||||
|
const nodes = await getPermTreeNodes()
|
||||||
|
return buildPermTree(nodes)
|
||||||
|
}
|
||||||
|
|
||||||
interface RoleWithPermissions {
|
interface RoleWithPermissions {
|
||||||
sn: number
|
sn: number
|
||||||
code: string
|
code: string
|
||||||
@ -17,6 +38,7 @@ interface RoleWithPermissions {
|
|||||||
permissions: Array<{
|
permissions: Array<{
|
||||||
sn: number
|
sn: number
|
||||||
resourceCode: string
|
resourceCode: string
|
||||||
|
operationCode: string
|
||||||
granted: boolean
|
granted: boolean
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
@ -42,8 +64,8 @@ export async function listRolesWithPermissions(): Promise<RoleWithPermissions[]>
|
|||||||
|
|
||||||
for (const row of rolesResult.rows) {
|
for (const row of rolesResult.rows) {
|
||||||
const permsResult = await authPool.query(
|
const permsResult = await authPool.query(
|
||||||
`SELECT PERM_SN as sn, RSRC_CD as resource_code, GRANT_YN as granted
|
`SELECT PERM_SN as sn, RSRC_CD as resource_code, OPER_CD as operation_code, GRANT_YN as granted
|
||||||
FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD`,
|
FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD, OPER_CD`,
|
||||||
[row.sn]
|
[row.sn]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -53,9 +75,12 @@ export async function listRolesWithPermissions(): Promise<RoleWithPermissions[]>
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
isDefault: row.is_default === 'Y',
|
isDefault: row.is_default === 'Y',
|
||||||
permissions: permsResult.rows.map((p: { sn: number; resource_code: string; granted: string }) => ({
|
permissions: permsResult.rows.map((p: {
|
||||||
|
sn: number; resource_code: string; operation_code: string; granted: string
|
||||||
|
}) => ({
|
||||||
sn: p.sn,
|
sn: p.sn,
|
||||||
resourceCode: p.resource_code,
|
resourceCode: p.resource_code,
|
||||||
|
operationCode: p.operation_code,
|
||||||
granted: p.granted === 'Y',
|
granted: p.granted === 'Y',
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
@ -94,17 +119,20 @@ export async function createRole(input: CreateRoleInput): Promise<RoleWithPermis
|
|||||||
)
|
)
|
||||||
const row = result.rows[0]
|
const row = result.rows[0]
|
||||||
|
|
||||||
for (const rsrc of PERM_RESOURCE_CODES) {
|
// 새 역할: level 0 리소스에 READ='N' 초기화
|
||||||
|
const topLevelCodes = await getTopLevelResourceCodes()
|
||||||
|
for (const rsrc of topLevelCodes) {
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES ($1, $2, $3)',
|
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES ($1, $2, $3, $4)',
|
||||||
[row.sn, rsrc, 'N']
|
[row.sn, rsrc, 'READ', 'N']
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query('COMMIT')
|
await client.query('COMMIT')
|
||||||
|
|
||||||
const permsResult = await authPool.query(
|
const permsResult = await authPool.query(
|
||||||
'SELECT PERM_SN as sn, RSRC_CD as resource_code, GRANT_YN as granted FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD',
|
`SELECT PERM_SN as sn, RSRC_CD as resource_code, OPER_CD as operation_code, GRANT_YN as granted
|
||||||
|
FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD, OPER_CD`,
|
||||||
[row.sn]
|
[row.sn]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -114,9 +142,12 @@ export async function createRole(input: CreateRoleInput): Promise<RoleWithPermis
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
isDefault: row.is_default === 'Y',
|
isDefault: row.is_default === 'Y',
|
||||||
permissions: permsResult.rows.map((p: { sn: number; resource_code: string; granted: string }) => ({
|
permissions: permsResult.rows.map((p: {
|
||||||
|
sn: number; resource_code: string; operation_code: string; granted: string
|
||||||
|
}) => ({
|
||||||
sn: p.sn,
|
sn: p.sn,
|
||||||
resourceCode: p.resource_code,
|
resourceCode: p.resource_code,
|
||||||
|
operationCode: p.operation_code,
|
||||||
granted: p.granted === 'Y',
|
granted: p.granted === 'Y',
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
@ -177,23 +208,23 @@ export async function deleteRole(roleSn: number): Promise<void> {
|
|||||||
|
|
||||||
export async function updatePermissions(
|
export async function updatePermissions(
|
||||||
roleSn: number,
|
roleSn: number,
|
||||||
permissions: Array<{ resourceCode: string; granted: boolean }>
|
permissions: Array<{ resourceCode: string; operationCode: string; granted: boolean }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const perm of permissions) {
|
for (const perm of permissions) {
|
||||||
const existing = await authPool.query(
|
const existing = await authPool.query(
|
||||||
'SELECT PERM_SN FROM AUTH_PERM WHERE ROLE_SN = $1 AND RSRC_CD = $2',
|
'SELECT PERM_SN FROM AUTH_PERM WHERE ROLE_SN = $1 AND RSRC_CD = $2 AND OPER_CD = $3',
|
||||||
[roleSn, perm.resourceCode]
|
[roleSn, perm.resourceCode, perm.operationCode]
|
||||||
)
|
)
|
||||||
|
|
||||||
if (existing.rows.length > 0) {
|
if (existing.rows.length > 0) {
|
||||||
await authPool.query(
|
await authPool.query(
|
||||||
'UPDATE AUTH_PERM SET GRANT_YN = $1 WHERE ROLE_SN = $2 AND RSRC_CD = $3',
|
'UPDATE AUTH_PERM SET GRANT_YN = $1 WHERE ROLE_SN = $2 AND RSRC_CD = $3 AND OPER_CD = $4',
|
||||||
[perm.granted ? 'Y' : 'N', roleSn, perm.resourceCode]
|
[perm.granted ? 'Y' : 'N', roleSn, perm.resourceCode, perm.operationCode]
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
await authPool.query(
|
await authPool.query(
|
||||||
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES ($1, $2, $3)',
|
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES ($1, $2, $3, $4)',
|
||||||
[roleSn, perm.resourceCode, perm.granted ? 'Y' : 'N']
|
[roleSn, perm.resourceCode, perm.operationCode, perm.granted ? 'Y' : 'N']
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,6 +48,7 @@ app.use(helmet({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
crossOriginEmbedderPolicy: false, // API 서버이므로 비활성
|
crossOriginEmbedderPolicy: false, // API 서버이므로 비활성
|
||||||
|
crossOriginResourcePolicy: { policy: 'cross-origin' }, // sendBeacon cross-origin 허용
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 2. 서버 정보 제거 (공격자에게 기술 스택 노출 방지)
|
// 2. 서버 정보 제거 (공격자에게 기술 스택 노출 방지)
|
||||||
|
|||||||
@ -134,18 +134,21 @@ CREATE TABLE AUTH_PERM (
|
|||||||
PERM_SN SERIAL NOT NULL,
|
PERM_SN SERIAL NOT NULL,
|
||||||
ROLE_SN INTEGER NOT NULL,
|
ROLE_SN INTEGER NOT NULL,
|
||||||
RSRC_CD VARCHAR(50) NOT NULL,
|
RSRC_CD VARCHAR(50) NOT NULL,
|
||||||
|
OPER_CD VARCHAR(20) NOT NULL,
|
||||||
GRANT_YN CHAR(1) NOT NULL DEFAULT 'Y',
|
GRANT_YN CHAR(1) NOT NULL DEFAULT 'Y',
|
||||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
CONSTRAINT PK_AUTH_PERM PRIMARY KEY (PERM_SN),
|
CONSTRAINT PK_AUTH_PERM PRIMARY KEY (PERM_SN),
|
||||||
CONSTRAINT FK_AP_ROLE FOREIGN KEY (ROLE_SN) REFERENCES AUTH_ROLE(ROLE_SN) ON DELETE CASCADE,
|
CONSTRAINT FK_AP_ROLE FOREIGN KEY (ROLE_SN) REFERENCES AUTH_ROLE(ROLE_SN) ON DELETE CASCADE,
|
||||||
CONSTRAINT UK_AUTH_PERM UNIQUE (ROLE_SN, RSRC_CD),
|
CONSTRAINT UK_AUTH_PERM UNIQUE (ROLE_SN, RSRC_CD, OPER_CD),
|
||||||
CONSTRAINT CK_AUTH_PERM_GRANT CHECK (GRANT_YN IN ('Y','N'))
|
CONSTRAINT CK_AUTH_PERM_GRANT CHECK (GRANT_YN IN ('Y','N')),
|
||||||
|
CONSTRAINT CK_AUTH_PERM_OPER CHECK (OPER_CD IN ('READ','CREATE','UPDATE','DELETE','MANAGE','EXPORT'))
|
||||||
);
|
);
|
||||||
|
|
||||||
COMMENT ON TABLE AUTH_PERM IS '역할별권한';
|
COMMENT ON TABLE AUTH_PERM IS '역할별권한';
|
||||||
COMMENT ON COLUMN AUTH_PERM.PERM_SN IS '권한순번';
|
COMMENT ON COLUMN AUTH_PERM.PERM_SN IS '권한순번';
|
||||||
COMMENT ON COLUMN AUTH_PERM.ROLE_SN IS '역할순번';
|
COMMENT ON COLUMN AUTH_PERM.ROLE_SN IS '역할순번';
|
||||||
COMMENT ON COLUMN AUTH_PERM.RSRC_CD IS '리소스코드 (탭 ID: prediction, hns, rescue 등)';
|
COMMENT ON COLUMN AUTH_PERM.RSRC_CD IS '리소스코드 (탭 ID: prediction, hns, rescue 등)';
|
||||||
|
COMMENT ON COLUMN AUTH_PERM.OPER_CD IS '오퍼레이션코드 (READ, CREATE, UPDATE, DELETE, MANAGE, EXPORT)';
|
||||||
COMMENT ON COLUMN AUTH_PERM.GRANT_YN IS '부여여부 (Y:허용, N:거부)';
|
COMMENT ON COLUMN AUTH_PERM.GRANT_YN IS '부여여부 (Y:허용, N:거부)';
|
||||||
COMMENT ON COLUMN AUTH_PERM.REG_DTM IS '등록일시';
|
COMMENT ON COLUMN AUTH_PERM.REG_DTM IS '등록일시';
|
||||||
|
|
||||||
@ -239,6 +242,7 @@ CREATE UNIQUE INDEX UK_AUTH_USER_OAUTH ON AUTH_USER(OAUTH_PROVIDER, OAUTH_SUB) W
|
|||||||
CREATE UNIQUE INDEX UK_AUTH_USER_EMAIL ON AUTH_USER(EMAIL) WHERE EMAIL IS NOT NULL;
|
CREATE UNIQUE INDEX UK_AUTH_USER_EMAIL ON AUTH_USER(EMAIL) WHERE EMAIL IS NOT NULL;
|
||||||
CREATE INDEX IDX_AUTH_PERM_ROLE ON AUTH_PERM (ROLE_SN);
|
CREATE INDEX IDX_AUTH_PERM_ROLE ON AUTH_PERM (ROLE_SN);
|
||||||
CREATE INDEX IDX_AUTH_PERM_RSRC ON AUTH_PERM (RSRC_CD);
|
CREATE INDEX IDX_AUTH_PERM_RSRC ON AUTH_PERM (RSRC_CD);
|
||||||
|
CREATE INDEX IDX_AUTH_PERM_OPER ON AUTH_PERM (OPER_CD);
|
||||||
CREATE INDEX IDX_AUTH_LOGIN_USER ON AUTH_LOGIN_HIST (USER_ID);
|
CREATE INDEX IDX_AUTH_LOGIN_USER ON AUTH_LOGIN_HIST (USER_ID);
|
||||||
CREATE INDEX IDX_AUTH_LOGIN_DTM ON AUTH_LOGIN_HIST (LOGIN_DTM);
|
CREATE INDEX IDX_AUTH_LOGIN_DTM ON AUTH_LOGIN_HIST (LOGIN_DTM);
|
||||||
CREATE INDEX IDX_AUDIT_LOG_USER ON AUTH_AUDIT_LOG (USER_ID);
|
CREATE INDEX IDX_AUDIT_LOG_USER ON AUTH_AUDIT_LOG (USER_ID);
|
||||||
@ -257,36 +261,65 @@ INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN) VALUES
|
|||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 11. 초기 데이터: 역할별 권한 (탭 접근 매트릭스)
|
-- 11. 초기 데이터: 역할별 권한 (리소스 × 오퍼레이션 매트릭스)
|
||||||
|
-- OPER_CD: READ(조회), CREATE(생성), UPDATE(수정), DELETE(삭제)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
-- ADMIN (ROLE_SN=1): 모든 탭 접근
|
-- ADMIN (ROLE_SN=1): 모든 탭 × 모든 오퍼레이션 허용
|
||||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||||
(1, 'prediction', 'Y'), (1, 'hns', 'Y'), (1, 'rescue', 'Y'),
|
(1, 'prediction', 'READ', 'Y'), (1, 'prediction', 'CREATE', 'Y'), (1, 'prediction', 'UPDATE', 'Y'), (1, 'prediction', 'DELETE', 'Y'),
|
||||||
(1, 'reports', 'Y'), (1, 'aerial', 'Y'), (1, 'assets', 'Y'),
|
(1, 'hns', 'READ', 'Y'), (1, 'hns', 'CREATE', 'Y'), (1, 'hns', 'UPDATE', 'Y'), (1, 'hns', 'DELETE', 'Y'),
|
||||||
(1, 'scat', 'Y'), (1, 'incidents', 'Y'), (1, 'board', 'Y'),
|
(1, 'rescue', 'READ', 'Y'), (1, 'rescue', 'CREATE', 'Y'), (1, 'rescue', 'UPDATE', 'Y'), (1, 'rescue', 'DELETE', 'Y'),
|
||||||
(1, 'weather', 'Y'), (1, 'admin', 'Y');
|
(1, 'reports', 'READ', 'Y'), (1, 'reports', 'CREATE', 'Y'), (1, 'reports', 'UPDATE', 'Y'), (1, 'reports', 'DELETE', 'Y'),
|
||||||
|
(1, 'aerial', 'READ', 'Y'), (1, 'aerial', 'CREATE', 'Y'), (1, 'aerial', 'UPDATE', 'Y'), (1, 'aerial', 'DELETE', 'Y'),
|
||||||
|
(1, 'assets', 'READ', 'Y'), (1, 'assets', 'CREATE', 'Y'), (1, 'assets', 'UPDATE', 'Y'), (1, 'assets', 'DELETE', 'Y'),
|
||||||
|
(1, 'scat', 'READ', 'Y'), (1, 'scat', 'CREATE', 'Y'), (1, 'scat', 'UPDATE', 'Y'), (1, 'scat', 'DELETE', 'Y'),
|
||||||
|
(1, 'incidents', 'READ', 'Y'), (1, 'incidents', 'CREATE', 'Y'), (1, 'incidents', 'UPDATE', 'Y'), (1, 'incidents', 'DELETE', 'Y'),
|
||||||
|
(1, 'board', 'READ', 'Y'), (1, 'board', 'CREATE', 'Y'), (1, 'board', 'UPDATE', 'Y'), (1, 'board', 'DELETE', 'Y'),
|
||||||
|
(1, 'weather', 'READ', 'Y'), (1, 'weather', 'CREATE', 'Y'), (1, 'weather', 'UPDATE', 'Y'), (1, 'weather', 'DELETE', 'Y'),
|
||||||
|
(1, 'admin', 'READ', 'Y'), (1, 'admin', 'CREATE', 'Y'), (1, 'admin', 'UPDATE', 'Y'), (1, 'admin', 'DELETE', 'Y');
|
||||||
|
|
||||||
-- MANAGER (ROLE_SN=2): admin 탭 제외
|
-- MANAGER (ROLE_SN=2): admin 탭 제외, RCUD 허용
|
||||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||||
(2, 'prediction', 'Y'), (2, 'hns', 'Y'), (2, 'rescue', 'Y'),
|
(2, 'prediction', 'READ', 'Y'), (2, 'prediction', 'CREATE', 'Y'), (2, 'prediction', 'UPDATE', 'Y'), (2, 'prediction', 'DELETE', 'Y'),
|
||||||
(2, 'reports', 'Y'), (2, 'aerial', 'Y'), (2, 'assets', 'Y'),
|
(2, 'hns', 'READ', 'Y'), (2, 'hns', 'CREATE', 'Y'), (2, 'hns', 'UPDATE', 'Y'), (2, 'hns', 'DELETE', 'Y'),
|
||||||
(2, 'scat', 'Y'), (2, 'incidents', 'Y'), (2, 'board', 'Y'),
|
(2, 'rescue', 'READ', 'Y'), (2, 'rescue', 'CREATE', 'Y'), (2, 'rescue', 'UPDATE', 'Y'), (2, 'rescue', 'DELETE', 'Y'),
|
||||||
(2, 'weather', 'Y'), (2, 'admin', 'N');
|
(2, 'reports', 'READ', 'Y'), (2, 'reports', 'CREATE', 'Y'), (2, 'reports', 'UPDATE', 'Y'), (2, 'reports', 'DELETE', 'Y'),
|
||||||
|
(2, 'aerial', 'READ', 'Y'), (2, 'aerial', 'CREATE', 'Y'), (2, 'aerial', 'UPDATE', 'Y'), (2, 'aerial', 'DELETE', 'Y'),
|
||||||
|
(2, 'assets', 'READ', 'Y'), (2, 'assets', 'CREATE', 'Y'), (2, 'assets', 'UPDATE', 'Y'), (2, 'assets', 'DELETE', 'Y'),
|
||||||
|
(2, 'scat', 'READ', 'Y'), (2, 'scat', 'CREATE', 'Y'), (2, 'scat', 'UPDATE', 'Y'), (2, 'scat', 'DELETE', 'Y'),
|
||||||
|
(2, 'incidents', 'READ', 'Y'), (2, 'incidents', 'CREATE', 'Y'), (2, 'incidents', 'UPDATE', 'Y'), (2, 'incidents', 'DELETE', 'Y'),
|
||||||
|
(2, 'board', 'READ', 'Y'), (2, 'board', 'CREATE', 'Y'), (2, 'board', 'UPDATE', 'Y'), (2, 'board', 'DELETE', 'Y'),
|
||||||
|
(2, 'weather', 'READ', 'Y'), (2, 'weather', 'CREATE', 'Y'), (2, 'weather', 'UPDATE', 'Y'), (2, 'weather', 'DELETE', 'Y'),
|
||||||
|
(2, 'admin', 'READ', 'N');
|
||||||
|
|
||||||
-- USER (ROLE_SN=3): assets, admin 탭 제외
|
-- USER (ROLE_SN=3): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음
|
||||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||||
(3, 'prediction', 'Y'), (3, 'hns', 'Y'), (3, 'rescue', 'Y'),
|
(3, 'prediction', 'READ', 'Y'), (3, 'prediction', 'CREATE', 'Y'), (3, 'prediction', 'UPDATE', 'Y'),
|
||||||
(3, 'reports', 'Y'), (3, 'aerial', 'Y'), (3, 'assets', 'N'),
|
(3, 'hns', 'READ', 'Y'), (3, 'hns', 'CREATE', 'Y'), (3, 'hns', 'UPDATE', 'Y'),
|
||||||
(3, 'scat', 'Y'), (3, 'incidents', 'Y'), (3, 'board', 'Y'),
|
(3, 'rescue', 'READ', 'Y'), (3, 'rescue', 'CREATE', 'Y'), (3, 'rescue', 'UPDATE', 'Y'),
|
||||||
(3, 'weather', 'Y'), (3, 'admin', 'N');
|
(3, 'reports', 'READ', 'Y'), (3, 'reports', 'CREATE', 'Y'), (3, 'reports', 'UPDATE', 'Y'),
|
||||||
|
(3, 'aerial', 'READ', 'Y'), (3, 'aerial', 'CREATE', 'Y'), (3, 'aerial', 'UPDATE', 'Y'),
|
||||||
|
(3, 'assets', 'READ', 'N'),
|
||||||
|
(3, 'scat', 'READ', 'Y'), (3, 'scat', 'CREATE', 'Y'), (3, 'scat', 'UPDATE', 'Y'),
|
||||||
|
(3, 'incidents', 'READ', 'Y'), (3, 'incidents', 'CREATE', 'Y'), (3, 'incidents', 'UPDATE', 'Y'),
|
||||||
|
(3, 'board', 'READ', 'Y'), (3, 'board', 'CREATE', 'Y'), (3, 'board', 'UPDATE', 'Y'),
|
||||||
|
(3, 'weather', 'READ', 'Y'),
|
||||||
|
(3, 'admin', 'READ', 'N');
|
||||||
|
|
||||||
-- VIEWER (ROLE_SN=4): reports, assets, scat, admin 제외
|
-- VIEWER (ROLE_SN=4): 제한적 탭의 READ만 허용
|
||||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||||
(4, 'prediction', 'Y'), (4, 'hns', 'Y'), (4, 'rescue', 'Y'),
|
(4, 'prediction', 'READ', 'Y'),
|
||||||
(4, 'reports', 'N'), (4, 'aerial', 'Y'), (4, 'assets', 'N'),
|
(4, 'hns', 'READ', 'Y'),
|
||||||
(4, 'scat', 'N'), (4, 'incidents', 'Y'), (4, 'board', 'Y'),
|
(4, 'rescue', 'READ', 'Y'),
|
||||||
(4, 'weather', 'Y'), (4, 'admin', 'N');
|
(4, 'reports', 'READ', 'N'),
|
||||||
|
(4, 'aerial', 'READ', 'Y'),
|
||||||
|
(4, 'assets', 'READ', 'N'),
|
||||||
|
(4, 'scat', 'READ', 'N'),
|
||||||
|
(4, 'incidents', 'READ', 'Y'),
|
||||||
|
(4, 'board', 'READ', 'Y'),
|
||||||
|
(4, 'weather', 'READ', 'Y'),
|
||||||
|
(4, 'admin', 'READ', 'N');
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
108
database/migration/003_perm_tree.sql
Normal file
108
database/migration/003_perm_tree.sql
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- AUTH_PERM_TREE: 트리 구조 기반 리소스(메뉴) 권한 정의
|
||||||
|
-- 부모-자식 관계로 N-depth 서브탭 권한 제어 지원
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS AUTH_PERM_TREE (
|
||||||
|
RSRC_CD VARCHAR(50) NOT NULL, -- 콜론 구분 경로: 'prediction', 'aerial:media'
|
||||||
|
PARENT_CD VARCHAR(50), -- NULL이면 최상위 탭
|
||||||
|
RSRC_NM VARCHAR(100) NOT NULL, -- 표시명
|
||||||
|
RSRC_DESC VARCHAR(200), -- 설명 (NULL 허용)
|
||||||
|
ICON VARCHAR(20), -- 아이콘 (NULL 허용, 선택 옵션)
|
||||||
|
RSRC_LEVEL SMALLINT NOT NULL DEFAULT 0, -- depth (0=탭, 1=서브탭, 2+)
|
||||||
|
SORT_ORD SMALLINT NOT NULL DEFAULT 0, -- 형제 노드 간 정렬
|
||||||
|
USE_YN CHAR(1) NOT NULL DEFAULT 'Y',
|
||||||
|
CONSTRAINT PK_PERM_TREE PRIMARY KEY (RSRC_CD),
|
||||||
|
CONSTRAINT FK_PERM_TREE_PARENT FOREIGN KEY (PARENT_CD)
|
||||||
|
REFERENCES AUTH_PERM_TREE(RSRC_CD)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_PERM_TREE_PARENT ON AUTH_PERM_TREE(PARENT_CD);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 초기 데이터
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Level 0: 메인 탭 (11개)
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_DESC, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('prediction', NULL, '유출유 확산예측', '확산 예측 실행 및 결과 조회', 0, 1),
|
||||||
|
('hns', NULL, 'HNS·대기확산', '대기확산 분석 실행 및 조회', 0, 2),
|
||||||
|
('rescue', NULL, '긴급구난', '구난 예측 실행 및 조회', 0, 3),
|
||||||
|
('reports', NULL, '보고자료', '사고 보고서 작성 및 조회', 0, 4),
|
||||||
|
('aerial', NULL, '항공탐색', '항공 탐색 데이터 조회', 0, 5),
|
||||||
|
('assets', NULL, '방제자산 관리', '방제 장비 및 자산 관리', 0, 6),
|
||||||
|
('scat', NULL, '해안평가', 'SCAT 조사 실행 및 조회', 0, 7),
|
||||||
|
('board', NULL, '게시판', '자료실 및 공지사항 조회', 0, 8),
|
||||||
|
('weather', NULL, '기상정보', '기상 및 해상 정보 조회', 0, 9),
|
||||||
|
('incidents', NULL, '통합조회', '사고 상세 정보 조회', 0, 10),
|
||||||
|
('admin', NULL, '관리', '사용자 및 권한 관리', 0, 11)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 1: prediction 하위
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('prediction:analysis', 'prediction', '확산분석', 1, 1),
|
||||||
|
('prediction:list', 'prediction', '분석 목록', 1, 2),
|
||||||
|
('prediction:theory', 'prediction', '확산모델 이론', 1, 3),
|
||||||
|
('prediction:boom-theory', 'prediction', '오일펜스 배치 이론', 1, 4)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 1: hns 하위
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('hns:analysis', 'hns', '대기확산 분석', 1, 1),
|
||||||
|
('hns:list', 'hns', '분석 목록', 1, 2),
|
||||||
|
('hns:scenario', 'hns', '시나리오 관리', 1, 3),
|
||||||
|
('hns:manual', 'hns', 'HNS 대응매뉴얼', 1, 4),
|
||||||
|
('hns:theory', 'hns', '확산모델 이론', 1, 5),
|
||||||
|
('hns:substance', 'hns', 'HNS 물질정보', 1, 6)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 1: rescue 하위
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('rescue:rescue', 'rescue', '긴급구난예측', 1, 1),
|
||||||
|
('rescue:list', 'rescue', '긴급구난 목록', 1, 2),
|
||||||
|
('rescue:scenario', 'rescue', '시나리오 관리', 1, 3),
|
||||||
|
('rescue:theory', 'rescue', '긴급구난모델 이론', 1, 4)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 1: reports 하위
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('reports:report-list', 'reports', '보고서 목록', 1, 1),
|
||||||
|
('reports:template', 'reports', '표준보고서 템플릿', 1, 2),
|
||||||
|
('reports:generate', 'reports', '보고서 생성', 1, 3)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 1: aerial 하위
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('aerial:media', 'aerial', '영상사진관리', 1, 1),
|
||||||
|
('aerial:analysis', 'aerial', '유출유면적분석', 1, 2),
|
||||||
|
('aerial:realtime', 'aerial', '실시간드론', 1, 3),
|
||||||
|
('aerial:sensor', 'aerial', '오염/선박3D분석', 1, 4),
|
||||||
|
('aerial:satellite', 'aerial', '위성요청', 1, 5),
|
||||||
|
('aerial:cctv', 'aerial', 'CCTV 조회', 1, 6),
|
||||||
|
('aerial:theory', 'aerial', '항공탐색 이론', 1, 7)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 1: assets 하위
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('assets:management', 'assets', '자산 관리', 1, 1),
|
||||||
|
('assets:upload', 'assets', '자산 현행화', 1, 2),
|
||||||
|
('assets:theory', 'assets', '방제자원 이론', 1, 3),
|
||||||
|
('assets:insurance', 'assets', '선박 보험정보', 1, 4)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 1: board 하위
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('board:all', 'board', '전체', 1, 1),
|
||||||
|
('board:notice', 'board', '공지사항', 1, 2),
|
||||||
|
('board:data', 'board', '자료실', 1, 3),
|
||||||
|
('board:qna', 'board', 'Q&A', 1, 4),
|
||||||
|
('board:manual', 'board', '해경매뉴얼', 1, 5)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 1: admin 하위
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('admin:users', 'admin', '사용자 관리', 1, 1),
|
||||||
|
('admin:permissions', 'admin', '권한 관리', 1, 2),
|
||||||
|
('admin:menus', 'admin', '메뉴 관리', 1, 3),
|
||||||
|
('admin:settings', 'admin', '시스템 설정', 1, 4)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
55
database/migration/004_oper_cd.sql
Normal file
55
database/migration/004_oper_cd.sql
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 마이그레이션 004: AUTH_PERM에 OPER_CD 컬럼 추가
|
||||||
|
-- 리소스 단일 권한 → 리소스 × 오퍼레이션(RCUD) 2차원 권한 모델
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Step 1: OPER_CD 컬럼 추가 (기존 레코드는 'READ'로 설정)
|
||||||
|
ALTER TABLE AUTH_PERM ADD COLUMN IF NOT EXISTS OPER_CD VARCHAR(20) NOT NULL DEFAULT 'READ';
|
||||||
|
COMMENT ON COLUMN AUTH_PERM.OPER_CD IS '오퍼레이션코드 (READ, CREATE, UPDATE, DELETE, MANAGE, EXPORT)';
|
||||||
|
|
||||||
|
-- Step 2: UNIQUE 제약 변경 (ROLE_SN, RSRC_CD) → (ROLE_SN, RSRC_CD, OPER_CD)
|
||||||
|
-- INSERT 전에 변경해야 CUD 레코드 삽입 시 충돌 없음
|
||||||
|
ALTER TABLE AUTH_PERM DROP CONSTRAINT IF EXISTS UK_AUTH_PERM;
|
||||||
|
ALTER TABLE AUTH_PERM ADD CONSTRAINT UK_AUTH_PERM UNIQUE (ROLE_SN, RSRC_CD, OPER_CD);
|
||||||
|
|
||||||
|
-- Step 3: 기존 GRANT_YN='Y' 레코드를 CREATE/UPDATE/DELETE로 확장
|
||||||
|
-- (기존에 허용된 리소스는 RCUD 모두 허용하여 동작 보존)
|
||||||
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
|
||||||
|
SELECT ROLE_SN, RSRC_CD, 'CREATE', GRANT_YN
|
||||||
|
FROM AUTH_PERM WHERE OPER_CD = 'READ' AND GRANT_YN = 'Y'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
|
||||||
|
SELECT ROLE_SN, RSRC_CD, 'UPDATE', GRANT_YN
|
||||||
|
FROM AUTH_PERM WHERE OPER_CD = 'READ' AND GRANT_YN = 'Y'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
|
||||||
|
SELECT ROLE_SN, RSRC_CD, 'DELETE', GRANT_YN
|
||||||
|
FROM AUTH_PERM WHERE OPER_CD = 'READ' AND GRANT_YN = 'Y'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Step 3-1: VIEWER(조회 전용) 역할의 CUD 레코드 제거
|
||||||
|
-- VIEWER는 READ만 허용, CUD 확장은 의미 없음
|
||||||
|
DELETE FROM AUTH_PERM
|
||||||
|
WHERE ROLE_SN = (SELECT ROLE_SN FROM AUTH_ROLE WHERE ROLE_CD = 'VIEWER')
|
||||||
|
AND OPER_CD != 'READ';
|
||||||
|
|
||||||
|
-- Step 4: 기본값 제거 (신규 레코드는 반드시 OPER_CD 명시)
|
||||||
|
ALTER TABLE AUTH_PERM ALTER COLUMN OPER_CD DROP DEFAULT;
|
||||||
|
|
||||||
|
-- Step 5: CHECK 제약 추가 (확장 가능: MANAGE, EXPORT 포함)
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE AUTH_PERM ADD CONSTRAINT CK_AUTH_PERM_OPER
|
||||||
|
CHECK (OPER_CD IN ('READ','CREATE','UPDATE','DELETE','MANAGE','EXPORT'));
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Step 6: 인덱스
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_AUTH_PERM_OPER ON AUTH_PERM (OPER_CD);
|
||||||
|
|
||||||
|
-- 검증
|
||||||
|
SELECT ROLE_SN, OPER_CD, COUNT(*), STRING_AGG(GRANT_YN, '') as grants
|
||||||
|
FROM AUTH_PERM
|
||||||
|
GROUP BY ROLE_SN, OPER_CD
|
||||||
|
ORDER BY ROLE_SN, OPER_CD;
|
||||||
@ -10,21 +10,79 @@
|
|||||||
### 개요
|
### 개요
|
||||||
JWT 기반 세션 인증. HttpOnly 쿠키(`WING_SESSION`)로 토큰을 관리하며, 프론트엔드에서는 Zustand `authStore`로 상태를 관리합니다.
|
JWT 기반 세션 인증. HttpOnly 쿠키(`WING_SESSION`)로 토큰을 관리하며, 프론트엔드에서는 Zustand `authStore`로 상태를 관리합니다.
|
||||||
|
|
||||||
|
### 권한 모델: 리소스 × 오퍼레이션 (RBAC)
|
||||||
|
|
||||||
|
**2차원 권한 모델**: 리소스 트리(상속) × 오퍼레이션(RCUD, 플랫)
|
||||||
|
|
||||||
|
```
|
||||||
|
AUTH_PERM 테이블: (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
|
||||||
|
|
||||||
|
리소스 트리 (AUTH_PERM_TREE) 오퍼레이션 (플랫)
|
||||||
|
├── prediction READ = 조회/열람
|
||||||
|
│ ├── prediction:analysis CREATE = 생성
|
||||||
|
│ ├── prediction:list UPDATE = 수정
|
||||||
|
│ └── prediction:theory DELETE = 삭제
|
||||||
|
├── board
|
||||||
|
│ ├── board:notice
|
||||||
|
│ └── board:data
|
||||||
|
└── admin
|
||||||
|
├── admin:users
|
||||||
|
└── admin:permissions
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 오퍼레이션 코드
|
||||||
|
|
||||||
|
| OPER_CD | 설명 | 비고 |
|
||||||
|
|---------|------|------|
|
||||||
|
| `READ` | 조회/열람 | 목록, 상세 조회 |
|
||||||
|
| `CREATE` | 생성 | 새 데이터 등록 |
|
||||||
|
| `UPDATE` | 수정 | 기존 데이터 변경 |
|
||||||
|
| `DELETE` | 삭제 | 데이터 삭제 |
|
||||||
|
| `MANAGE` | 관리 | 관리자 설정 (확장용) |
|
||||||
|
| `EXPORT` | 내보내기 | 다운로드/출력 (확장용) |
|
||||||
|
|
||||||
|
#### 상속 규칙
|
||||||
|
|
||||||
|
1. 부모 리소스의 **READ**가 N → 자식의 **모든 오퍼레이션** 강제 N (접근 자체 차단)
|
||||||
|
2. 해당 `(RSRC_CD, OPER_CD)` 명시적 레코드 있으면 → 그 값 사용
|
||||||
|
3. 명시적 레코드 없으면 → 부모의 **같은 OPER_CD** 상속
|
||||||
|
4. 최상위까지 없으면 → 기본 N (거부)
|
||||||
|
|
||||||
|
```
|
||||||
|
예시: board (READ:Y, CREATE:Y, UPDATE:Y, DELETE:N)
|
||||||
|
└── board:notice
|
||||||
|
├── READ: 상속 Y (부모 READ Y)
|
||||||
|
├── CREATE: 상속 Y (부모 CREATE Y)
|
||||||
|
├── UPDATE: 명시적 N (override 가능)
|
||||||
|
└── DELETE: 상속 N (부모 DELETE N)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 키 구분자
|
||||||
|
- 리소스 내부 경로: `:` (board:notice)
|
||||||
|
- 리소스-오퍼레이션 결합 (내부용): `::` (board:notice::READ)
|
||||||
|
|
||||||
### 백엔드
|
### 백엔드
|
||||||
|
|
||||||
#### 미들웨어 적용
|
#### 미들웨어
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// backend/src/auth/authMiddleware.ts
|
import { requireAuth, requireRole, requirePermission } from '../auth/authMiddleware.js'
|
||||||
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
|
||||||
|
|
||||||
// 인증만 필요한 라우트
|
// 인증만 필요한 라우트
|
||||||
router.use(requireAuth)
|
router.use(requireAuth)
|
||||||
|
|
||||||
// 특정 역할 필요
|
// 역할 기반 (관리 API용)
|
||||||
router.use(requireRole('ADMIN'))
|
router.use(requireRole('ADMIN'))
|
||||||
router.use(requireRole('ADMIN', 'MANAGER'))
|
|
||||||
|
// 리소스×오퍼레이션 기반 (일반 비즈니스 API용)
|
||||||
|
router.post('/notice/list', requirePermission('board:notice', 'READ'), handler)
|
||||||
|
router.post('/notice/create', requirePermission('board:notice', 'CREATE'), handler)
|
||||||
|
router.post('/notice/update', requirePermission('board:notice', 'UPDATE'), handler)
|
||||||
|
router.post('/notice/delete', requirePermission('board:notice', 'DELETE'), handler)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`requirePermission`은 요청당 1회만 DB 조회하고 `req.resolvedPermissions`에 캐싱합니다.
|
||||||
|
|
||||||
#### JWT 페이로드 (req.user)
|
#### JWT 페이로드 (req.user)
|
||||||
`requireAuth` 통과 후 `req.user`에 담기는 정보:
|
`requireAuth` 통과 후 `req.user`에 담기는 정보:
|
||||||
```typescript
|
```typescript
|
||||||
@ -36,25 +94,21 @@ interface JwtPayload {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 라우터 패턴
|
#### 라우터 패턴 (CRUD 구조)
|
||||||
```typescript
|
```typescript
|
||||||
// backend/src/[모듈]/[모듈]Router.ts
|
// backend/src/[모듈]/[모듈]Router.ts
|
||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
router.use(requireAuth)
|
router.use(requireAuth)
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
// 리소스별 CRUD 엔드포인트
|
||||||
try {
|
router.post('/list', requirePermission('module:sub', 'READ'), listHandler)
|
||||||
const userId = req.user!.sub
|
router.post('/detail', requirePermission('module:sub', 'READ'), detailHandler)
|
||||||
// 비즈니스 로직...
|
router.post('/create', requirePermission('module:sub', 'CREATE'), createHandler)
|
||||||
res.json(result)
|
router.post('/update', requirePermission('module:sub', 'UPDATE'), updateHandler)
|
||||||
} catch (err) {
|
router.post('/delete', requirePermission('module:sub', 'DELETE'), deleteHandler)
|
||||||
console.error('[모듈] 오류:', err)
|
|
||||||
res.status(500).json({ error: '처리 중 오류가 발생했습니다.' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
```
|
```
|
||||||
@ -63,32 +117,36 @@ export default router
|
|||||||
|
|
||||||
#### authStore (Zustand)
|
#### authStore (Zustand)
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/store/authStore.ts
|
import { useAuthStore } from '@common/store/authStore'
|
||||||
import { useAuthStore } from '../store/authStore'
|
|
||||||
|
|
||||||
// 컴포넌트 내에서 사용
|
|
||||||
const { user, isAuthenticated, hasPermission, logout } = useAuthStore()
|
const { user, isAuthenticated, hasPermission, logout } = useAuthStore()
|
||||||
|
|
||||||
// 사용자 정보
|
// 사용자 정보
|
||||||
user?.id // UUID
|
user?.id // UUID
|
||||||
user?.name // 이름
|
user?.name // 이름
|
||||||
user?.roles // ['ADMIN', 'USER']
|
user?.roles // ['ADMIN', 'USER']
|
||||||
|
user?.permissions // { 'prediction': ['READ','CREATE','UPDATE','DELETE'], ... }
|
||||||
|
|
||||||
// 권한 확인 (탭 ID 기준)
|
// 권한 확인 (리소스 × 오퍼레이션)
|
||||||
hasPermission('prediction') // true/false
|
hasPermission('prediction') // READ 확인 (기본값)
|
||||||
hasPermission('admin') // true/false
|
hasPermission('prediction', 'READ') // 명시적 READ 확인
|
||||||
|
hasPermission('board:notice', 'CREATE') // 공지사항 생성 권한
|
||||||
|
hasPermission('board:notice', 'DELETE') // 공지사항 삭제 권한
|
||||||
|
|
||||||
|
// 하위 호환: operation 생략 시 'READ' 기본값
|
||||||
|
hasPermission('admin') // === hasPermission('admin', 'READ')
|
||||||
```
|
```
|
||||||
|
|
||||||
#### API 클라이언트
|
#### API 클라이언트
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/services/api.ts
|
import { api } from '@common/services/api'
|
||||||
import { api } from './api'
|
|
||||||
|
|
||||||
// withCredentials: true 설정으로 JWT 쿠키 자동 포함
|
// withCredentials: true 설정으로 JWT 쿠키 자동 포함
|
||||||
const response = await api.get('/your-endpoint')
|
const response = await api.post('/your-endpoint/list', params)
|
||||||
const response = await api.post('/your-endpoint', data)
|
const response = await api.post('/your-endpoint/create', data)
|
||||||
|
|
||||||
// 401 응답 시 자동 로그아웃 처리 (인터셉터)
|
// 401 응답 시 자동 로그아웃 처리 (인터셉터)
|
||||||
|
// 403 응답 시 권한 부족 (requirePermission 미들웨어)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -103,13 +161,15 @@ const response = await api.post('/your-endpoint', data)
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/App.tsx (자동 적용, 수정 불필요)
|
// frontend/src/App.tsx (자동 적용, 수정 불필요)
|
||||||
|
import { API_BASE_URL } from '@common/services/api'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return
|
if (!isAuthenticated) return
|
||||||
const blob = new Blob(
|
const blob = new Blob(
|
||||||
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
|
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
|
||||||
{ type: 'text/plain' }
|
{ type: 'text/plain' }
|
||||||
)
|
)
|
||||||
navigator.sendBeacon('/api/audit/log', blob)
|
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
|
||||||
}, [activeMainTab, isAuthenticated])
|
}, [activeMainTab, isAuthenticated])
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -117,12 +177,13 @@ useEffect(() => {
|
|||||||
특정 작업에 대해 명시적으로 감사 로그를 기록하려면:
|
특정 작업에 대해 명시적으로 감사 로그를 기록하려면:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 프론트엔드에서 sendBeacon 사용
|
import { API_BASE_URL } from '@common/services/api'
|
||||||
|
|
||||||
const blob = new Blob(
|
const blob = new Blob(
|
||||||
[JSON.stringify({ action: 'ADMIN_ACTION', detail: '사용자 승인' })],
|
[JSON.stringify({ action: 'ADMIN_ACTION', detail: '사용자 승인' })],
|
||||||
{ type: 'text/plain' }
|
{ type: 'text/plain' }
|
||||||
)
|
)
|
||||||
navigator.sendBeacon('/api/audit/log', blob)
|
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 감사 로그 테이블 구조 (AUTH_AUDIT_LOG)
|
### 감사 로그 테이블 구조 (AUTH_AUDIT_LOG)
|
||||||
@ -275,19 +336,70 @@ const mutation = useMutation({
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 백엔드 모듈 추가 절차
|
## 6. 백엔드 API CRUD 규칙
|
||||||
|
|
||||||
|
### HTTP Method 정책 (보안 가이드 준수)
|
||||||
|
- 보안 취약점 점검 가이드에 따라 **POST 메서드를 기본**으로 사용한다.
|
||||||
|
- GET은 단순 조회 중 민감하지 않은 경우에만 허용 (필요 시 POST로 전환).
|
||||||
|
- PUT, DELETE, PATCH 등 기타 메서드는 사용하지 않는다.
|
||||||
|
|
||||||
|
### 오퍼레이션 기반 권한 미들웨어
|
||||||
|
OPER_CD는 HTTP Method가 아닌 **비즈니스 의미**로 결정한다.
|
||||||
|
`requirePermission` 미들웨어에 명시적으로 오퍼레이션을 지정한다.
|
||||||
|
|
||||||
|
| URL 패턴 | OPER_CD | 미들웨어 |
|
||||||
|
|----------|---------|----------|
|
||||||
|
| `/resource/list` | READ | `requirePermission(resource, 'READ')` |
|
||||||
|
| `/resource/detail` | READ | `requirePermission(resource, 'READ')` |
|
||||||
|
| `/resource/create` | CREATE | `requirePermission(resource, 'CREATE')` |
|
||||||
|
| `/resource/update` | UPDATE | `requirePermission(resource, 'UPDATE')` |
|
||||||
|
| `/resource/delete` | DELETE | `requirePermission(resource, 'DELETE')` |
|
||||||
|
|
||||||
|
### 라우터 작성 예시
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// backend/src/board/noticeRouter.ts
|
||||||
|
import { Router } from 'express'
|
||||||
|
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
router.use(requireAuth)
|
||||||
|
|
||||||
|
// 조회
|
||||||
|
router.post('/list', requirePermission('board:notice', 'READ'), listHandler)
|
||||||
|
router.post('/detail', requirePermission('board:notice', 'READ'), detailHandler)
|
||||||
|
|
||||||
|
// 생성/수정/삭제
|
||||||
|
router.post('/create', requirePermission('board:notice', 'CREATE'), createHandler)
|
||||||
|
router.post('/update', requirePermission('board:notice', 'UPDATE'), updateHandler)
|
||||||
|
router.post('/delete', requirePermission('board:notice', 'DELETE'), deleteHandler)
|
||||||
|
|
||||||
|
export default router
|
||||||
|
```
|
||||||
|
|
||||||
|
### 관리 API (예외)
|
||||||
|
사용자/역할/설정 등 관리 API는 `requireRole('ADMIN')` 유지:
|
||||||
|
```typescript
|
||||||
|
router.use(requireAuth)
|
||||||
|
router.use(requireRole('ADMIN'))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 백엔드 모듈 추가 절차
|
||||||
|
|
||||||
새 백엔드 모듈을 추가할 때:
|
새 백엔드 모듈을 추가할 때:
|
||||||
|
|
||||||
1. `backend/src/[모듈명]/` 디렉토리 생성
|
1. `backend/src/[모듈명]/` 디렉토리 생성
|
||||||
2. `[모듈명]Service.ts` — 비즈니스 로직 (DB 쿼리)
|
2. `[모듈명]Service.ts` — 비즈니스 로직 (DB 쿼리)
|
||||||
3. `[모듈명]Router.ts` — Express 라우터 (입력 검증, 에러 처리)
|
3. `[모듈명]Router.ts` — Express 라우터 (CRUD 엔드포인트 + requirePermission)
|
||||||
4. `backend/src/server.ts`에 라우터 등록:
|
4. `backend/src/server.ts`에 라우터 등록:
|
||||||
```typescript
|
```typescript
|
||||||
import newRouter from './[모듈명]/[모듈명]Router.js'
|
import newRouter from './[모듈명]/[모듈명]Router.js'
|
||||||
app.use('/api/[경로]', newRouter)
|
app.use('/api/[경로]', newRouter)
|
||||||
```
|
```
|
||||||
5. DB 테이블 필요 시 `database/auth_init.sql`에 DDL 추가
|
5. DB 테이블 필요 시 `database/auth_init.sql`에 DDL 추가
|
||||||
|
6. 리소스 코드를 `AUTH_PERM_TREE`에 등록 (마이그레이션 SQL)
|
||||||
|
|
||||||
### DB 접근
|
### DB 접근
|
||||||
```typescript
|
```typescript
|
||||||
@ -306,20 +418,30 @@ const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1'
|
|||||||
|
|
||||||
```
|
```
|
||||||
frontend/src/
|
frontend/src/
|
||||||
├── services/api.ts Axios 인스턴스 + 인터셉터
|
├── common/
|
||||||
├── services/authApi.ts 인증/사용자/역할/설정/메뉴/감사로그 API
|
│ ├── services/api.ts Axios 인스턴스 + API_BASE_URL + 인터셉터
|
||||||
├── store/authStore.ts 인증 상태 (Zustand)
|
│ ├── services/authApi.ts 인증/사용자/역할/설정/메뉴/감사로그 API
|
||||||
├── store/menuStore.ts 메뉴 상태 (Zustand)
|
│ ├── store/authStore.ts 인증 상태 + hasPermission (Zustand)
|
||||||
└── App.tsx 탭 라우팅 + 감사 로그 자동 기록
|
│ ├── store/menuStore.ts 메뉴 상태 (Zustand)
|
||||||
|
│ └── hooks/ useSubMenu, useFeatureTracking 등
|
||||||
|
├── tabs/ 탭별 패키지 (11개)
|
||||||
|
└── App.tsx 탭 라우팅 + 감사 로그 자동 기록
|
||||||
|
|
||||||
backend/src/
|
backend/src/
|
||||||
├── auth/ 인증 (JWT, OAuth, 미들웨어)
|
├── auth/ 인증 (JWT, OAuth, 미들웨어, requirePermission)
|
||||||
├── users/ 사용자 관리
|
├── users/ 사용자 관리
|
||||||
├── roles/ 역할/권한 관리
|
├── roles/ 역할/권한 관리 (permResolver, roleService)
|
||||||
├── settings/ 시스템 설정
|
├── settings/ 시스템 설정
|
||||||
├── menus/ 메뉴 설정
|
├── menus/ 메뉴 설정
|
||||||
├── audit/ 감사 로그
|
├── audit/ 감사 로그
|
||||||
├── db/ DB 연결 (authDb, database)
|
├── db/ DB 연결 (authDb, wingDb)
|
||||||
├── middleware/ 보안 미들웨어
|
├── middleware/ 보안 미들웨어
|
||||||
└── server.ts Express 진입점 + 라우터 등록
|
└── server.ts Express 진입점 + 라우터 등록
|
||||||
|
|
||||||
|
database/
|
||||||
|
├── auth_init.sql 인증 DB DDL + 초기 데이터
|
||||||
|
├── init.sql 운영 DB DDL
|
||||||
|
└── migration/ 마이그레이션 스크립트
|
||||||
|
├── 003_perm_tree.sql 리소스 트리 (AUTH_PERM_TREE)
|
||||||
|
└── 004_oper_cd.sql 오퍼레이션 코드 (OPER_CD) 추가
|
||||||
```
|
```
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { MainLayout } from '@common/components/layout/MainLayout'
|
|||||||
import { LoginPage } from '@common/components/auth/LoginPage'
|
import { LoginPage } from '@common/components/auth/LoginPage'
|
||||||
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu'
|
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu'
|
||||||
import { useAuthStore } from '@common/store/authStore'
|
import { useAuthStore } from '@common/store/authStore'
|
||||||
|
import { API_BASE_URL } from '@common/services/api'
|
||||||
import { useMenuStore } from '@common/store/menuStore'
|
import { useMenuStore } from '@common/store/menuStore'
|
||||||
import { OilSpillView } from '@tabs/prediction'
|
import { OilSpillView } from '@tabs/prediction'
|
||||||
import { ReportsView } from '@tabs/reports'
|
import { ReportsView } from '@tabs/reports'
|
||||||
@ -46,7 +47,7 @@ function App() {
|
|||||||
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
|
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
|
||||||
{ type: 'text/plain' }
|
{ type: 'text/plain' }
|
||||||
)
|
)
|
||||||
navigator.sendBeacon('/api/audit/log', blob)
|
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
|
||||||
}, [activeMainTab, isAuthenticated])
|
}, [activeMainTab, isAuthenticated])
|
||||||
|
|
||||||
// 세션 확인 중 스플래시
|
// 세션 확인 중 스플래시
|
||||||
|
|||||||
@ -1,22 +1,24 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useAuthStore } from '@common/store/authStore';
|
import { useAuthStore } from '@common/store/authStore';
|
||||||
import type { FeatureId } from '@common/constants/featureIds';
|
import { API_BASE_URL } from '@common/services/api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 서브탭 진입 시 감사 로그를 기록하는 훅.
|
* 서브탭 진입 시 감사 로그를 기록하는 훅.
|
||||||
* App.tsx의 탭 레벨 TAB_VIEW와 함께, 서브탭 레벨 SUBTAB_VIEW를 기록한다.
|
* App.tsx의 탭 레벨 TAB_VIEW와 함께, 서브탭 레벨 SUBTAB_VIEW를 기록한다.
|
||||||
*
|
*
|
||||||
* @param featureId - FEATURE_ID (예: 'aerial:media', 'admin:users')
|
* N-depth 지원: 콜론 구분 경로 (예: 'aerial:media', 'admin:users', 'a:b:c:d')
|
||||||
|
*
|
||||||
|
* @param featureId - 콜론 구분 리소스 경로
|
||||||
*/
|
*/
|
||||||
export function useFeatureTracking(featureId: FeatureId) {
|
export function useFeatureTracking(featureId: string) {
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated || !featureId) return;
|
||||||
const blob = new Blob(
|
const blob = new Blob(
|
||||||
[JSON.stringify({ action: 'SUBTAB_VIEW', detail: featureId })],
|
[JSON.stringify({ action: 'SUBTAB_VIEW', detail: featureId })],
|
||||||
{ type: 'text/plain' },
|
{ type: 'text/plain' },
|
||||||
);
|
);
|
||||||
navigator.sendBeacon('/api/audit/log', blob);
|
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob);
|
||||||
}, [featureId, isAuthenticated]);
|
}, [featureId, isAuthenticated]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import type { MainTab } from '../types/navigation'
|
import type { MainTab } from '../types/navigation'
|
||||||
|
import { useAuthStore } from '@common/store/authStore'
|
||||||
|
import { API_BASE_URL } from '@common/services/api'
|
||||||
|
|
||||||
interface SubMenuItem {
|
interface SubMenuItem {
|
||||||
id: string
|
id: string
|
||||||
@ -91,6 +93,8 @@ function subscribe(listener: () => void) {
|
|||||||
|
|
||||||
export function useSubMenu(mainTab: MainTab) {
|
export function useSubMenu(mainTab: MainTab) {
|
||||||
const [activeSubTab, setActiveSubTabLocal] = useState(subMenuState[mainTab])
|
const [activeSubTab, setActiveSubTabLocal] = useState(subMenuState[mainTab])
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||||
|
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = subscribe(() => {
|
const unsubscribe = subscribe(() => {
|
||||||
@ -103,10 +107,27 @@ export function useSubMenu(mainTab: MainTab) {
|
|||||||
setSubTab(mainTab, subTab)
|
setSubTab(mainTab, subTab)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 권한 기반 서브메뉴 필터링
|
||||||
|
const rawConfig = subMenuConfigs[mainTab]
|
||||||
|
const filteredConfig = rawConfig?.filter(item =>
|
||||||
|
hasPermission(`${mainTab}:${item.id}`)
|
||||||
|
) ?? null
|
||||||
|
|
||||||
|
// 서브탭 전환 시 자동 감사 로그 (N-depth 지원: 콜론 구분 경로)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated || !activeSubTab) return
|
||||||
|
const resourcePath = `${mainTab}:${activeSubTab}`
|
||||||
|
const blob = new Blob(
|
||||||
|
[JSON.stringify({ action: 'SUBTAB_VIEW', detail: resourcePath })],
|
||||||
|
{ type: 'text/plain' },
|
||||||
|
)
|
||||||
|
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
|
||||||
|
}, [mainTab, activeSubTab, isAuthenticated])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeSubTab,
|
activeSubTab,
|
||||||
setActiveSubTab,
|
setActiveSubTab,
|
||||||
subMenuConfig: subMenuConfigs[mainTab]
|
subMenuConfig: filteredConfig,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
|
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
|
||||||
|
|
||||||
export const api = axios.create({
|
export const api = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
|
|||||||
@ -7,7 +7,7 @@ export interface AuthUser {
|
|||||||
rank: string | null
|
rank: string | null
|
||||||
org: { sn: number; name: string; abbr: string } | null
|
org: { sn: number; name: string; abbr: string } | null
|
||||||
roles: string[]
|
roles: string[]
|
||||||
permissions: string[]
|
permissions: Record<string, string[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
@ -117,6 +117,7 @@ export interface RoleWithPermissions {
|
|||||||
permissions: Array<{
|
permissions: Array<{
|
||||||
sn: number
|
sn: number
|
||||||
resourceCode: string
|
resourceCode: string
|
||||||
|
operationCode: string
|
||||||
granted: boolean
|
granted: boolean
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
@ -126,9 +127,26 @@ export async function fetchRoles(): Promise<RoleWithPermissions[]> {
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 권한 트리 구조 API
|
||||||
|
export interface PermTreeNode {
|
||||||
|
code: string
|
||||||
|
parentCode: string | null
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
icon: string | null
|
||||||
|
level: number
|
||||||
|
sortOrder: number
|
||||||
|
children: PermTreeNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPermTree(): Promise<PermTreeNode[]> {
|
||||||
|
const response = await api.get<PermTreeNode[]>('/roles/perm-tree')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function updatePermissionsApi(
|
export async function updatePermissionsApi(
|
||||||
roleSn: number,
|
roleSn: number,
|
||||||
permissions: Array<{ resourceCode: string; granted: boolean }>
|
permissions: Array<{ resourceCode: string; operationCode: string; granted: boolean }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await api.put(`/roles/${roleSn}/permissions`, { permissions })
|
await api.put(`/roles/${roleSn}/permissions`, { permissions })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ interface AuthState {
|
|||||||
googleLogin: (credential: string) => Promise<void>
|
googleLogin: (credential: string) => Promise<void>
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
checkSession: () => Promise<void>
|
checkSession: () => Promise<void>
|
||||||
hasPermission: (resource: string) => boolean
|
hasPermission: (resource: string, operation?: string) => boolean
|
||||||
clearError: () => void
|
clearError: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,10 +70,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
hasPermission: (resource: string) => {
|
hasPermission: (resource: string, operation?: string) => {
|
||||||
const { user } = get()
|
const { user } = get()
|
||||||
if (!user) return false
|
if (!user) return false
|
||||||
return user.permissions.includes(resource)
|
const ops = user.permissions[resource]
|
||||||
|
if (!ops) return false
|
||||||
|
return ops.includes(operation ?? 'READ')
|
||||||
},
|
},
|
||||||
|
|
||||||
clearError: () => set({ error: null, pendingMessage: null }),
|
clearError: () => set({ error: null, pendingMessage: null }),
|
||||||
|
|||||||
@ -1,18 +1,251 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
fetchRoles,
|
fetchRoles,
|
||||||
|
fetchPermTree,
|
||||||
updatePermissionsApi,
|
updatePermissionsApi,
|
||||||
createRoleApi,
|
createRoleApi,
|
||||||
updateRoleApi,
|
updateRoleApi,
|
||||||
deleteRoleApi,
|
deleteRoleApi,
|
||||||
updateRoleDefaultApi,
|
updateRoleDefaultApi,
|
||||||
type RoleWithPermissions,
|
type RoleWithPermissions,
|
||||||
|
type PermTreeNode,
|
||||||
} from '@common/services/authApi'
|
} from '@common/services/authApi'
|
||||||
import { getRoleColor, PERM_RESOURCES } from './adminConstants'
|
import { getRoleColor } from './adminConstants'
|
||||||
|
|
||||||
// ─── 권한 관리 패널 ─────────────────────────────────────────
|
// ─── 오퍼레이션 코드 ─────────────────────────────────
|
||||||
|
const OPER_CODES = ['READ', 'CREATE', 'UPDATE', 'DELETE'] as const
|
||||||
|
type OperCode = (typeof OPER_CODES)[number]
|
||||||
|
const OPER_LABELS: Record<OperCode, string> = { READ: 'R', CREATE: 'C', UPDATE: 'U', DELETE: 'D' }
|
||||||
|
const OPER_FULL_LABELS: Record<OperCode, string> = { READ: '조회', CREATE: '생성', UPDATE: '수정', DELETE: '삭제' }
|
||||||
|
|
||||||
|
// ─── 권한 상태 타입 ─────────────────────────────────────
|
||||||
|
type PermState = 'explicit-granted' | 'inherited-granted' | 'explicit-denied' | 'forced-denied'
|
||||||
|
|
||||||
|
// ─── 키 유틸 ──────────────────────────────────────────
|
||||||
|
function makeKey(rsrc: string, oper: string): string { return `${rsrc}::${oper}` }
|
||||||
|
|
||||||
|
// ─── 유틸: 플랫 노드 목록 추출 (트리 DFS) ─────────────
|
||||||
|
function flattenTree(nodes: PermTreeNode[]): PermTreeNode[] {
|
||||||
|
const result: PermTreeNode[] = []
|
||||||
|
function walk(list: PermTreeNode[]) {
|
||||||
|
for (const n of list) {
|
||||||
|
result.push(n)
|
||||||
|
if (n.children.length > 0) walk(n.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(nodes)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 유틸: 권한 상태 계산 (오퍼레이션별) ──────────────
|
||||||
|
function resolvePermStateForOper(
|
||||||
|
code: string,
|
||||||
|
parentCode: string | null,
|
||||||
|
operCd: string,
|
||||||
|
explicitPerms: Map<string, boolean>,
|
||||||
|
cache: Map<string, PermState>,
|
||||||
|
): PermState {
|
||||||
|
const key = makeKey(code, operCd)
|
||||||
|
const cached = cache.get(key)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const explicit = explicitPerms.get(key)
|
||||||
|
|
||||||
|
if (parentCode === null) {
|
||||||
|
const state: PermState = explicit === true ? 'explicit-granted'
|
||||||
|
: explicit === false ? 'explicit-denied'
|
||||||
|
: 'explicit-denied'
|
||||||
|
cache.set(key, state)
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부모 READ 확인 (접근 게이트)
|
||||||
|
const parentReadKey = makeKey(parentCode, 'READ')
|
||||||
|
const parentReadState = cache.get(parentReadKey)
|
||||||
|
if (parentReadState === 'explicit-denied' || parentReadState === 'forced-denied') {
|
||||||
|
cache.set(key, 'forced-denied')
|
||||||
|
return 'forced-denied'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (explicit === true) {
|
||||||
|
cache.set(key, 'explicit-granted')
|
||||||
|
return 'explicit-granted'
|
||||||
|
}
|
||||||
|
if (explicit === false) {
|
||||||
|
cache.set(key, 'explicit-denied')
|
||||||
|
return 'explicit-denied'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부모의 같은 오퍼레이션 상속
|
||||||
|
const parentOperKey = makeKey(parentCode, operCd)
|
||||||
|
const parentOperState = cache.get(parentOperKey)
|
||||||
|
if (parentOperState === 'explicit-granted' || parentOperState === 'inherited-granted') {
|
||||||
|
cache.set(key, 'inherited-granted')
|
||||||
|
return 'inherited-granted'
|
||||||
|
}
|
||||||
|
if (parentOperState === 'forced-denied') {
|
||||||
|
cache.set(key, 'forced-denied')
|
||||||
|
return 'forced-denied'
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(key, 'explicit-denied')
|
||||||
|
return 'explicit-denied'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEffectiveStates(
|
||||||
|
flatNodes: PermTreeNode[],
|
||||||
|
explicitPerms: Map<string, boolean>,
|
||||||
|
): Map<string, PermState> {
|
||||||
|
const cache = new Map<string, PermState>()
|
||||||
|
for (const node of flatNodes) {
|
||||||
|
// READ 먼저 (CUD는 READ에 의존)
|
||||||
|
resolvePermStateForOper(node.code, node.parentCode, 'READ', explicitPerms, cache)
|
||||||
|
for (const oper of OPER_CODES) {
|
||||||
|
if (oper === 'READ') continue
|
||||||
|
resolvePermStateForOper(node.code, node.parentCode, oper, explicitPerms, cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 체크박스 셀 컴포넌트 ────────────────────────────
|
||||||
|
interface PermCellProps {
|
||||||
|
state: PermState
|
||||||
|
onToggle: () => void
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermCell({ state, onToggle, label }: PermCellProps) {
|
||||||
|
const isDisabled = state === 'forced-denied'
|
||||||
|
|
||||||
|
const baseClasses = 'w-7 h-7 rounded border text-xs font-bold transition-all flex items-center justify-center'
|
||||||
|
|
||||||
|
let classes: string
|
||||||
|
let icon: string
|
||||||
|
|
||||||
|
switch (state) {
|
||||||
|
case 'explicit-granted':
|
||||||
|
classes = `${baseClasses} bg-[rgba(6,182,212,0.2)] border-primary-cyan text-primary-cyan cursor-pointer hover:bg-[rgba(6,182,212,0.3)]`
|
||||||
|
icon = '✓'
|
||||||
|
break
|
||||||
|
case 'inherited-granted':
|
||||||
|
classes = `${baseClasses} bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] cursor-pointer hover:border-primary-cyan`
|
||||||
|
icon = '✓'
|
||||||
|
break
|
||||||
|
case 'explicit-denied':
|
||||||
|
classes = `${baseClasses} bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-red-400 cursor-pointer hover:border-red-400`
|
||||||
|
icon = '—'
|
||||||
|
break
|
||||||
|
case 'forced-denied':
|
||||||
|
classes = `${baseClasses} bg-bg-2 border-border text-text-3 opacity-40 cursor-not-allowed`
|
||||||
|
icon = '—'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={isDisabled ? undefined : onToggle}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={classes}
|
||||||
|
title={
|
||||||
|
state === 'explicit-granted' ? `${label ?? ''} 명시적 허용 (클릭: 거부로 전환)`
|
||||||
|
: state === 'inherited-granted' ? `${label ?? ''} 부모 상속 허용 (클릭: 명시적 거부)`
|
||||||
|
: state === 'explicit-denied' ? `${label ?? ''} 명시적 거부 (클릭: 허용으로 전환)`
|
||||||
|
: `${label ?? ''} 부모 거부로 비활성`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 트리 행 컴포넌트 ────────────────────────────────
|
||||||
|
interface TreeRowProps {
|
||||||
|
node: PermTreeNode
|
||||||
|
stateMap: Map<string, PermState>
|
||||||
|
expanded: Set<string>
|
||||||
|
onToggleExpand: (code: string) => void
|
||||||
|
onTogglePerm: (code: string, oper: OperCode, currentState: PermState) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeRow({ node, stateMap, expanded, onToggleExpand, onTogglePerm }: TreeRowProps) {
|
||||||
|
const hasChildren = node.children.length > 0
|
||||||
|
const isExpanded = expanded.has(node.code)
|
||||||
|
const indent = node.level * 24
|
||||||
|
|
||||||
|
// 이 노드의 READ 상태 (CUD 비활성 판단용)
|
||||||
|
const readState = stateMap.get(makeKey(node.code, 'READ')) ?? 'forced-denied'
|
||||||
|
const readDenied = readState === 'explicit-denied' || readState === 'forced-denied'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex items-center" style={{ paddingLeft: indent }}>
|
||||||
|
{hasChildren ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleExpand(node.code)}
|
||||||
|
className="w-5 h-5 flex items-center justify-center text-text-3 hover:text-text-1 transition-colors mr-1 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
|
||||||
|
className={`transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||||
|
>
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="w-5 mr-1 flex-shrink-0 text-center text-text-3 text-[10px]">
|
||||||
|
{node.level > 0 ? '├' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{node.icon && <span className="mr-1.5 flex-shrink-0">{node.icon}</span>}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className={`text-[12px] font-korean truncate ${node.level === 0 ? 'font-bold text-text-1' : 'font-medium text-text-2'}`}>
|
||||||
|
{node.name}
|
||||||
|
</div>
|
||||||
|
{node.description && node.level === 0 && (
|
||||||
|
<div className="text-[10px] text-text-3 font-korean truncate mt-0.5">{node.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{OPER_CODES.map(oper => {
|
||||||
|
const key = makeKey(node.code, oper)
|
||||||
|
const state = stateMap.get(key) ?? 'forced-denied'
|
||||||
|
// READ 거부 시 CUD도 강제 거부
|
||||||
|
const effectiveState = (oper !== 'READ' && readDenied) ? 'forced-denied' as PermState : state
|
||||||
|
return (
|
||||||
|
<td key={oper} className="px-2 py-2.5 text-center">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<PermCell
|
||||||
|
state={effectiveState}
|
||||||
|
label={OPER_FULL_LABELS[oper]}
|
||||||
|
onToggle={() => onTogglePerm(node.code, oper, effectiveState)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
{hasChildren && isExpanded && node.children.map(child => (
|
||||||
|
<TreeRow
|
||||||
|
key={child.code}
|
||||||
|
node={child}
|
||||||
|
stateMap={stateMap}
|
||||||
|
expanded={expanded}
|
||||||
|
onToggleExpand={onToggleExpand}
|
||||||
|
onTogglePerm={onTogglePerm}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 메인 PermissionsPanel ──────────────────────────
|
||||||
function PermissionsPanel() {
|
function PermissionsPanel() {
|
||||||
const [roles, setRoles] = useState<RoleWithPermissions[]>([])
|
const [roles, setRoles] = useState<RoleWithPermissions[]>([])
|
||||||
|
const [permTree, setPermTree] = useState<PermTreeNode[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [dirty, setDirty] = useState(false)
|
const [dirty, setDirty] = useState(false)
|
||||||
@ -24,68 +257,118 @@ function PermissionsPanel() {
|
|||||||
const [createError, setCreateError] = useState('')
|
const [createError, setCreateError] = useState('')
|
||||||
const [editingRoleSn, setEditingRoleSn] = useState<number | null>(null)
|
const [editingRoleSn, setEditingRoleSn] = useState<number | null>(null)
|
||||||
const [editRoleName, setEditRoleName] = useState('')
|
const [editRoleName, setEditRoleName] = useState('')
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||||
|
const [selectedRoleSn, setSelectedRoleSn] = useState<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
// 역할별 명시적 권한: Map<roleSn, Map<"rsrc::oper", boolean>>
|
||||||
loadRoles()
|
const [rolePerms, setRolePerms] = useState<Map<number, Map<string, boolean>>>(new Map())
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadRoles = async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = await fetchRoles()
|
const [rolesData, treeData] = await Promise.all([fetchRoles(), fetchPermTree()])
|
||||||
setRoles(data)
|
setRoles(rolesData)
|
||||||
|
setPermTree(treeData)
|
||||||
|
|
||||||
|
// 명시적 권한 맵 초기화 (rsrc::oper 키 형식)
|
||||||
|
const permsMap = new Map<number, Map<string, boolean>>()
|
||||||
|
for (const role of rolesData) {
|
||||||
|
const roleMap = new Map<string, boolean>()
|
||||||
|
for (const p of role.permissions) {
|
||||||
|
roleMap.set(makeKey(p.resourceCode, p.operationCode), p.granted)
|
||||||
|
}
|
||||||
|
permsMap.set(role.sn, roleMap)
|
||||||
|
}
|
||||||
|
setRolePerms(permsMap)
|
||||||
|
|
||||||
|
// 최상위 노드 기본 펼침
|
||||||
|
setExpanded(new Set(treeData.map(n => n.code)))
|
||||||
|
// 첫 번째 역할 선택
|
||||||
|
if (rolesData.length > 0 && !selectedRoleSn) {
|
||||||
|
setSelectedRoleSn(rolesData[0].sn)
|
||||||
|
}
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('역할 목록 조회 실패:', err)
|
console.error('권한 데이터 조회 실패:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- 초기 마운트 시 1회만 실행
|
||||||
|
}, [])
|
||||||
|
|
||||||
const getPermGranted = (roleSn: number, resourceCode: string): boolean => {
|
useEffect(() => {
|
||||||
const role = roles.find(r => r.sn === roleSn)
|
loadData()
|
||||||
if (!role) return false
|
}, [loadData])
|
||||||
const perm = role.permissions.find(p => p.resourceCode === resourceCode)
|
|
||||||
return perm?.granted ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
const togglePerm = (roleSn: number, resourceCode: string) => {
|
// 플랫 노드 목록
|
||||||
setRoles(prev => prev.map(role => {
|
const flatNodes = flattenTree(permTree)
|
||||||
if (role.sn !== roleSn) return role
|
|
||||||
const perms = role.permissions.map(p =>
|
// 선택된 역할의 effective state 계산
|
||||||
p.resourceCode === resourceCode ? { ...p, granted: !p.granted } : p
|
const currentStateMap = selectedRoleSn
|
||||||
)
|
? buildEffectiveStates(flatNodes, rolePerms.get(selectedRoleSn) ?? new Map())
|
||||||
if (!perms.find(p => p.resourceCode === resourceCode)) {
|
: new Map<string, PermState>()
|
||||||
perms.push({ sn: 0, resourceCode, granted: true })
|
|
||||||
|
const handleToggleExpand = useCallback((code: string) => {
|
||||||
|
setExpanded(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(code)) next.delete(code)
|
||||||
|
else next.add(code)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTogglePerm = useCallback((code: string, oper: OperCode, currentState: PermState) => {
|
||||||
|
if (!selectedRoleSn) return
|
||||||
|
|
||||||
|
setRolePerms(prev => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
const roleMap = new Map(next.get(selectedRoleSn) ?? new Map())
|
||||||
|
|
||||||
|
const key = makeKey(code, oper)
|
||||||
|
const node = flatNodes.find(n => n.code === code)
|
||||||
|
const isRoot = node ? node.parentCode === null : false
|
||||||
|
|
||||||
|
switch (currentState) {
|
||||||
|
case 'explicit-granted':
|
||||||
|
roleMap.set(key, false)
|
||||||
|
break
|
||||||
|
case 'inherited-granted':
|
||||||
|
roleMap.set(key, false)
|
||||||
|
break
|
||||||
|
case 'explicit-denied':
|
||||||
|
if (isRoot) {
|
||||||
|
roleMap.set(key, true)
|
||||||
|
} else {
|
||||||
|
roleMap.delete(key)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return prev
|
||||||
}
|
}
|
||||||
return { ...role, permissions: perms }
|
|
||||||
}))
|
|
||||||
setDirty(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleDefault = async (roleSn: number) => {
|
next.set(selectedRoleSn, roleMap)
|
||||||
const role = roles.find(r => r.sn === roleSn)
|
return next
|
||||||
if (!role) return
|
})
|
||||||
const newValue = !role.isDefault
|
setDirty(true)
|
||||||
try {
|
}, [selectedRoleSn, flatNodes])
|
||||||
await updateRoleDefaultApi(roleSn, newValue)
|
|
||||||
setRoles(prev => prev.map(r =>
|
|
||||||
r.sn === roleSn ? { ...r, isDefault: newValue } : r
|
|
||||||
))
|
|
||||||
} catch (err) {
|
|
||||||
console.error('기본 역할 변경 실패:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
for (const role of roles) {
|
for (const role of roles) {
|
||||||
const permissions = PERM_RESOURCES.map(r => ({
|
const perms = rolePerms.get(role.sn)
|
||||||
resourceCode: r.id,
|
if (!perms) continue
|
||||||
granted: getPermGranted(role.sn, r.id),
|
|
||||||
}))
|
const permsList: Array<{ resourceCode: string; operationCode: string; granted: boolean }> = []
|
||||||
await updatePermissionsApi(role.sn, permissions)
|
for (const [key, granted] of perms) {
|
||||||
|
const sepIdx = key.indexOf('::')
|
||||||
|
permsList.push({
|
||||||
|
resourceCode: key.substring(0, sepIdx),
|
||||||
|
operationCode: key.substring(sepIdx + 2),
|
||||||
|
granted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await updatePermissionsApi(role.sn, permsList)
|
||||||
}
|
}
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -100,7 +383,7 @@ function PermissionsPanel() {
|
|||||||
setCreateError('')
|
setCreateError('')
|
||||||
try {
|
try {
|
||||||
await createRoleApi({ code: newRoleCode, name: newRoleName, description: newRoleDesc || undefined })
|
await createRoleApi({ code: newRoleCode, name: newRoleName, description: newRoleDesc || undefined })
|
||||||
await loadRoles()
|
await loadData()
|
||||||
setShowCreateForm(false)
|
setShowCreateForm(false)
|
||||||
setNewRoleCode('')
|
setNewRoleCode('')
|
||||||
setNewRoleName('')
|
setNewRoleName('')
|
||||||
@ -119,7 +402,8 @@ function PermissionsPanel() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await deleteRoleApi(roleSn)
|
await deleteRoleApi(roleSn)
|
||||||
await loadRoles()
|
if (selectedRoleSn === roleSn) setSelectedRoleSn(null)
|
||||||
|
await loadData()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('역할 삭제 실패:', err)
|
console.error('역할 삭제 실패:', err)
|
||||||
}
|
}
|
||||||
@ -143,16 +427,31 @@ function PermissionsPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleDefault = async (roleSn: number) => {
|
||||||
|
const role = roles.find(r => r.sn === roleSn)
|
||||||
|
if (!role) return
|
||||||
|
const newValue = !role.isDefault
|
||||||
|
try {
|
||||||
|
await updateRoleDefaultApi(roleSn, newValue)
|
||||||
|
setRoles(prev => prev.map(r =>
|
||||||
|
r.sn === roleSn ? { ...r, isDefault: newValue } : r
|
||||||
|
))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('기본 역할 변경 실패:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">불러오는 중...</div>
|
return <div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">불러오는 중...</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-bold text-text-1 font-korean">사용자 권한 관리</h1>
|
<h1 className="text-lg font-bold text-text-1 font-korean">사용자 권한 관리</h1>
|
||||||
<p className="text-xs text-text-3 mt-1 font-korean">역할별 메뉴 접근 권한을 설정합니다</p>
|
<p className="text-xs text-text-3 mt-1 font-korean">역할별 리소스 × CRUD 권한을 설정합니다</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@ -173,92 +472,130 @@ function PermissionsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
{/* 역할 탭 바 */}
|
||||||
<table className="w-full">
|
<div className="flex items-center gap-2 px-6 py-3 border-b border-border bg-bg-1 overflow-x-auto" style={{ flexShrink: 0 }}>
|
||||||
<thead>
|
{roles.map((role, idx) => {
|
||||||
<tr className="border-b border-border bg-bg-1">
|
const color = getRoleColor(role.code, idx)
|
||||||
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean min-w-[200px]">기능</th>
|
const isSelected = selectedRoleSn === role.sn
|
||||||
{roles.map((role, idx) => {
|
return (
|
||||||
const color = getRoleColor(role.code, idx)
|
<div key={role.sn} className="flex items-center gap-1 flex-shrink-0">
|
||||||
return (
|
<button
|
||||||
<th key={role.sn} className="px-4 py-3 text-center min-w-[100px]">
|
onClick={() => setSelectedRoleSn(role.sn)}
|
||||||
<div className="flex items-center justify-center gap-1">
|
className={`px-3 py-1.5 text-xs font-semibold rounded-md transition-all font-korean ${
|
||||||
{editingRoleSn === role.sn ? (
|
isSelected
|
||||||
<input
|
? 'border-2 shadow-[0_0_8px_rgba(6,182,212,0.2)]'
|
||||||
type="text"
|
: 'border border-border text-text-3 hover:border-border'
|
||||||
value={editRoleName}
|
}`}
|
||||||
onChange={(e) => setEditRoleName(e.target.value)}
|
style={isSelected ? { borderColor: color, color } : undefined}
|
||||||
onKeyDown={(e) => {
|
>
|
||||||
if (e.key === 'Enter') handleSaveRoleName(role.sn)
|
{editingRoleSn === role.sn ? (
|
||||||
if (e.key === 'Escape') setEditingRoleSn(null)
|
<input
|
||||||
}}
|
type="text"
|
||||||
onBlur={() => handleSaveRoleName(role.sn)}
|
value={editRoleName}
|
||||||
autoFocus
|
onChange={(e) => setEditRoleName(e.target.value)}
|
||||||
className="w-20 px-1 py-0.5 text-[11px] font-semibold bg-bg-2 border border-primary-cyan rounded text-center text-text-1 focus:outline-none font-korean"
|
onKeyDown={(e) => {
|
||||||
/>
|
if (e.key === 'Enter') handleSaveRoleName(role.sn)
|
||||||
) : (
|
if (e.key === 'Escape') setEditingRoleSn(null)
|
||||||
<span
|
}}
|
||||||
className="text-[11px] font-semibold font-korean cursor-pointer hover:underline"
|
onBlur={() => handleSaveRoleName(role.sn)}
|
||||||
style={{ color }}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onClick={() => handleStartEditName(role)}
|
autoFocus
|
||||||
title="클릭하여 이름 수정"
|
className="w-20 px-1 py-0 text-[11px] font-semibold bg-bg-2 border border-primary-cyan rounded text-center text-text-1 focus:outline-none font-korean"
|
||||||
>
|
/>
|
||||||
{role.name}
|
) : (
|
||||||
</span>
|
<span onDoubleClick={() => handleStartEditName(role)}>
|
||||||
)}
|
{role.name}
|
||||||
{role.code !== 'ADMIN' && (
|
</span>
|
||||||
<button
|
)}
|
||||||
onClick={() => handleDeleteRole(role.sn, role.name)}
|
<span className="ml-1 text-[9px] font-mono opacity-50">{role.code}</span>
|
||||||
className="w-4 h-4 flex items-center justify-center text-text-3 hover:text-red-400 transition-colors"
|
{role.isDefault && <span className="ml-1 text-[9px] text-primary-cyan">기본</span>}
|
||||||
title="역할 삭제"
|
</button>
|
||||||
>
|
{isSelected && (
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<div className="flex items-center gap-0.5">
|
||||||
</button>
|
<button
|
||||||
)}
|
onClick={() => toggleDefault(role.sn)}
|
||||||
</div>
|
className={`px-1.5 py-0.5 text-[9px] rounded transition-all font-korean ${
|
||||||
<div className="text-[9px] text-text-3 font-mono mt-0.5">{role.code}</div>
|
role.isDefault
|
||||||
|
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan'
|
||||||
|
: 'text-text-3 hover:text-text-2'
|
||||||
|
}`}
|
||||||
|
title="신규 사용자 기본 역할 설정"
|
||||||
|
>
|
||||||
|
{role.isDefault ? '기본역할' : '기본설정'}
|
||||||
|
</button>
|
||||||
|
{role.code !== 'ADMIN' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleDefault(role.sn)}
|
onClick={() => handleDeleteRole(role.sn, role.name)}
|
||||||
className={`mt-1 px-1.5 py-0.5 text-[9px] rounded transition-all font-korean ${
|
className="w-5 h-5 flex items-center justify-center text-text-3 hover:text-red-400 transition-colors"
|
||||||
role.isDefault
|
title="역할 삭제"
|
||||||
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan'
|
|
||||||
: 'text-text-3 border border-transparent hover:border-border'
|
|
||||||
}`}
|
|
||||||
title="신규 사용자에게 자동 할당되는 기본 역할"
|
|
||||||
>
|
>
|
||||||
{role.isDefault ? '기본역할' : '기본역할 설정'}
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 범례 */}
|
||||||
|
<div className="flex items-center gap-4 px-6 py-2 border-b border-border bg-bg-1 text-[10px] text-text-3 font-korean" style={{ flexShrink: 0 }}>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block w-4 h-4 rounded border bg-[rgba(6,182,212,0.2)] border-primary-cyan text-primary-cyan text-center text-[9px] leading-4">✓</span>
|
||||||
|
명시적 허용
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block w-4 h-4 rounded border bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] text-center text-[9px] leading-4">✓</span>
|
||||||
|
상속 허용
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block w-4 h-4 rounded border bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-red-400 text-center text-[9px] leading-4">—</span>
|
||||||
|
명시적 거부
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block w-4 h-4 rounded border bg-bg-2 border-border text-text-3 opacity-40 text-center text-[9px] leading-4">—</span>
|
||||||
|
강제 거부
|
||||||
|
</span>
|
||||||
|
<span className="ml-4 border-l border-border pl-4 text-text-3">
|
||||||
|
R=조회 C=생성 U=수정 D=삭제
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CRUD 매트릭스 테이블 */}
|
||||||
|
{selectedRoleSn ? (
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-bg-1 sticky top-0 z-10">
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean min-w-[240px]">기능</th>
|
||||||
|
{OPER_CODES.map(oper => (
|
||||||
|
<th key={oper} className="px-2 py-3 text-center w-16">
|
||||||
|
<div className="text-[11px] font-semibold text-text-2">{OPER_LABELS[oper]}</div>
|
||||||
|
<div className="text-[9px] text-text-3 font-korean">{OPER_FULL_LABELS[oper]}</div>
|
||||||
</th>
|
</th>
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{PERM_RESOURCES.map((perm) => (
|
|
||||||
<tr key={perm.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
|
||||||
<td className="px-6 py-3">
|
|
||||||
<div className="text-[12px] text-text-1 font-semibold font-korean">{perm.label}</div>
|
|
||||||
<div className="text-[10px] text-text-3 font-korean mt-0.5">{perm.desc}</div>
|
|
||||||
</td>
|
|
||||||
{roles.map(role => (
|
|
||||||
<td key={role.sn} className="px-4 py-3 text-center">
|
|
||||||
<button
|
|
||||||
onClick={() => togglePerm(role.sn, perm.id)}
|
|
||||||
className={`w-8 h-8 rounded-md border text-sm transition-all ${
|
|
||||||
getPermGranted(role.sn, perm.id)
|
|
||||||
? 'bg-[rgba(6,182,212,0.15)] border-primary-cyan text-primary-cyan'
|
|
||||||
: 'bg-bg-2 border-border text-text-3 hover:border-text-3'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{getPermGranted(role.sn, perm.id) ? '✓' : '—'}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{permTree.map(rootNode => (
|
||||||
</div>
|
<TreeRow
|
||||||
|
key={rootNode.code}
|
||||||
|
node={rootNode}
|
||||||
|
stateMap={currentStateMap}
|
||||||
|
expanded={expanded}
|
||||||
|
onToggleExpand={handleToggleExpand}
|
||||||
|
onTogglePerm={handleTogglePerm}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-text-3 text-sm font-korean">
|
||||||
|
역할을 선택하세요
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 역할 생성 모달 */}
|
{/* 역할 생성 모달 */}
|
||||||
{showCreateForm && (
|
{showCreateForm && (
|
||||||
|
|||||||
@ -21,16 +21,4 @@ export const statusLabels: Record<string, { label: string; color: string; dot: s
|
|||||||
REJECTED: { label: '거절됨', color: 'text-red-300', dot: 'bg-red-300' },
|
REJECTED: { label: '거절됨', color: 'text-red-300', dot: 'bg-red-300' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PERM_RESOURCES = [
|
// PERM_RESOURCES 제거됨 — GET /api/roles/perm-tree에서 동적 로드 (PermissionsPanel)
|
||||||
{ id: 'prediction', label: '유출유 확산예측', desc: '확산 예측 실행 및 결과 조회' },
|
|
||||||
{ id: 'hns', label: 'HNS·대기확산', desc: '대기확산 분석 실행 및 조회' },
|
|
||||||
{ id: 'rescue', label: '긴급구난', desc: '구난 예측 실행 및 조회' },
|
|
||||||
{ id: 'reports', label: '보고자료', desc: '보고자료 생성 및 관리' },
|
|
||||||
{ id: 'aerial', label: '항공탐색', desc: '항공탐색 계획 및 결과 조회' },
|
|
||||||
{ id: 'assets', label: '방제자산 관리', desc: '방제자산 등록 및 관리' },
|
|
||||||
{ id: 'scat', label: '해안평가', desc: '해안 SCAT 조사 접근' },
|
|
||||||
{ id: 'incidents', label: '사고조회', desc: '사고 정보 등록 및 조회' },
|
|
||||||
{ id: 'board', label: '게시판', desc: '게시판 접근' },
|
|
||||||
{ id: 'weather', label: '기상정보', desc: '기상 정보 조회' },
|
|
||||||
{ id: 'admin', label: '관리자 설정', desc: '시스템 관리 기능 접근' },
|
|
||||||
]
|
|
||||||
|
|||||||
@ -4,12 +4,16 @@ import AssetManagement from './AssetManagement'
|
|||||||
import AssetUpload from './AssetUpload'
|
import AssetUpload from './AssetUpload'
|
||||||
import AssetTheory from './AssetTheory'
|
import AssetTheory from './AssetTheory'
|
||||||
import ShipInsurance from './ShipInsurance'
|
import ShipInsurance from './ShipInsurance'
|
||||||
|
import { useFeatureTracking } from '@common/hooks/useFeatureTracking'
|
||||||
|
|
||||||
// ── Main AssetsView ──
|
// ── Main AssetsView ──
|
||||||
|
|
||||||
export function AssetsView() {
|
export function AssetsView() {
|
||||||
const [activeTab, setActiveTab] = useState<AssetsTab>('management')
|
const [activeTab, setActiveTab] = useState<AssetsTab>('management')
|
||||||
|
|
||||||
|
// 내부 탭 전환 시 자동 감사 로그
|
||||||
|
useFeatureTracking(`assets:${activeTab}`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full bg-bg-0">
|
<div className="flex flex-col h-full w-full bg-bg-0">
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user