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:
htlee 2026-02-28 17:55:06 +09:00
부모 c727afd1ba
커밋 8657190578
19개의 변경된 파일1285개의 추가작업 그리고 256개의 파일을 삭제

파일 보기

@ -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,

파일 보기

@ -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');
-- ============================================================ -- ============================================================

파일 보기

@ -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;

파일 보기

@ -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 */}