refactor(phase4-5): 대형 View 분할 + RBAC 권한 시스템 + DB 통합 + 게시판 CRUD #25

병합
htlee feature/refactor-phase5-view-decomposition 에서 develop 로 4 commits 를 머지했습니다 2026-02-28 18:43:23 +09:00
19개의 변경된 파일1285개의 추가작업 그리고 256개의 파일을 삭제
Showing only changes of commit 8657190578 - Show all commits

파일 보기

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

파일 보기

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

파일 보기

@ -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`로 상태를 관리합니다.
### 권한 모델: 리소스 × 오퍼레이션 (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 */}