wing-ops/backend/src/roles/roleService.ts
htlee 8657190578 feat(auth): RBAC 오퍼레이션 기반 2차원 권한 시스템 구현
리소스 가시성(READ/HIDE) 단일 차원에서 리소스 × 오퍼레이션(RCUD) 2차원
권한 모델로 전환하여 세밀한 CRUD 권한 제어 지원.

- DB: AUTH_PERM에 OPER_CD 컬럼 추가, 마이그레이션 004 작성
- DB: AUTH_PERM_TREE 리소스 트리 테이블 추가 (마이그레이션 003)
- Backend: permResolver 2차원 권한 해석 엔진 (상속 + 오퍼레이션)
- Backend: requirePermission 미들웨어 신규 (리소스×오퍼레이션 검증)
- Backend: authService permissions → Record<string, string[]> 반환
- Backend: roleService/roleRouter OPER_CD 지원 API
- Backend: Helmet CORP 설정 (sendBeacon cross-origin 허용)
- Frontend: authStore.hasPermission(resource, operation?) 하위 호환 확장
- Frontend: PermissionsPanel 역할탭 + RCUD 4열 매트릭스 UI 전면 재작성
- Frontend: sendBeacon API_BASE_URL 절대경로 전환
- Docs: COMMON-GUIDE 권한 체계 + CRUD API 규칙 문서화

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:55:06 +09:00

232 lines
7.1 KiB
TypeScript

import { authPool } from '../db/authDb.js'
import { AuthError } from '../auth/authService.js'
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
name: string
description: string | null
isDefault: boolean
permissions: Array<{
sn: number
resourceCode: string
operationCode: string
granted: boolean
}>
}
interface CreateRoleInput {
code: string
name: string
description?: string
}
interface UpdateRoleInput {
name?: string
description?: string
}
export async function listRolesWithPermissions(): Promise<RoleWithPermissions[]> {
const rolesResult = await authPool.query(
`SELECT ROLE_SN as sn, ROLE_CD as code, ROLE_NM as name, ROLE_DC as description, DFLT_YN as is_default
FROM AUTH_ROLE ORDER BY ROLE_SN`
)
const roles: RoleWithPermissions[] = []
for (const row of rolesResult.rows) {
const permsResult = await authPool.query(
`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]
)
roles.push({
sn: row.sn,
code: row.code,
name: row.name,
description: row.description,
isDefault: row.is_default === 'Y',
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',
})),
})
}
return roles
}
export async function updateRoleDefault(roleSn: number, isDefault: boolean): Promise<void> {
await authPool.query(
'UPDATE AUTH_ROLE SET DFLT_YN = $1 WHERE ROLE_SN = $2',
[isDefault ? 'Y' : 'N', roleSn]
)
}
export async function createRole(input: CreateRoleInput): Promise<RoleWithPermissions> {
if (!/^[A-Z][A-Z0-9_]{0,18}[A-Z0-9]$/.test(input.code)) {
throw new AuthError('역할 코드는 영문 대문자, 숫자, 언더스코어만 허용되며 2~20자입니다.', 400)
}
const client = await authPool.connect()
try {
await client.query('BEGIN')
const existing = await client.query(
'SELECT 1 FROM AUTH_ROLE WHERE ROLE_CD = $1', [input.code]
)
if (existing.rows.length > 0) {
throw new AuthError('이미 존재하는 역할 코드입니다.', 409)
}
const result = await client.query(
`INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC) VALUES ($1, $2, $3)
RETURNING ROLE_SN as sn, ROLE_CD as code, ROLE_NM as name, ROLE_DC as description, DFLT_YN as is_default`,
[input.code, input.name, input.description || null]
)
const row = result.rows[0]
// 새 역할: level 0 리소스에 READ='N' 초기화
const topLevelCodes = await getTopLevelResourceCodes()
for (const rsrc of topLevelCodes) {
await client.query(
'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, OPER_CD as operation_code, GRANT_YN as granted
FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD, OPER_CD`,
[row.sn]
)
return {
sn: row.sn,
code: row.code,
name: row.name,
description: row.description,
isDefault: row.is_default === 'Y',
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',
})),
}
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release()
}
}
export async function updateRole(roleSn: number, input: UpdateRoleInput): Promise<void> {
const roleResult = await authPool.query(
'SELECT ROLE_CD FROM AUTH_ROLE WHERE ROLE_SN = $1', [roleSn]
)
if (roleResult.rows.length === 0) {
throw new AuthError('역할을 찾을 수 없습니다.', 404)
}
const sets: string[] = []
const params: (string | number | null)[] = []
let idx = 1
if (input.name !== undefined) {
sets.push(`ROLE_NM = $${idx++}`)
params.push(input.name)
}
if (input.description !== undefined) {
sets.push(`ROLE_DC = $${idx++}`)
params.push(input.description)
}
if (sets.length === 0) {
throw new AuthError('수정할 항목이 없습니다.', 400)
}
params.push(roleSn)
await authPool.query(
`UPDATE AUTH_ROLE SET ${sets.join(', ')} WHERE ROLE_SN = $${idx}`,
params
)
}
export async function deleteRole(roleSn: number): Promise<void> {
const roleResult = await authPool.query(
'SELECT ROLE_CD FROM AUTH_ROLE WHERE ROLE_SN = $1', [roleSn]
)
if (roleResult.rows.length === 0) {
throw new AuthError('역할을 찾을 수 없습니다.', 404)
}
if (PROTECTED_ROLE_CODES.includes(roleResult.rows[0].role_cd)) {
throw new AuthError('시스템 보호 역할은 삭제할 수 없습니다.', 403)
}
await authPool.query('DELETE FROM AUTH_ROLE WHERE ROLE_SN = $1', [roleSn])
}
export async function updatePermissions(
roleSn: number,
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 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 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, OPER_CD, GRANT_YN) VALUES ($1, $2, $3, $4)',
[roleSn, perm.resourceCode, perm.operationCode, perm.granted ? 'Y' : 'N']
)
}
}
}