From 8657190578d9e4080a0c6ea58a8dc86181851e17 Mon Sep 17 00:00:00 2001 From: htlee Date: Sat, 28 Feb 2026 17:55:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(auth):=20RBAC=20=EC=98=A4=ED=8D=BC?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EA=B8=B0=EB=B0=98=202=EC=B0=A8?= =?UTF-8?q?=EC=9B=90=20=EA=B6=8C=ED=95=9C=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리소스 가시성(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 반환 - 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 --- backend/src/auth/authMiddleware.ts | 42 ++ backend/src/auth/authService.ts | 72 ++- backend/src/roles/permResolver.ts | 197 ++++++ backend/src/roles/roleRouter.ts | 21 +- backend/src/roles/roleService.ts | 71 ++- backend/src/server.ts | 1 + database/auth_init.sql | 87 ++- database/migration/003_perm_tree.sql | 108 ++++ database/migration/004_oper_cd.sql | 55 ++ docs/COMMON-GUIDE.md | 202 ++++-- frontend/src/App.tsx | 3 +- .../src/common/hooks/useFeatureTracking.ts | 12 +- frontend/src/common/hooks/useSubMenu.ts | 23 +- frontend/src/common/services/api.ts | 2 +- frontend/src/common/services/authApi.ts | 22 +- frontend/src/common/store/authStore.ts | 8 +- .../admin/components/PermissionsPanel.tsx | 597 ++++++++++++++---- .../tabs/admin/components/adminConstants.ts | 14 +- .../src/tabs/assets/components/AssetsView.tsx | 4 + 19 files changed, 1285 insertions(+), 256 deletions(-) create mode 100644 backend/src/roles/permResolver.ts create mode 100644 database/migration/003_perm_tree.sql create mode 100644 database/migration/004_oper_cd.sql diff --git a/backend/src/auth/authMiddleware.ts b/backend/src/auth/authMiddleware.ts index 4193f54..0a2fc47 100644 --- a/backend/src/auth/authMiddleware.ts +++ b/backend/src/auth/authMiddleware.ts @@ -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 } } } @@ -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 => { + 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: '권한 확인 중 오류가 발생했습니다.' }) + } + } +} diff --git a/backend/src/auth/authService.ts b/backend/src/auth/authService.ts index 1be0467..0d607b7 100644 --- a/backend/src/auth/authService.ts +++ b/backend/src/auth/authService.ts @@ -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 } export async function login( @@ -127,9 +129,9 @@ export async function getUserInfo(userId: string): Promise { 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 { ) 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 + 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>() + 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, diff --git a/backend/src/roles/permResolver.ts b/backend/src/roles/permResolver.ts new file mode 100644 index 0000000..52dadc7 --- /dev/null +++ b/backend/src/roles/permResolver.ts @@ -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>, +): Set { + const granted = new Set() + + const nodeMap = new Map() + 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, + explicitPerms: Map, +): Set { + const effective = new Map() + + // 레벨 순(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() + for (const [key, value] of effective) { + if (value) granted.add(key) + } + return granted +} + +/** + * 개별 노드 × 오퍼레이션의 effective 값 계산. + */ +function resolveNodeOper( + node: PermTreeNode, + operCd: string, + explicitPerms: Map, + effective: Map, +): 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 변환 (API 반환용). + */ +export function grantedSetToRecord(granted: Set): Record { + const result: Record = {} + 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() + 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 +} diff --git a/backend/src/roles/roleRouter.ts b/backend/src/roles/roleRouter.ts index 0f59ef3..3706dad 100644 --- a/backend/src/roles/roleRouter.ts +++ b/backend/src/roles/roleRouter.ts @@ -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) { diff --git a/backend/src/roles/roleService.ts b/backend/src/roles/roleService.ts index b3b0862..b78aeb1 100644 --- a/backend/src/roles/roleService.ts +++ b/backend/src/roles/roleService.ts @@ -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 { + 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 { + 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 { + 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 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 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 ({ + 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 { export async function updatePermissions( roleSn: number, - permissions: Array<{ resourceCode: string; granted: boolean }> + permissions: Array<{ resourceCode: string; operationCode: string; granted: boolean }> ): Promise { 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'] ) } } diff --git a/backend/src/server.ts b/backend/src/server.ts index d46dbdc..b29e946 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -48,6 +48,7 @@ app.use(helmet({ } }, crossOriginEmbedderPolicy: false, // API 서버이므로 비활성 + crossOriginResourcePolicy: { policy: 'cross-origin' }, // sendBeacon cross-origin 허용 })) // 2. 서버 정보 제거 (공격자에게 기술 스택 노출 방지) diff --git a/database/auth_init.sql b/database/auth_init.sql index d55729b..b99b723 100644 --- a/database/auth_init.sql +++ b/database/auth_init.sql @@ -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'); -- ============================================================ diff --git a/database/migration/003_perm_tree.sql b/database/migration/003_perm_tree.sql new file mode 100644 index 0000000..2688735 --- /dev/null +++ b/database/migration/003_perm_tree.sql @@ -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; diff --git a/database/migration/004_oper_cd.sql b/database/migration/004_oper_cd.sql new file mode 100644 index 0000000..a352a58 --- /dev/null +++ b/database/migration/004_oper_cd.sql @@ -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; diff --git a/docs/COMMON-GUIDE.md b/docs/COMMON-GUIDE.md index b77b5a9..2700234 100644 --- a/docs/COMMON-GUIDE.md +++ b/docs/COMMON-GUIDE.md @@ -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) 추가 ``` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bc5b47b..920ca3e 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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]) // 세션 확인 중 스플래시 diff --git a/frontend/src/common/hooks/useFeatureTracking.ts b/frontend/src/common/hooks/useFeatureTracking.ts index b12b8a9..2aaf674 100644 --- a/frontend/src/common/hooks/useFeatureTracking.ts +++ b/frontend/src/common/hooks/useFeatureTracking.ts @@ -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]); } diff --git a/frontend/src/common/hooks/useSubMenu.ts b/frontend/src/common/hooks/useSubMenu.ts index 9dac4b4..dafecae 100755 --- a/frontend/src/common/hooks/useSubMenu.ts +++ b/frontend/src/common/hooks/useSubMenu.ts @@ -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, } } diff --git a/frontend/src/common/services/api.ts b/frontend/src/common/services/api.ts index 904a372..6d99764 100755 --- a/frontend/src/common/services/api.ts +++ b/frontend/src/common/services/api.ts @@ -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, diff --git a/frontend/src/common/services/authApi.ts b/frontend/src/common/services/authApi.ts index 5f58a77..9fccd40 100644 --- a/frontend/src/common/services/authApi.ts +++ b/frontend/src/common/services/authApi.ts @@ -7,7 +7,7 @@ export interface AuthUser { rank: string | null org: { sn: number; name: string; abbr: string } | null roles: string[] - permissions: string[] + permissions: Record } 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 { 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 { + const response = await api.get('/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 { await api.put(`/roles/${roleSn}/permissions`, { permissions }) } diff --git a/frontend/src/common/store/authStore.ts b/frontend/src/common/store/authStore.ts index 299cdc6..f47a474 100644 --- a/frontend/src/common/store/authStore.ts +++ b/frontend/src/common/store/authStore.ts @@ -12,7 +12,7 @@ interface AuthState { googleLogin: (credential: string) => Promise logout: () => Promise checkSession: () => Promise - hasPermission: (resource: string) => boolean + hasPermission: (resource: string, operation?: string) => boolean clearError: () => void } @@ -70,10 +70,12 @@ export const useAuthStore = create((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 }), diff --git a/frontend/src/tabs/admin/components/PermissionsPanel.tsx b/frontend/src/tabs/admin/components/PermissionsPanel.tsx index 01ea644..e20ccac 100644 --- a/frontend/src/tabs/admin/components/PermissionsPanel.tsx +++ b/frontend/src/tabs/admin/components/PermissionsPanel.tsx @@ -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 = { READ: 'R', CREATE: 'C', UPDATE: 'U', DELETE: 'D' } +const OPER_FULL_LABELS: Record = { 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, + cache: Map, +): 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, +): Map { + const cache = new Map() + 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 ( + + ) +} + +// ─── 트리 행 컴포넌트 ──────────────────────────────── +interface TreeRowProps { + node: PermTreeNode + stateMap: Map + expanded: Set + 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 ( + <> + + +
+ {hasChildren ? ( + + ) : ( + + {node.level > 0 ? '├' : ''} + + )} + {node.icon && {node.icon}} +
+
+ {node.name} +
+ {node.description && node.level === 0 && ( +
{node.description}
+ )} +
+
+ + {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 ( + +
+ onTogglePerm(node.code, oper, effectiveState)} + /> +
+ + ) + })} + + {hasChildren && isExpanded && node.children.map(child => ( + + ))} + + ) +} + +// ─── 메인 PermissionsPanel ────────────────────────── function PermissionsPanel() { const [roles, setRoles] = useState([]) + const [permTree, setPermTree] = useState([]) 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(null) const [editRoleName, setEditRoleName] = useState('') + const [expanded, setExpanded] = useState>(new Set()) + const [selectedRoleSn, setSelectedRoleSn] = useState(null) - useEffect(() => { - loadRoles() - }, []) + // 역할별 명시적 권한: Map> + const [rolePerms, setRolePerms] = useState>>(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>() + for (const role of rolesData) { + const roleMap = new Map() + 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() + + 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
불러오는 중...
} return (
+ {/* 헤더 */}

사용자 권한 관리

-

역할별 메뉴 접근 권한을 설정합니다

+

역할별 리소스 × CRUD 권한을 설정합니다

-
- - - - - {roles.map((role, idx) => { - const color = getRoleColor(role.code, idx) - return ( - + + {permTree.map(rootNode => ( + + ))} + +
기능 -
- {editingRoleSn === role.sn ? ( - 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" - /> - ) : ( - handleStartEditName(role)} - title="클릭하여 이름 수정" - > - {role.name} - - )} - {role.code !== 'ADMIN' && ( - - )} -
-
{role.code}
+ {/* 역할 탭 바 */} +
+ {roles.map((role, idx) => { + const color = getRoleColor(role.code, idx) + const isSelected = selectedRoleSn === role.sn + return ( +
+ + {isSelected && ( +
+ + {role.code !== 'ADMIN' && ( + )} +
+ )} +
+ ) + })} +
+ + {/* 범례 */} +
+ + + 명시적 허용 + + + + 상속 허용 + + + + 명시적 거부 + + + + 강제 거부 + + + R=조회 C=생성 U=수정 D=삭제 + +
+ + {/* CRUD 매트릭스 테이블 */} + {selectedRoleSn ? ( +
+ + + + + {OPER_CODES.map(oper => ( + - ) - })} - - - - {PERM_RESOURCES.map((perm) => ( - - - {roles.map(role => ( - ))} - ))} - -
기능 +
{OPER_LABELS[oper]}
+
{OPER_FULL_LABELS[oper]}
-
{perm.label}
-
{perm.desc}
-
- -
-
+
+
+ ) : ( +
+ 역할을 선택하세요 +
+ )} {/* 역할 생성 모달 */} {showCreateForm && ( diff --git a/frontend/src/tabs/admin/components/adminConstants.ts b/frontend/src/tabs/admin/components/adminConstants.ts index b29f8b4..b27ed50 100644 --- a/frontend/src/tabs/admin/components/adminConstants.ts +++ b/frontend/src/tabs/admin/components/adminConstants.ts @@ -21,16 +21,4 @@ export const statusLabels: Record('management') + // 내부 탭 전환 시 자동 감사 로그 + useFeatureTracking(`assets:${activeTab}`) + return (
{/* Tab Navigation */}