Files
johngreen 824a3100ce security(멀티테넌시): 관리 plane vs 테넌트 plane 격리 + 부서관리 후속
이번 PR 은 invyone 멀티테넌시 SaaS 의 "관리 plane vs 테넌트 plane" 격리를
4 영역(PR #A~D) 에서 강화하고, 별도로 진행 중이던 부서관리 후속 작업을 포함한다.

# 보안 (plane 격리)

PR #A — controller/CompanyManagementController 인증 누락 패치
  /api/company-management/* 가 JWT/role/host 체크 없이 외부에서 누구나 회사 삭제
  + 디스크 통계 호출 가능했던 critical 누수 막음. SuperAdminGuard.enforce() 적용.

PR #C — cross-tenant 컨트롤러 호스트 격리 + 감사 로그
  CrossTenantContext.requireManagementHost() 헬퍼 추가, 5 컨트롤러
  (CrossTenantContext/Controller/UserController/RoleController/DeptController) 모두
  테넌트 호스트에서 호출 시 403. CompanyAuditLogService 에 cross-tenant write 4종
  (USER_CREATE/DELETE, PW_RESET, ROLE_UPDATE) audit action 추가.
  SuperAdminGuard.isTenantHost 가시성 public static 으로 승격.

PR #B — 프론트 솔루션 전용 admin 페이지 가드
  admin/* 페이지 전수 분류 결과 솔루션 전용 3건 식별:
  subdomainList / companyList / audit-log. 각 페이지에 isManagementHost
  useEffect 가드 + redirect 추가. 사이드바도 같이 숨김.

PR #D — MENU_INFO.IS_SOLUTION_ONLY 컬럼 + DB-driven 메뉴 필터
  V023 마이그레이션으로 컬럼 추가 + 솔루션 메뉴 3개 마킹.
  admin.xml selectUserMenuList 에 호스트 기반 필터 추가, AdminController.getUserMenus
  가 Host 헤더로 is_management_host 결정. 프론트 MANAGEMENT_ONLY_MENU_URLS
  하드코딩 set 폐기 (DB 가 대신함). 페이지 자체 가드는 defense in depth 로 유지.
  StartupSchemaMigrator 에 V023 등록되어 모든 테넌트 DB 부팅 시 자동 적용.

# 부서관리 후속 (이전 PR #18/#19 follow-up)

DepartmentController/Service + frontend deptMngList/department.ts 의 추가 작업분.
이번 격리 작업과 무관하지만 같이 정리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:59:15 +09:00

278 lines
8.4 KiB
TypeScript

/**
* 부서 관리 API 클라이언트
*/
import { apiClient } from "./client";
import { Department, DepartmentMember, DepartmentFormData } from "@/types/department";
/**
* 부서 목록 조회 (회사별).
* options.includeDeleted=true 시 soft-delete 된 부서도 포함.
* options.baseDate (YYYY-MM-DD) 가 있으면 해당 시점에 active 한 부서만 반환.
*/
export async function getDepartments(
companyCode: string,
options?: { includeDeleted?: boolean; baseDate?: string },
) {
try {
const url = `/departments/companies/${companyCode}/departments`;
const params: Record<string, any> = {};
if (options?.includeDeleted) params.include_deleted = true;
if (options?.baseDate) params.base_date = options.baseDate;
const response = await apiClient.get<{ success: boolean; data: Department[] }>(url, {
params: Object.keys(params).length > 0 ? params : undefined,
});
return response.data;
} catch (error: any) {
console.error("부서 목록 조회 실패:", error);
return { success: false, error: error.message };
}
}
/**
* 부서 상세 조회
*/
export async function getDepartment(deptCode: string) {
try {
const response = await apiClient.get<{ success: boolean; data: Department }>(`/departments/${deptCode}`);
return response.data;
} catch (error: any) {
console.error("부서 상세 조회 실패:", error);
return { success: false, error: error.message };
}
}
/**
* 부서 생성
*/
export async function createDepartment(companyCode: string, data: DepartmentFormData) {
try {
const response = await apiClient.post<{ success: boolean; data: Department }>(
`/departments/companies/${companyCode}/departments`,
data,
);
return response.data;
} catch (error: any) {
console.error("부서 생성 실패:", error);
const isDuplicate = error.response?.status === 409;
return {
success: false,
error: error.response?.data?.message || error.message,
isDuplicate,
};
}
}
/**
* 부서 수정
*/
export async function updateDepartment(deptCode: string, data: DepartmentFormData) {
try {
const response = await apiClient.put<{ success: boolean; data: Department }>(`/departments/${deptCode}`, data);
return response.data;
} catch (error: any) {
console.error("부서 수정 실패:", error);
return { success: false, error: error.message };
}
}
/**
* 부서 삭제 (V1: soft-delete).
* 응답 호환: 기존 { success, message } 에 data.soft_deleted=true 필드 추가.
*/
export async function deleteDepartment(deptCode: string) {
try {
const response = await apiClient.delete<{
success: boolean;
message?: string;
data?: { soft_deleted?: boolean; dept_code?: string };
}>(`/departments/${deptCode}`);
return response.data;
} catch (error: any) {
console.error("부서 삭제 실패:", error);
return { success: false, error: error.message };
}
}
/**
* 부서 복구 (V1 신규 — soft-delete 된 부서 되살리기).
* 부모가 deleted 면 차단 (400) → "상위 부서를 먼저 복구해주세요" 메시지.
*/
export async function restoreDepartment(deptCode: string) {
try {
const response = await apiClient.post<{
success: boolean;
message?: string;
data?: { dept_code?: string; restored?: boolean };
}>(`/departments/${deptCode}/restore`);
return response.data;
} catch (error: any) {
console.error("부서 복구 실패:", error);
return {
success: false,
error: error.response?.data?.message || error.message,
};
}
}
/**
* 부서원 목록 조회
*/
export async function getDepartmentMembers(deptCode: string) {
try {
const response = await apiClient.get<{ success: boolean; data: DepartmentMember[] }>(
`/departments/${deptCode}/members`,
);
return response.data;
} catch (error: any) {
console.error("부서원 목록 조회 실패:", error);
return { success: false, error: error.message };
}
}
/**
* 사용자 검색 (부서원 추가용)
*/
export async function searchUsers(companyCode: string, search: string) {
try {
const response = await apiClient.get<{ success: boolean; data: any[] }>(
`/departments/companies/${companyCode}/users/search`,
{ params: { search } },
);
return response.data;
} catch (error: any) {
console.error("사용자 검색 실패:", error);
return { success: false, error: error.message };
}
}
/**
* 부서원 추가
*/
export async function addDepartmentMember(deptCode: string, userId: string) {
try {
const response = await apiClient.post<{ success: boolean; message?: string }>(`/departments/${deptCode}/members`, {
user_id: userId,
});
return response.data;
} catch (error: any) {
console.error("부서원 추가 실패:", error);
const isDuplicate = error.response?.status === 409;
return {
success: false,
error: error.response?.data?.message || error.message,
isDuplicate,
};
}
}
/**
* 부서원 제거
*/
export async function removeDepartmentMember(deptCode: string, userId: string) {
try {
const response = await apiClient.delete<{ success: boolean }>(`/departments/${deptCode}/members/${userId}`);
return response.data;
} catch (error: any) {
console.error("부서원 제거 실패:", error);
return { success: false, error: error.message };
}
}
/**
* 주 부서 설정
*/
export async function setPrimaryDepartment(deptCode: string, userId: string) {
try {
const response = await apiClient.put<{ success: boolean }>(`/departments/${deptCode}/members/${userId}/primary`);
return response.data;
} catch (error: any) {
console.error("주 부서 설정 실패:", error);
return { success: false, error: error.message };
}
}
// ──────────────────────────────────────────────────
// 일괄등록 / 일괄업데이트
// ──────────────────────────────────────────────────
export type BulkAction = "create" | "update_department" | "update_manager";
export interface BulkPreviewRow extends Record<string, any> {
row_index: number;
result: "ok" | "error";
error_detail: string | null;
}
/**
* 일괄 미리보기 — read-only validation, write 없음.
* action 에 따라 create/update_department/update_manager 로 검증.
* 응답 rows 각 element 에 result(ok|error), error_detail 채워짐.
*/
export async function bulkPreviewDepartments(
companyCode: string,
action: BulkAction,
rows: Record<string, any>[],
) {
try {
const response = await apiClient.post<{
success: boolean;
data?: { rows: BulkPreviewRow[]; ok_count: number; error_count: number };
message?: string;
}>(`/departments/companies/${companyCode}/departments/bulk/preview`, { action, rows });
return response.data;
} catch (error: any) {
console.error("일괄 미리보기 실패:", error);
return {
success: false,
error: error.response?.data?.message || error.message,
};
}
}
/**
* 일괄등록 적용 (트랜잭션, all-or-nothing).
* rows 는 보통 미리보기에서 ok 인 row 만 보냄.
*/
export async function bulkCreateDepartments(companyCode: string, rows: Record<string, any>[]) {
try {
const response = await apiClient.post<{
success: boolean;
data?: { inserted: number };
message?: string;
}>(`/departments/companies/${companyCode}/departments/bulk/create`, { rows });
return response.data;
} catch (error: any) {
console.error("일괄등록 실패:", error);
return {
success: false,
error: error.response?.data?.message || error.message,
};
}
}
/**
* 일괄업데이트 적용 (트랜잭션). mode = department | manager.
* 각 row 에 dept_code 필수.
*/
export async function bulkUpdateDepartments(
companyCode: string,
mode: "department" | "manager",
rows: Record<string, any>[],
) {
try {
const response = await apiClient.post<{
success: boolean;
data?: { updated: number };
message?: string;
}>(`/departments/companies/${companyCode}/departments/bulk/update`, { mode, rows });
return response.data;
} catch (error: any) {
console.error("일괄업데이트 실패:", error);
return {
success: false,
error: error.response?.data?.message || error.message,
};
}
}