리소스 가시성(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>
232 lines
7.1 KiB
TypeScript
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']
|
|
)
|
|
}
|
|
}
|
|
}
|