wing-ops/backend/src/roles/roleService.ts
htlee b52d8097b0 feat(auth): 역할 CRUD 및 다중 역할 할당 기능 구현
- 역할 생성/수정/삭제 API 추가 (POST/PUT/DELETE /api/roles)
- 권한 관리 패널에 역할 추가/이름수정/삭제 UI 구현
- 사용자 관리 패널에 다중 역할 뱃지 표시 및 역할 할당 드롭다운 추가
- 사용자 활성화/비활성화 상태 변경 버튼 추가
- UserListItem에 roleSns 필드 추가로 역할 SN 기반 할당 지원

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 01:03:21 +09:00

201 lines
5.7 KiB
TypeScript

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
const PROTECTED_ROLE_CODES = ['ADMIN']
interface RoleWithPermissions {
sn: number
code: string
name: string
description: string | null
isDefault: boolean
permissions: Array<{
sn: number
resourceCode: 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, GRANT_YN as granted
FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_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; granted: string }) => ({
sn: p.sn,
resourceCode: p.resource_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]
for (const rsrc of PERM_RESOURCE_CODES) {
await client.query(
'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES ($1, $2, $3)',
[row.sn, rsrc, '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',
[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; granted: string }) => ({
sn: p.sn,
resourceCode: p.resource_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; 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]
)
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]
)
} 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']
)
}
}
}