refactor(phase4-5): 대형 View 분할 + RBAC 권한 시스템 + DB 통합 + 게시판 CRUD #25
@ -1,11 +1,13 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import { verifyToken, getTokenFromCookie } from './jwtProvider.js'
|
||||
import type { JwtPayload } from './jwtProvider.js'
|
||||
import { getUserInfo } from './authService.js'
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: JwtPayload
|
||||
resolvedPermissions?: Record<string, string[]>
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -43,3 +45,43 @@ export function requireRole(...roles: string[]) {
|
||||
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 { signToken, setTokenCookie } from './jwtProvider.js'
|
||||
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 SALT_ROUNDS = 10
|
||||
@ -24,7 +26,7 @@ interface AuthUserInfo {
|
||||
rank: string | null
|
||||
org: { sn: number; name: string; abbr: string } | null
|
||||
roles: string[]
|
||||
permissions: string[]
|
||||
permissions: Record<string, string[]>
|
||||
}
|
||||
|
||||
export async function login(
|
||||
@ -127,9 +129,9 @@ export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
|
||||
|
||||
const row = userResult.rows[0]
|
||||
|
||||
// 역할 조회
|
||||
// 역할 조회 (ROLE_SN + ROLE_CD)
|
||||
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
|
||||
JOIN AUTH_ROLE r ON ur.ROLE_SN = r.ROLE_SN
|
||||
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 roleSns = rolesResult.rows.map((r: { role_sn: number }) => r.role_sn)
|
||||
|
||||
// 권한 조회 (역할 기반)
|
||||
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]
|
||||
)
|
||||
// 트리 기반 resolved permissions (리소스 × 오퍼레이션)
|
||||
let permissions: Record<string, string[]>
|
||||
try {
|
||||
const treeNodes = await getPermTreeNodes()
|
||||
|
||||
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 {
|
||||
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 { requireAuth, requireRole } from '../auth/authMiddleware.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()
|
||||
|
||||
router.use(requireAuth)
|
||||
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
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
@ -76,6 +87,7 @@ router.delete('/:id', async (req, res) => {
|
||||
})
|
||||
|
||||
// PUT /api/roles/:id/permissions
|
||||
// 요청: { permissions: [{ resourceCode, operationCode, granted }] }
|
||||
router.put('/:id/permissions', async (req, res) => {
|
||||
try {
|
||||
const roleSn = Number(req.params.id)
|
||||
@ -86,6 +98,13 @@ router.put('/:id/permissions', async (req, res) => {
|
||||
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)
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
|
||||
@ -1,13 +1,34 @@
|
||||
import { authPool } from '../db/authDb.js'
|
||||
import { AuthError } from '../auth/authService.js'
|
||||
|
||||
const PERM_RESOURCE_CODES = [
|
||||
'prediction', 'hns', 'rescue', 'reports', 'aerial',
|
||||
'assets', 'scat', 'incidents', 'board', 'weather', 'admin',
|
||||
] as const
|
||||
import { type PermTreeNode, buildPermTree, type PermTreeResponse } from './permResolver.js'
|
||||
|
||||
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 {
|
||||
sn: number
|
||||
code: string
|
||||
@ -17,6 +38,7 @@ interface RoleWithPermissions {
|
||||
permissions: Array<{
|
||||
sn: number
|
||||
resourceCode: string
|
||||
operationCode: string
|
||||
granted: boolean
|
||||
}>
|
||||
}
|
||||
@ -42,8 +64,8 @@ export async function listRolesWithPermissions(): Promise<RoleWithPermissions[]>
|
||||
|
||||
for (const row of rolesResult.rows) {
|
||||
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]
|
||||
)
|
||||
|
||||
@ -53,9 +75,12 @@ export async function listRolesWithPermissions(): Promise<RoleWithPermissions[]>
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
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,
|
||||
resourceCode: p.resource_code,
|
||||
operationCode: p.operation_code,
|
||||
granted: p.granted === 'Y',
|
||||
})),
|
||||
})
|
||||
@ -94,17 +119,20 @@ export async function createRole(input: CreateRoleInput): Promise<RoleWithPermis
|
||||
)
|
||||
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(
|
||||
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES ($1, $2, $3)',
|
||||
[row.sn, rsrc, 'N']
|
||||
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES ($1, $2, $3, $4)',
|
||||
[row.sn, rsrc, 'READ', 'N']
|
||||
)
|
||||
}
|
||||
|
||||
await client.query('COMMIT')
|
||||
|
||||
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]
|
||||
)
|
||||
|
||||
@ -114,9 +142,12 @@ export async function createRole(input: CreateRoleInput): Promise<RoleWithPermis
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
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,
|
||||
resourceCode: p.resource_code,
|
||||
operationCode: p.operation_code,
|
||||
granted: p.granted === 'Y',
|
||||
})),
|
||||
}
|
||||
@ -177,23 +208,23 @@ export async function deleteRole(roleSn: number): Promise<void> {
|
||||
|
||||
export async function updatePermissions(
|
||||
roleSn: number,
|
||||
permissions: Array<{ resourceCode: string; granted: boolean }>
|
||||
permissions: Array<{ resourceCode: string; operationCode: string; granted: boolean }>
|
||||
): Promise<void> {
|
||||
for (const perm of permissions) {
|
||||
const existing = await authPool.query(
|
||||
'SELECT PERM_SN FROM AUTH_PERM WHERE ROLE_SN = $1 AND RSRC_CD = $2',
|
||||
[roleSn, perm.resourceCode]
|
||||
'SELECT PERM_SN FROM AUTH_PERM WHERE ROLE_SN = $1 AND RSRC_CD = $2 AND OPER_CD = $3',
|
||||
[roleSn, perm.resourceCode, perm.operationCode]
|
||||
)
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
await authPool.query(
|
||||
'UPDATE AUTH_PERM SET GRANT_YN = $1 WHERE ROLE_SN = $2 AND RSRC_CD = $3',
|
||||
[perm.granted ? 'Y' : 'N', roleSn, perm.resourceCode]
|
||||
'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.operationCode]
|
||||
)
|
||||
} else {
|
||||
await authPool.query(
|
||||
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES ($1, $2, $3)',
|
||||
[roleSn, perm.resourceCode, perm.granted ? 'Y' : 'N']
|
||||
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES ($1, $2, $3, $4)',
|
||||
[roleSn, perm.resourceCode, perm.operationCode, perm.granted ? 'Y' : 'N']
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,6 +48,7 @@ app.use(helmet({
|
||||
}
|
||||
},
|
||||
crossOriginEmbedderPolicy: false, // API 서버이므로 비활성
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' }, // sendBeacon cross-origin 허용
|
||||
}))
|
||||
|
||||
// 2. 서버 정보 제거 (공격자에게 기술 스택 노출 방지)
|
||||
|
||||
@ -134,18 +134,21 @@ CREATE TABLE AUTH_PERM (
|
||||
PERM_SN SERIAL NOT NULL,
|
||||
ROLE_SN INTEGER NOT NULL,
|
||||
RSRC_CD VARCHAR(50) NOT NULL,
|
||||
OPER_CD VARCHAR(20) NOT NULL,
|
||||
GRANT_YN CHAR(1) NOT NULL DEFAULT 'Y',
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
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 UK_AUTH_PERM UNIQUE (ROLE_SN, RSRC_CD),
|
||||
CONSTRAINT CK_AUTH_PERM_GRANT CHECK (GRANT_YN IN ('Y','N'))
|
||||
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_OPER CHECK (OPER_CD IN ('READ','CREATE','UPDATE','DELETE','MANAGE','EXPORT'))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE AUTH_PERM IS '역할별권한';
|
||||
COMMENT ON COLUMN AUTH_PERM.PERM_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.OPER_CD IS '오퍼레이션코드 (READ, CREATE, UPDATE, DELETE, MANAGE, EXPORT)';
|
||||
COMMENT ON COLUMN AUTH_PERM.GRANT_YN IS '부여여부 (Y:허용, N:거부)';
|
||||
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 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_OPER ON AUTH_PERM (OPER_CD);
|
||||
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_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): 모든 탭 접근
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
|
||||
(1, 'prediction', 'Y'), (1, 'hns', 'Y'), (1, 'rescue', 'Y'),
|
||||
(1, 'reports', 'Y'), (1, 'aerial', 'Y'), (1, 'assets', 'Y'),
|
||||
(1, 'scat', 'Y'), (1, 'incidents', 'Y'), (1, 'board', 'Y'),
|
||||
(1, 'weather', 'Y'), (1, 'admin', 'Y');
|
||||
-- ADMIN (ROLE_SN=1): 모든 탭 × 모든 오퍼레이션 허용
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||
(1, 'prediction', 'READ', 'Y'), (1, 'prediction', 'CREATE', 'Y'), (1, 'prediction', 'UPDATE', 'Y'), (1, 'prediction', 'DELETE', 'Y'),
|
||||
(1, 'hns', 'READ', 'Y'), (1, 'hns', 'CREATE', 'Y'), (1, 'hns', 'UPDATE', 'Y'), (1, 'hns', 'DELETE', 'Y'),
|
||||
(1, 'rescue', 'READ', 'Y'), (1, 'rescue', 'CREATE', 'Y'), (1, 'rescue', 'UPDATE', 'Y'), (1, 'rescue', 'DELETE', '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 탭 제외
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
|
||||
(2, 'prediction', 'Y'), (2, 'hns', 'Y'), (2, 'rescue', 'Y'),
|
||||
(2, 'reports', 'Y'), (2, 'aerial', 'Y'), (2, 'assets', 'Y'),
|
||||
(2, 'scat', 'Y'), (2, 'incidents', 'Y'), (2, 'board', 'Y'),
|
||||
(2, 'weather', 'Y'), (2, 'admin', 'N');
|
||||
-- MANAGER (ROLE_SN=2): admin 탭 제외, RCUD 허용
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||
(2, 'prediction', 'READ', 'Y'), (2, 'prediction', 'CREATE', 'Y'), (2, 'prediction', 'UPDATE', 'Y'), (2, 'prediction', 'DELETE', 'Y'),
|
||||
(2, 'hns', 'READ', 'Y'), (2, 'hns', 'CREATE', 'Y'), (2, 'hns', 'UPDATE', 'Y'), (2, 'hns', 'DELETE', 'Y'),
|
||||
(2, 'rescue', 'READ', 'Y'), (2, 'rescue', 'CREATE', 'Y'), (2, 'rescue', 'UPDATE', 'Y'), (2, 'rescue', 'DELETE', 'Y'),
|
||||
(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 탭 제외
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
|
||||
(3, 'prediction', 'Y'), (3, 'hns', 'Y'), (3, 'rescue', 'Y'),
|
||||
(3, 'reports', 'Y'), (3, 'aerial', 'Y'), (3, 'assets', 'N'),
|
||||
(3, 'scat', 'Y'), (3, 'incidents', 'Y'), (3, 'board', 'Y'),
|
||||
(3, 'weather', 'Y'), (3, 'admin', 'N');
|
||||
-- USER (ROLE_SN=3): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||
(3, 'prediction', 'READ', 'Y'), (3, 'prediction', 'CREATE', 'Y'), (3, 'prediction', 'UPDATE', 'Y'),
|
||||
(3, 'hns', 'READ', 'Y'), (3, 'hns', 'CREATE', 'Y'), (3, 'hns', 'UPDATE', 'Y'),
|
||||
(3, 'rescue', 'READ', 'Y'), (3, 'rescue', 'CREATE', 'Y'), (3, 'rescue', 'UPDATE', 'Y'),
|
||||
(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 제외
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES
|
||||
(4, 'prediction', 'Y'), (4, 'hns', 'Y'), (4, 'rescue', 'Y'),
|
||||
(4, 'reports', 'N'), (4, 'aerial', 'Y'), (4, 'assets', 'N'),
|
||||
(4, 'scat', 'N'), (4, 'incidents', 'Y'), (4, 'board', 'Y'),
|
||||
(4, 'weather', 'Y'), (4, 'admin', 'N');
|
||||
-- VIEWER (ROLE_SN=4): 제한적 탭의 READ만 허용
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||
(4, 'prediction', 'READ', 'Y'),
|
||||
(4, 'hns', 'READ', 'Y'),
|
||||
(4, 'rescue', 'READ', 'Y'),
|
||||
(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`로 상태를 관리합니다.
|
||||
|
||||
### 권한 모델: 리소스 × 오퍼레이션 (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
|
||||
// backend/src/auth/authMiddleware.ts
|
||||
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
||||
import { requireAuth, requireRole, requirePermission } from '../auth/authMiddleware.js'
|
||||
|
||||
// 인증만 필요한 라우트
|
||||
router.use(requireAuth)
|
||||
|
||||
// 특정 역할 필요
|
||||
// 역할 기반 (관리 API용)
|
||||
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)
|
||||
`requireAuth` 통과 후 `req.user`에 담기는 정보:
|
||||
```typescript
|
||||
@ -36,25 +94,21 @@ interface JwtPayload {
|
||||
}
|
||||
```
|
||||
|
||||
#### 라우터 패턴
|
||||
#### 라우터 패턴 (CRUD 구조)
|
||||
```typescript
|
||||
// backend/src/[모듈]/[모듈]Router.ts
|
||||
import { Router } from 'express'
|
||||
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
||||
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireAuth)
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const userId = req.user!.sub
|
||||
// 비즈니스 로직...
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
console.error('[모듈] 오류:', err)
|
||||
res.status(500).json({ error: '처리 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
// 리소스별 CRUD 엔드포인트
|
||||
router.post('/list', requirePermission('module:sub', 'READ'), listHandler)
|
||||
router.post('/detail', requirePermission('module:sub', 'READ'), detailHandler)
|
||||
router.post('/create', requirePermission('module:sub', 'CREATE'), createHandler)
|
||||
router.post('/update', requirePermission('module:sub', 'UPDATE'), updateHandler)
|
||||
router.post('/delete', requirePermission('module:sub', 'DELETE'), deleteHandler)
|
||||
|
||||
export default router
|
||||
```
|
||||
@ -63,32 +117,36 @@ export default router
|
||||
|
||||
#### authStore (Zustand)
|
||||
```typescript
|
||||
// frontend/src/store/authStore.ts
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useAuthStore } from '@common/store/authStore'
|
||||
|
||||
// 컴포넌트 내에서 사용
|
||||
const { user, isAuthenticated, hasPermission, logout } = useAuthStore()
|
||||
|
||||
// 사용자 정보
|
||||
user?.id // UUID
|
||||
user?.name // 이름
|
||||
user?.roles // ['ADMIN', 'USER']
|
||||
user?.permissions // { 'prediction': ['READ','CREATE','UPDATE','DELETE'], ... }
|
||||
|
||||
// 권한 확인 (탭 ID 기준)
|
||||
hasPermission('prediction') // true/false
|
||||
hasPermission('admin') // true/false
|
||||
// 권한 확인 (리소스 × 오퍼레이션)
|
||||
hasPermission('prediction') // READ 확인 (기본값)
|
||||
hasPermission('prediction', 'READ') // 명시적 READ 확인
|
||||
hasPermission('board:notice', 'CREATE') // 공지사항 생성 권한
|
||||
hasPermission('board:notice', 'DELETE') // 공지사항 삭제 권한
|
||||
|
||||
// 하위 호환: operation 생략 시 'READ' 기본값
|
||||
hasPermission('admin') // === hasPermission('admin', 'READ')
|
||||
```
|
||||
|
||||
#### API 클라이언트
|
||||
```typescript
|
||||
// frontend/src/services/api.ts
|
||||
import { api } from './api'
|
||||
import { api } from '@common/services/api'
|
||||
|
||||
// withCredentials: true 설정으로 JWT 쿠키 자동 포함
|
||||
const response = await api.get('/your-endpoint')
|
||||
const response = await api.post('/your-endpoint', data)
|
||||
const response = await api.post('/your-endpoint/list', params)
|
||||
const response = await api.post('/your-endpoint/create', data)
|
||||
|
||||
// 401 응답 시 자동 로그아웃 처리 (인터셉터)
|
||||
// 403 응답 시 권한 부족 (requirePermission 미들웨어)
|
||||
```
|
||||
|
||||
---
|
||||
@ -103,13 +161,15 @@ const response = await api.post('/your-endpoint', data)
|
||||
|
||||
```typescript
|
||||
// frontend/src/App.tsx (자동 적용, 수정 불필요)
|
||||
import { API_BASE_URL } from '@common/services/api'
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return
|
||||
const blob = new Blob(
|
||||
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
|
||||
{ type: 'text/plain' }
|
||||
)
|
||||
navigator.sendBeacon('/api/audit/log', blob)
|
||||
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
|
||||
}, [activeMainTab, isAuthenticated])
|
||||
```
|
||||
|
||||
@ -117,12 +177,13 @@ useEffect(() => {
|
||||
특정 작업에 대해 명시적으로 감사 로그를 기록하려면:
|
||||
|
||||
```typescript
|
||||
// 프론트엔드에서 sendBeacon 사용
|
||||
import { API_BASE_URL } from '@common/services/api'
|
||||
|
||||
const blob = new Blob(
|
||||
[JSON.stringify({ action: 'ADMIN_ACTION', detail: '사용자 승인' })],
|
||||
{ type: 'text/plain' }
|
||||
)
|
||||
navigator.sendBeacon('/api/audit/log', blob)
|
||||
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
|
||||
```
|
||||
|
||||
### 감사 로그 테이블 구조 (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/[모듈명]/` 디렉토리 생성
|
||||
2. `[모듈명]Service.ts` — 비즈니스 로직 (DB 쿼리)
|
||||
3. `[모듈명]Router.ts` — Express 라우터 (입력 검증, 에러 처리)
|
||||
3. `[모듈명]Router.ts` — Express 라우터 (CRUD 엔드포인트 + requirePermission)
|
||||
4. `backend/src/server.ts`에 라우터 등록:
|
||||
```typescript
|
||||
import newRouter from './[모듈명]/[모듈명]Router.js'
|
||||
app.use('/api/[경로]', newRouter)
|
||||
```
|
||||
5. DB 테이블 필요 시 `database/auth_init.sql`에 DDL 추가
|
||||
6. 리소스 코드를 `AUTH_PERM_TREE`에 등록 (마이그레이션 SQL)
|
||||
|
||||
### DB 접근
|
||||
```typescript
|
||||
@ -306,20 +418,30 @@ const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1'
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── services/api.ts Axios 인스턴스 + 인터셉터
|
||||
├── services/authApi.ts 인증/사용자/역할/설정/메뉴/감사로그 API
|
||||
├── store/authStore.ts 인증 상태 (Zustand)
|
||||
├── store/menuStore.ts 메뉴 상태 (Zustand)
|
||||
└── App.tsx 탭 라우팅 + 감사 로그 자동 기록
|
||||
├── common/
|
||||
│ ├── services/api.ts Axios 인스턴스 + API_BASE_URL + 인터셉터
|
||||
│ ├── services/authApi.ts 인증/사용자/역할/설정/메뉴/감사로그 API
|
||||
│ ├── store/authStore.ts 인증 상태 + hasPermission (Zustand)
|
||||
│ ├── store/menuStore.ts 메뉴 상태 (Zustand)
|
||||
│ └── hooks/ useSubMenu, useFeatureTracking 등
|
||||
├── tabs/ 탭별 패키지 (11개)
|
||||
└── App.tsx 탭 라우팅 + 감사 로그 자동 기록
|
||||
|
||||
backend/src/
|
||||
├── auth/ 인증 (JWT, OAuth, 미들웨어)
|
||||
├── auth/ 인증 (JWT, OAuth, 미들웨어, requirePermission)
|
||||
├── users/ 사용자 관리
|
||||
├── roles/ 역할/권한 관리
|
||||
├── roles/ 역할/권한 관리 (permResolver, roleService)
|
||||
├── settings/ 시스템 설정
|
||||
├── menus/ 메뉴 설정
|
||||
├── audit/ 감사 로그
|
||||
├── db/ DB 연결 (authDb, database)
|
||||
├── db/ DB 연결 (authDb, wingDb)
|
||||
├── middleware/ 보안 미들웨어
|
||||
└── 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 { registerMainTabSwitcher } from '@common/hooks/useSubMenu'
|
||||
import { useAuthStore } from '@common/store/authStore'
|
||||
import { API_BASE_URL } from '@common/services/api'
|
||||
import { useMenuStore } from '@common/store/menuStore'
|
||||
import { OilSpillView } from '@tabs/prediction'
|
||||
import { ReportsView } from '@tabs/reports'
|
||||
@ -46,7 +47,7 @@ function App() {
|
||||
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
|
||||
{ type: 'text/plain' }
|
||||
)
|
||||
navigator.sendBeacon('/api/audit/log', blob)
|
||||
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
|
||||
}, [activeMainTab, isAuthenticated])
|
||||
|
||||
// 세션 확인 중 스플래시
|
||||
|
||||
@ -1,22 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
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를 기록한다.
|
||||
*
|
||||
* @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);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
if (!isAuthenticated || !featureId) return;
|
||||
const blob = new Blob(
|
||||
[JSON.stringify({ action: 'SUBTAB_VIEW', detail: featureId })],
|
||||
{ type: 'text/plain' },
|
||||
);
|
||||
navigator.sendBeacon('/api/audit/log', blob);
|
||||
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob);
|
||||
}, [featureId, isAuthenticated]);
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { MainTab } from '../types/navigation'
|
||||
import { useAuthStore } from '@common/store/authStore'
|
||||
import { API_BASE_URL } from '@common/services/api'
|
||||
|
||||
interface SubMenuItem {
|
||||
id: string
|
||||
@ -91,6 +93,8 @@ function subscribe(listener: () => void) {
|
||||
|
||||
export function useSubMenu(mainTab: MainTab) {
|
||||
const [activeSubTab, setActiveSubTabLocal] = useState(subMenuState[mainTab])
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribe(() => {
|
||||
@ -103,10 +107,27 @@ export function useSubMenu(mainTab: MainTab) {
|
||||
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 {
|
||||
activeSubTab,
|
||||
setActiveSubTab,
|
||||
subMenuConfig: subMenuConfigs[mainTab]
|
||||
subMenuConfig: filteredConfig,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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({
|
||||
baseURL: API_BASE_URL,
|
||||
|
||||
@ -7,7 +7,7 @@ export interface AuthUser {
|
||||
rank: string | null
|
||||
org: { sn: number; name: string; abbr: string } | null
|
||||
roles: string[]
|
||||
permissions: string[]
|
||||
permissions: Record<string, string[]>
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
@ -117,6 +117,7 @@ export interface RoleWithPermissions {
|
||||
permissions: Array<{
|
||||
sn: number
|
||||
resourceCode: string
|
||||
operationCode: string
|
||||
granted: boolean
|
||||
}>
|
||||
}
|
||||
@ -126,9 +127,26 @@ export async function fetchRoles(): Promise<RoleWithPermissions[]> {
|
||||
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(
|
||||
roleSn: number,
|
||||
permissions: Array<{ resourceCode: string; granted: boolean }>
|
||||
permissions: Array<{ resourceCode: string; operationCode: string; granted: boolean }>
|
||||
): Promise<void> {
|
||||
await api.put(`/roles/${roleSn}/permissions`, { permissions })
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ interface AuthState {
|
||||
googleLogin: (credential: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
checkSession: () => Promise<void>
|
||||
hasPermission: (resource: string) => boolean
|
||||
hasPermission: (resource: string, operation?: string) => boolean
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
@ -70,10 +70,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
hasPermission: (resource: string) => {
|
||||
hasPermission: (resource: string, operation?: string) => {
|
||||
const { user } = get()
|
||||
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 }),
|
||||
|
||||
@ -1,18 +1,251 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
fetchRoles,
|
||||
fetchPermTree,
|
||||
updatePermissionsApi,
|
||||
createRoleApi,
|
||||
updateRoleApi,
|
||||
deleteRoleApi,
|
||||
updateRoleDefaultApi,
|
||||
type RoleWithPermissions,
|
||||
type PermTreeNode,
|
||||
} 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() {
|
||||
const [roles, setRoles] = useState<RoleWithPermissions[]>([])
|
||||
const [permTree, setPermTree] = useState<PermTreeNode[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
@ -24,68 +257,118 @@ function PermissionsPanel() {
|
||||
const [createError, setCreateError] = useState('')
|
||||
const [editingRoleSn, setEditingRoleSn] = useState<number | null>(null)
|
||||
const [editRoleName, setEditRoleName] = useState('')
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const [selectedRoleSn, setSelectedRoleSn] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadRoles()
|
||||
}, [])
|
||||
// 역할별 명시적 권한: Map<roleSn, Map<"rsrc::oper", boolean>>
|
||||
const [rolePerms, setRolePerms] = useState<Map<number, Map<string, boolean>>>(new Map())
|
||||
|
||||
const loadRoles = async () => {
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchRoles()
|
||||
setRoles(data)
|
||||
const [rolesData, treeData] = await Promise.all([fetchRoles(), fetchPermTree()])
|
||||
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)
|
||||
} catch (err) {
|
||||
console.error('역할 목록 조회 실패:', err)
|
||||
console.error('권한 데이터 조회 실패:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 초기 마운트 시 1회만 실행
|
||||
}, [])
|
||||
|
||||
const getPermGranted = (roleSn: number, resourceCode: string): boolean => {
|
||||
const role = roles.find(r => r.sn === roleSn)
|
||||
if (!role) return false
|
||||
const perm = role.permissions.find(p => p.resourceCode === resourceCode)
|
||||
return perm?.granted ?? false
|
||||
}
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
const togglePerm = (roleSn: number, resourceCode: string) => {
|
||||
setRoles(prev => prev.map(role => {
|
||||
if (role.sn !== roleSn) return role
|
||||
const perms = role.permissions.map(p =>
|
||||
p.resourceCode === resourceCode ? { ...p, granted: !p.granted } : p
|
||||
)
|
||||
if (!perms.find(p => p.resourceCode === resourceCode)) {
|
||||
perms.push({ sn: 0, resourceCode, granted: true })
|
||||
// 플랫 노드 목록
|
||||
const flatNodes = flattenTree(permTree)
|
||||
|
||||
// 선택된 역할의 effective state 계산
|
||||
const currentStateMap = selectedRoleSn
|
||||
? buildEffectiveStates(flatNodes, rolePerms.get(selectedRoleSn) ?? new Map())
|
||||
: new Map<string, PermState>()
|
||||
|
||||
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) => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
next.set(selectedRoleSn, roleMap)
|
||||
return next
|
||||
})
|
||||
setDirty(true)
|
||||
}, [selectedRoleSn, flatNodes])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
for (const role of roles) {
|
||||
const permissions = PERM_RESOURCES.map(r => ({
|
||||
resourceCode: r.id,
|
||||
granted: getPermGranted(role.sn, r.id),
|
||||
}))
|
||||
await updatePermissionsApi(role.sn, permissions)
|
||||
const perms = rolePerms.get(role.sn)
|
||||
if (!perms) continue
|
||||
|
||||
const permsList: Array<{ resourceCode: string; operationCode: string; granted: boolean }> = []
|
||||
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)
|
||||
} catch (err) {
|
||||
@ -100,7 +383,7 @@ function PermissionsPanel() {
|
||||
setCreateError('')
|
||||
try {
|
||||
await createRoleApi({ code: newRoleCode, name: newRoleName, description: newRoleDesc || undefined })
|
||||
await loadRoles()
|
||||
await loadData()
|
||||
setShowCreateForm(false)
|
||||
setNewRoleCode('')
|
||||
setNewRoleName('')
|
||||
@ -119,7 +402,8 @@ function PermissionsPanel() {
|
||||
}
|
||||
try {
|
||||
await deleteRoleApi(roleSn)
|
||||
await loadRoles()
|
||||
if (selectedRoleSn === roleSn) setSelectedRoleSn(null)
|
||||
await loadData()
|
||||
} catch (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) {
|
||||
return <div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">불러오는 중...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div>
|
||||
<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 className="flex items-center gap-2">
|
||||
<button
|
||||
@ -173,92 +472,130 @@ function PermissionsPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-1">
|
||||
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean min-w-[200px]">기능</th>
|
||||
{roles.map((role, idx) => {
|
||||
const color = getRoleColor(role.code, idx)
|
||||
return (
|
||||
<th key={role.sn} className="px-4 py-3 text-center min-w-[100px]">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{editingRoleSn === role.sn ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editRoleName}
|
||||
onChange={(e) => setEditRoleName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveRoleName(role.sn)
|
||||
if (e.key === 'Escape') setEditingRoleSn(null)
|
||||
}}
|
||||
onBlur={() => handleSaveRoleName(role.sn)}
|
||||
autoFocus
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-[11px] font-semibold font-korean cursor-pointer hover:underline"
|
||||
style={{ color }}
|
||||
onClick={() => handleStartEditName(role)}
|
||||
title="클릭하여 이름 수정"
|
||||
>
|
||||
{role.name}
|
||||
</span>
|
||||
)}
|
||||
{role.code !== 'ADMIN' && (
|
||||
<button
|
||||
onClick={() => handleDeleteRole(role.sn, role.name)}
|
||||
className="w-4 h-4 flex items-center justify-center text-text-3 hover:text-red-400 transition-colors"
|
||||
title="역할 삭제"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[9px] text-text-3 font-mono mt-0.5">{role.code}</div>
|
||||
{/* 역할 탭 바 */}
|
||||
<div className="flex items-center gap-2 px-6 py-3 border-b border-border bg-bg-1 overflow-x-auto" style={{ flexShrink: 0 }}>
|
||||
{roles.map((role, idx) => {
|
||||
const color = getRoleColor(role.code, idx)
|
||||
const isSelected = selectedRoleSn === role.sn
|
||||
return (
|
||||
<div key={role.sn} className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setSelectedRoleSn(role.sn)}
|
||||
className={`px-3 py-1.5 text-xs font-semibold rounded-md transition-all font-korean ${
|
||||
isSelected
|
||||
? 'border-2 shadow-[0_0_8px_rgba(6,182,212,0.2)]'
|
||||
: 'border border-border text-text-3 hover:border-border'
|
||||
}`}
|
||||
style={isSelected ? { borderColor: color, color } : undefined}
|
||||
>
|
||||
{editingRoleSn === role.sn ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editRoleName}
|
||||
onChange={(e) => setEditRoleName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveRoleName(role.sn)
|
||||
if (e.key === 'Escape') setEditingRoleSn(null)
|
||||
}}
|
||||
onBlur={() => handleSaveRoleName(role.sn)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
<span onDoubleClick={() => handleStartEditName(role)}>
|
||||
{role.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-1 text-[9px] font-mono opacity-50">{role.code}</span>
|
||||
{role.isDefault && <span className="ml-1 text-[9px] text-primary-cyan">기본</span>}
|
||||
</button>
|
||||
{isSelected && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => toggleDefault(role.sn)}
|
||||
className={`px-1.5 py-0.5 text-[9px] rounded transition-all font-korean ${
|
||||
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
|
||||
onClick={() => toggleDefault(role.sn)}
|
||||
className={`mt-1 px-1.5 py-0.5 text-[9px] rounded transition-all font-korean ${
|
||||
role.isDefault
|
||||
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan'
|
||||
: 'text-text-3 border border-transparent hover:border-border'
|
||||
}`}
|
||||
title="신규 사용자에게 자동 할당되는 기본 역할"
|
||||
onClick={() => handleDeleteRole(role.sn, role.name)}
|
||||
className="w-5 h-5 flex items-center justify-center text-text-3 hover:text-red-400 transition-colors"
|
||||
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>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{permTree.map(rootNode => (
|
||||
<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 && (
|
||||
|
||||
@ -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' },
|
||||
}
|
||||
|
||||
export const PERM_RESOURCES = [
|
||||
{ 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: '시스템 관리 기능 접근' },
|
||||
]
|
||||
// PERM_RESOURCES 제거됨 — GET /api/roles/perm-tree에서 동적 로드 (PermissionsPanel)
|
||||
|
||||
@ -4,12 +4,16 @@ import AssetManagement from './AssetManagement'
|
||||
import AssetUpload from './AssetUpload'
|
||||
import AssetTheory from './AssetTheory'
|
||||
import ShipInsurance from './ShipInsurance'
|
||||
import { useFeatureTracking } from '@common/hooks/useFeatureTracking'
|
||||
|
||||
// ── Main AssetsView ──
|
||||
|
||||
export function AssetsView() {
|
||||
const [activeTab, setActiveTab] = useState<AssetsTab>('management')
|
||||
|
||||
// 내부 탭 전환 시 자동 감사 로그
|
||||
useFeatureTracking(`assets:${activeTab}`)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-bg-0">
|
||||
{/* Tab Navigation */}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user