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>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.provisioning.SuperAdminGuard;
|
||||
import com.erp.service.AdminService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -49,11 +51,15 @@ public class AdminController {
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
@RequestParam Map<String, Object> params,
|
||||
HttpServletRequest request) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("user_type", role);
|
||||
params.put("user_id", userId);
|
||||
params.putIfAbsent("user_lang", "ko");
|
||||
// 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외
|
||||
String host = request.getHeader("Host");
|
||||
params.put("is_management_host", !SuperAdminGuard.isTenantHost(host));
|
||||
return ResponseEntity.ok(ApiResponse.success(adminService.getUserMenuList(params), "사용자 메뉴 목록 조회 성공"));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.provisioning.SuperAdminGuard;
|
||||
import com.erp.service.CompanyManagementService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -16,6 +18,7 @@ import java.util.Map;
|
||||
@Slf4j
|
||||
public class CompanyManagementController {
|
||||
|
||||
private final SuperAdminGuard guard;
|
||||
private final CompanyManagementService companyManagementService;
|
||||
|
||||
/**
|
||||
@@ -24,9 +27,12 @@ public class CompanyManagementController {
|
||||
*/
|
||||
@DeleteMapping("/{companyCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCompany(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String companyCode,
|
||||
@RequestBody(required = false) Map<String, Object> body) {
|
||||
|
||||
guard.enforce(request);
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
if (body != null) {
|
||||
@@ -52,7 +58,11 @@ public class CompanyManagementController {
|
||||
* ※ /{companyCode}/disk-usage 보다 먼저 정의 (경로 특이성으로 충돌 없음)
|
||||
*/
|
||||
@GetMapping("/disk-usage/all")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage() {
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage(
|
||||
HttpServletRequest request) {
|
||||
|
||||
guard.enforce(request);
|
||||
|
||||
try {
|
||||
Map<String, Object> data = companyManagementService.getAllCompaniesDiskUsage();
|
||||
return ResponseEntity.ok(ApiResponse.success(data));
|
||||
@@ -68,7 +78,11 @@ public class CompanyManagementController {
|
||||
*/
|
||||
@GetMapping("/{companyCode}/disk-usage")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyDiskUsage(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String companyCode) {
|
||||
|
||||
guard.enforce(request);
|
||||
|
||||
try {
|
||||
Map<String, Object> data = companyManagementService.getCompanyDiskUsage(companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(data));
|
||||
|
||||
@@ -142,6 +142,135 @@ public class DepartmentController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 미리보기 (read-only validation).
|
||||
* POST /api/departments/companies/{companyCode}/departments/bulk/preview
|
||||
* body: { action: "create"|"update_department"|"update_manager", rows: List<Map> }
|
||||
* response: { rows: [...with row_index/result/error_detail], ok_count, error_count }
|
||||
*/
|
||||
@PostMapping("/companies/{companyCode}/departments/bulk/preview")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkPreview(
|
||||
@PathVariable String companyCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 처리할 권한이 없습니다."));
|
||||
}
|
||||
String action = body.get("action") != null ? body.get("action").toString() : "";
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> rows = body.get("rows") instanceof List
|
||||
? (List<Map<String, Object>>) body.get("rows") : null;
|
||||
if (rows == null) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("rows 가 없습니다."));
|
||||
}
|
||||
try {
|
||||
List<Map<String, Object>> result;
|
||||
switch (action) {
|
||||
case "create":
|
||||
result = departmentService.bulkPreviewCreate(companyCode, rows);
|
||||
break;
|
||||
case "update_department":
|
||||
result = departmentService.bulkPreviewUpdate(companyCode, "department", rows);
|
||||
break;
|
||||
case "update_manager":
|
||||
result = departmentService.bulkPreviewUpdate(companyCode, "manager", rows);
|
||||
break;
|
||||
default:
|
||||
return ResponseEntity.status(400)
|
||||
.body(ApiResponse.error("action 은 create|update_department|update_manager 중 하나."));
|
||||
}
|
||||
int ok = 0, err = 0;
|
||||
for (Map<String, Object> r : result) {
|
||||
if ("ok".equals(r.get("result"))) ok++; else err++;
|
||||
}
|
||||
Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("rows", result);
|
||||
data.put("ok_count", ok);
|
||||
data.put("error_count", err);
|
||||
return ResponseEntity.ok(ApiResponse.success(data, "미리보기 완료"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄등록 적용 (@Transactional, all-or-nothing).
|
||||
* POST /api/departments/companies/{companyCode}/departments/bulk/create
|
||||
* body: { rows: List<Map> } — 클라이언트가 미리보기 결과 중 ok 인 row 만 보내야 함.
|
||||
*/
|
||||
@PostMapping("/companies/{companyCode}/departments/bulk/create")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkCreate(
|
||||
@PathVariable String companyCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 등록할 권한이 없습니다."));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> rows = body.get("rows") instanceof List
|
||||
? (List<Map<String, Object>>) body.get("rows") : null;
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("등록할 데이터가 없습니다."));
|
||||
}
|
||||
try {
|
||||
int inserted = departmentService.bulkSaveCreate(companyCode, rows);
|
||||
Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("inserted", inserted);
|
||||
return ResponseEntity.status(201).body(ApiResponse.success(data, inserted + "건이 등록되었습니다."));
|
||||
} catch (DepartmentService.DuplicateDeptNameException e) {
|
||||
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 적용 (@Transactional). mode = department | manager.
|
||||
* POST /api/departments/companies/{companyCode}/departments/bulk/update
|
||||
* body: { mode: "department"|"manager", rows: List<Map> } — 각 row 에 dept_code 필수.
|
||||
*/
|
||||
@PostMapping("/companies/{companyCode}/departments/bulk/update")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkUpdate(
|
||||
@PathVariable String companyCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 수정할 권한이 없습니다."));
|
||||
}
|
||||
String mode = body.get("mode") != null ? body.get("mode").toString() : "";
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> rows = body.get("rows") instanceof List
|
||||
? (List<Map<String, Object>>) body.get("rows") : null;
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("수정할 데이터가 없습니다."));
|
||||
}
|
||||
try {
|
||||
int updated = departmentService.bulkUpdate(companyCode, mode, rows);
|
||||
Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("updated", updated);
|
||||
return ResponseEntity.ok(ApiResponse.success(data, updated + "건이 수정되었습니다."));
|
||||
} catch (DepartmentService.DuplicateDeptNameException e) {
|
||||
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 삭제 (soft-delete, V1 slim scope).
|
||||
* - 기존 hard-delete → DELETED_AT = NOW() 마킹으로 변경
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.erp.crosstenant;
|
||||
|
||||
import com.erp.tenant.DbContextHolder;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* Cross-tenant 어드민 엔드포인트 진입 가드.
|
||||
@@ -42,4 +44,16 @@ public final class CrossTenantContext {
|
||||
public static boolean isMetaContext() {
|
||||
return DbContextHolder.isMeta();
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리 호스트(solution.invyone.com / admin.invyone.com / localhost / 베이스 도메인) 외엔 거절.
|
||||
* cross-tenant 작업은 plane 격리상 관리 호스트에서만 허용. SuperAdminGuard.isTenantHost 와 동일 규칙.
|
||||
*/
|
||||
public static void requireManagementHost(HttpServletRequest request) {
|
||||
String host = request.getHeader("Host");
|
||||
if (com.erp.provisioning.SuperAdminGuard.isTenantHost(host)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"Cross-tenant operations are only available on the management site");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,12 @@ public class CrossTenantController {
|
||||
*/
|
||||
@GetMapping("/_active-companies")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> activeCompaniesSmoke(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -92,6 +98,12 @@ public class CrossTenantController {
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> listUsers(
|
||||
HttpServletRequest request,
|
||||
@RequestParam Map<String, Object> queryParams) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -173,6 +185,12 @@ public class CrossTenantController {
|
||||
Map<String, Object> queryParams,
|
||||
String mapperId,
|
||||
boolean wrapSearchWithPercent) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
|
||||
@@ -39,6 +39,12 @@ public class CrossTenantDeptController {
|
||||
public ResponseEntity<Map<String, Object>> listDepartments(
|
||||
HttpServletRequest request,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(errorBody(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(errorBody("super_admin_required", request.getRequestURI()));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.erp.crosstenant;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.provisioning.CompanyAuditLogService;
|
||||
import com.erp.service.RoleService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -33,6 +34,7 @@ public class CrossTenantRoleController {
|
||||
|
||||
private final CrossTenantExecutor executor;
|
||||
private final RoleService roleService;
|
||||
private final CompanyAuditLogService auditLogService;
|
||||
|
||||
// ── 권한 그룹 CRUD ──────────────────────────────────────────────
|
||||
|
||||
@@ -49,6 +51,7 @@ public class CrossTenantRoleController {
|
||||
if (g != null) return g;
|
||||
|
||||
String targetCompany = (String) body.get("company_code");
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
|
||||
Map<String, Object> params = new HashMap<>(body);
|
||||
@@ -62,6 +65,10 @@ public class CrossTenantRoleController {
|
||||
}
|
||||
return roleService.createRoleGroup(params);
|
||||
});
|
||||
auditLogService.log(targetCompany, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
|
||||
(String) body.get("auth_code"),
|
||||
auditDetails(request, null));
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(result, "권한 그룹 생성 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
@@ -81,6 +88,7 @@ public class CrossTenantRoleController {
|
||||
if (g != null) return g;
|
||||
|
||||
String targetCompany = (String) body.get("company_code");
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
|
||||
Map<String, Object> params = new HashMap<>(body);
|
||||
@@ -94,6 +102,10 @@ public class CrossTenantRoleController {
|
||||
}
|
||||
return roleService.updateRoleGroup(params);
|
||||
});
|
||||
auditLogService.log(targetCompany, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
|
||||
id,
|
||||
auditDetails(request, id));
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "권한 그룹 수정 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
@@ -111,12 +123,17 @@ public class CrossTenantRoleController {
|
||||
ResponseEntity<ApiResponse<Void>> g = guardVoid(request);
|
||||
if (g != null) return g;
|
||||
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
executor.runInCompany(companyCode, () -> {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("objid", id);
|
||||
roleService.deleteRoleGroup(p);
|
||||
});
|
||||
auditLogService.log(companyCode, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
|
||||
id,
|
||||
auditDetails(request, id));
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "권한 그룹 삭제 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
@@ -266,6 +283,12 @@ public class CrossTenantRoleController {
|
||||
// ── 가드 헬퍼 (응답 타입별로 3가지 — Map/Void/List) ────────
|
||||
|
||||
private ResponseEntity<ApiResponse<Map<String, Object>>> guardMap(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -278,6 +301,12 @@ public class CrossTenantRoleController {
|
||||
}
|
||||
|
||||
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -290,6 +319,12 @@ public class CrossTenantRoleController {
|
||||
}
|
||||
|
||||
private ResponseEntity<ApiResponse<List<Map<String, Object>>>> guardList(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -301,6 +336,14 @@ public class CrossTenantRoleController {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** audit log details 기본 맵 생성 헬퍼. */
|
||||
private Map<String, Object> auditDetails(HttpServletRequest request, String roleId) {
|
||||
Map<String, Object> d = new HashMap<>();
|
||||
d.put("host", request.getHeader("Host"));
|
||||
if (roleId != null) d.put("role_id", roleId);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** "Y"/"N"/null 정규화 — RoleController 의 동일 헬퍼 미러. */
|
||||
private String asYn(Object raw) {
|
||||
if (raw == null) return null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.erp.crosstenant;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.provisioning.CompanyAuditLogService;
|
||||
import com.erp.service.AdminService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -36,6 +37,7 @@ public class CrossTenantUserController {
|
||||
|
||||
private final CrossTenantExecutor executor;
|
||||
private final AdminService adminService;
|
||||
private final CompanyAuditLogService auditLogService;
|
||||
|
||||
// ── 등록 / 수정 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -51,9 +53,14 @@ public class CrossTenantUserController {
|
||||
if (guard != null) return guard;
|
||||
|
||||
String targetCompanyCode = (String) body.get("company_code");
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
|
||||
() -> adminService.saveUser(body));
|
||||
auditLogService.log(targetCompanyCode, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_USER_CREATE,
|
||||
(String) body.get("user_id"),
|
||||
auditDetails(request, (String) body.get("user_id")));
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "사용자 저장 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
@@ -116,6 +123,7 @@ public class CrossTenantUserController {
|
||||
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
executor.runInCompany(companyCode, () -> {
|
||||
Map<String, Object> existing = adminService.getUserInfo(userId);
|
||||
@@ -124,6 +132,10 @@ public class CrossTenantUserController {
|
||||
}
|
||||
adminService.changeUserStatus(userId, "inactive");
|
||||
});
|
||||
auditLogService.log(companyCode, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_USER_DELETE,
|
||||
userId,
|
||||
auditDetails(request, userId));
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "사용자 삭제 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
||||
@@ -166,9 +178,14 @@ public class CrossTenantUserController {
|
||||
|
||||
String targetCompanyCode = (String) body.get("company_code");
|
||||
String userId = (String) body.get("user_id");
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
executor.runInCompany(targetCompanyCode, () ->
|
||||
adminService.resetUserPassword(userId));
|
||||
auditLogService.log(targetCompanyCode, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_PW_RESET,
|
||||
userId,
|
||||
auditDetails(request, userId));
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
||||
@@ -260,6 +277,12 @@ public class CrossTenantUserController {
|
||||
|
||||
/** Map<String,Object> 응답용 가드 — null 이면 통과, 아니면 그대로 반환. */
|
||||
private ResponseEntity<ApiResponse<Map<String, Object>>> guard(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -273,6 +296,12 @@ public class CrossTenantUserController {
|
||||
|
||||
/** Void 응답용 가드 (제네릭만 다름). */
|
||||
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -283,4 +312,12 @@ public class CrossTenantUserController {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** audit log details 기본 맵 생성 헬퍼. */
|
||||
private Map<String, Object> auditDetails(HttpServletRequest request, String targetUserId) {
|
||||
Map<String, Object> d = new HashMap<>();
|
||||
d.put("host", request.getHeader("Host"));
|
||||
if (targetUserId != null) d.put("target_user_id", targetUserId);
|
||||
return d;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +203,13 @@ public class StartupSchemaMigrator {
|
||||
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)"
|
||||
"CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)",
|
||||
|
||||
// V023 / RUN_089: MENU_INFO 에 IS_SOLUTION_ONLY 컬럼 추가.
|
||||
// 솔루션 관리 호스트(solution.invyone.com 등) 에서만 노출되는 메뉴 플래그.
|
||||
// 테넌트 사이트에선 mapper SQL 단계에서 제외. 메타 DB 는 Flyway V023 으로도 적용되지만
|
||||
// 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
|
||||
"ALTER TABLE MENU_INFO ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
);
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
|
||||
@@ -40,6 +40,12 @@ public class CompanyAuditLogService {
|
||||
public static final String ACTION_PW_RESET = "ADMIN_PASSWORD_RESET";
|
||||
public static final String ACTION_RECOPY = "TEMPLATES_RECOPY";
|
||||
|
||||
// cross-tenant write 감사 액션
|
||||
public static final String ACTION_CT_USER_CREATE = "CROSS_TENANT_USER_CREATE";
|
||||
public static final String ACTION_CT_USER_DELETE = "CROSS_TENANT_USER_DELETE";
|
||||
public static final String ACTION_CT_PW_RESET = "CROSS_TENANT_PASSWORD_RESET";
|
||||
public static final String ACTION_CT_ROLE_UPDATE = "CROSS_TENANT_ROLE_UPDATE";
|
||||
|
||||
private final SqlSession sqlSession;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
|
||||
@@ -5,12 +5,9 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
@@ -40,13 +37,7 @@ public class ProvisioningController {
|
||||
private final ProvisioningRegistry registry;
|
||||
private final SqlSession sqlSession;
|
||||
private final CompanyStatsService statsService;
|
||||
|
||||
/**
|
||||
* 프로덕션 배포 시엔 반드시 true 로. 개발 중엔 JWT 없는 curl 테스트를 허용하기 위해 false 기본.
|
||||
* 환경변수: TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN=true
|
||||
*/
|
||||
@Value("${tenant.provisioning.require-super-admin:false}")
|
||||
private boolean requireSuperAdmin;
|
||||
private final SuperAdminGuard guard;
|
||||
|
||||
@GetMapping("/table-groups")
|
||||
public ResponseEntity<List<Map<String, Object>>> tableGroups(HttpServletRequest request) {
|
||||
@@ -208,23 +199,11 @@ public class ProvisioningController {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 권한 체크
|
||||
//
|
||||
// 현재 `/api/**` 가 permitAll 이라 Controller 레벨에서 수동 검증.
|
||||
// JWT 가 있으면 JwtAuthenticationFilter 가 request.getAttribute("user_type") 세팅.
|
||||
// 개발 모드(requireSuperAdmin=false): JWT 없이도 통과 (curl 테스트용). 단 다른 role 은 차단.
|
||||
// 프로덕션 모드(requireSuperAdmin=true): SUPER_ADMIN 아니면 모두 403.
|
||||
// 권한 체크 — SuperAdminGuard 로 위임 (호스트 격리 + role 검증).
|
||||
// CompanyMgmtController 와 동일한 가드를 공유.
|
||||
// ------------------------------------------------------------------
|
||||
private void enforceSuperAdmin(HttpServletRequest request) {
|
||||
String userType = (String) request.getAttribute("user_type");
|
||||
if ("SUPER_ADMIN".equals(userType)) return;
|
||||
|
||||
if (!requireSuperAdmin && userType == null) {
|
||||
log.warn("[Provisioning] anonymous access allowed in dev mode (set " +
|
||||
"tenant.provisioning.require-super-admin=true in production)");
|
||||
return;
|
||||
}
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "SUPER_ADMIN only");
|
||||
guard.enforce(request);
|
||||
}
|
||||
|
||||
// --- Validation helpers ---
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.erp.provisioning;
|
||||
|
||||
import com.erp.tenant.ReservedSubdomains;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -7,9 +8,14 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* `/api/admin/provisioning/*` 계열 엔드포인트 공통 권한 가드.
|
||||
*
|
||||
* - 호스트 격리: 테넌트 서브도메인(qnc.invyone.com 등)에서 호출하면 무조건 403.
|
||||
* 프로비저닝 plane 은 solution/admin/localhost/베이스 도메인 같은 "관리 호스트" 에서만 동작.
|
||||
* (한 번 SUPER_ADMIN 토큰이 새도 임의의 테넌트 사이트에서는 회사를 만들 수 없게 막음)
|
||||
* - 프로덕션 (tenant.provisioning.require-super-admin=true): SUPER_ADMIN 만 통과
|
||||
* - 개발 (기본값 false): JWT 없어도 통과 (curl 테스트). 다른 role 은 여전히 차단
|
||||
*
|
||||
@@ -19,10 +25,22 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
@Slf4j
|
||||
public class SuperAdminGuard {
|
||||
|
||||
private static final Pattern IPV4 = Pattern.compile("^\\d{1,3}(\\.\\d{1,3}){3}$");
|
||||
|
||||
@Value("${tenant.provisioning.require-super-admin:false}")
|
||||
private boolean requireSuperAdmin;
|
||||
|
||||
public void enforce(HttpServletRequest request) {
|
||||
// 1) 호스트 격리 — role 보다 먼저 체크. 어떤 role 도 테넌트 사이트에서는 통과 못 함.
|
||||
String host = request.getHeader("Host");
|
||||
if (isTenantHost(host)) {
|
||||
log.warn("[ProvisioningGuard] blocked tenant-host call: host={} path={} userType={}",
|
||||
host, request.getRequestURI(), request.getAttribute("user_type"));
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"Provisioning is only available on the management site");
|
||||
}
|
||||
|
||||
// 2) role 체크
|
||||
String userType = (String) request.getAttribute("user_type");
|
||||
if ("SUPER_ADMIN".equals(userType)) return;
|
||||
if (!requireSuperAdmin && userType == null) {
|
||||
@@ -37,4 +55,40 @@ public class SuperAdminGuard {
|
||||
String userId = (String) request.getAttribute("user_id");
|
||||
return userId == null ? "dev-anonymous" : userId;
|
||||
}
|
||||
|
||||
/** 감사 로그에 기록할 호스트 (Host 헤더 그대로, 포트 포함). null safe. */
|
||||
public String requestHost(HttpServletRequest request) {
|
||||
String host = request.getHeader("Host");
|
||||
return host == null ? "" : host;
|
||||
}
|
||||
|
||||
/**
|
||||
* "테넌트 사이트에서 온 요청인지" 판단. SubdomainResolverFilter.extractSubdomain 와 같은 규칙.
|
||||
* - localhost / IP / 베이스 도메인 → false (관리 호스트)
|
||||
* - solution.invyone.com 등 예약어 prefix → false (관리 호스트)
|
||||
* - qnc.invyone.com / test02.invyone.com 같은 실제 테넌트 → true
|
||||
*/
|
||||
public static boolean isTenantHost(String host) {
|
||||
if (host == null || host.isBlank()) return false;
|
||||
|
||||
int colon = host.indexOf(':');
|
||||
if (colon != -1) host = host.substring(0, colon);
|
||||
host = host.toLowerCase();
|
||||
|
||||
if ("localhost".equals(host)) return false;
|
||||
if (IPV4.matcher(host).matches()) return false;
|
||||
|
||||
String[] parts = host.split("\\.");
|
||||
if (parts.length == 2) {
|
||||
// {sub}.localhost (dev)
|
||||
if (!"localhost".equals(parts[1])) return false;
|
||||
String first = parts[0];
|
||||
if (first.isEmpty()) return false;
|
||||
return !ReservedSubdomains.VALUES.contains(first);
|
||||
}
|
||||
if (parts.length < 3) return false;
|
||||
|
||||
String first = parts[0];
|
||||
return !ReservedSubdomains.VALUES.contains(first);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
@@ -365,6 +366,330 @@ public class DepartmentService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 일괄등록 / 일괄업데이트 (Bulk)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
private static final int BULK_MAX_ROWS = 1000;
|
||||
|
||||
/**
|
||||
* 일괄등록 — preview (read-only validation). DB 쓰기 없음.
|
||||
* batch 내 dept_name 중복 + DB active 중복 + parent/날짜/매니저 검증.
|
||||
* 각 row 에 row_index / result(ok|error) / error_detail 채워서 반환.
|
||||
*/
|
||||
public List<Map<String, Object>> bulkPreviewCreate(String companyCode, List<Map<String, Object>> rows) {
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
if (rows == null || rows.isEmpty()) return results;
|
||||
if (rows.size() > BULK_MAX_ROWS) {
|
||||
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다.");
|
||||
}
|
||||
Set<String> existingNames = collectActiveDeptNames(companyCode);
|
||||
Set<String> batchNames = new HashSet<>();
|
||||
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Map<String, Object> input = rows.get(i);
|
||||
Map<String, Object> out = new HashMap<>(input);
|
||||
out.put("row_index", i);
|
||||
String error = validateBulkCreateRow(input, companyCode, existingNames, batchNames);
|
||||
if (error == null) {
|
||||
out.put("result", "ok");
|
||||
out.put("error_detail", null);
|
||||
String dn = trimString(input.get("dept_name"));
|
||||
if (dn != null) batchNames.add(dn.toLowerCase());
|
||||
} else {
|
||||
out.put("result", "error");
|
||||
out.put("error_detail", error);
|
||||
}
|
||||
results.add(out);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 — preview (read-only). mode = department | manager.
|
||||
*/
|
||||
public List<Map<String, Object>> bulkPreviewUpdate(String companyCode, String mode, List<Map<String, Object>> rows) {
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
if (rows == null || rows.isEmpty()) return results;
|
||||
if (rows.size() > BULK_MAX_ROWS) {
|
||||
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다.");
|
||||
}
|
||||
if (!"department".equals(mode) && !"manager".equals(mode)) {
|
||||
throw new IllegalArgumentException("mode 는 department | manager 여야 합니다.");
|
||||
}
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Map<String, Object> input = rows.get(i);
|
||||
Map<String, Object> out = new HashMap<>(input);
|
||||
out.put("row_index", i);
|
||||
String error = validateBulkUpdateRow(input, companyCode, mode);
|
||||
if (error == null) {
|
||||
out.put("result", "ok");
|
||||
out.put("error_detail", null);
|
||||
} else {
|
||||
out.put("result", "error");
|
||||
out.put("error_detail", error);
|
||||
}
|
||||
results.add(out);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄등록 — 실제 저장 (@Transactional, all-or-nothing).
|
||||
* 각 row 를 createDepartment 로 위임 — 검증 + manager sync 까지 동일 흐름.
|
||||
* 중간 실패 시 IllegalArgumentException 으로 행번호+사유 합쳐서 던짐 → 전체 롤백.
|
||||
*/
|
||||
@Transactional
|
||||
public int bulkSaveCreate(String companyCode, List<Map<String, Object>> rows) {
|
||||
if (rows == null || rows.isEmpty()) return 0;
|
||||
if (rows.size() > BULK_MAX_ROWS) {
|
||||
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 등록 가능합니다.");
|
||||
}
|
||||
int inserted = 0;
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Map<String, Object> row = rows.get(i);
|
||||
String label = trimString(row.get("dept_name"));
|
||||
try {
|
||||
createDepartment(companyCode, row);
|
||||
inserted++;
|
||||
} catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + " (" + (label != null ? label : "?") + "): " + e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info("부서 일괄등록 성공: company={}, inserted={}", companyCode, inserted);
|
||||
return inserted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 — 실제 적용 (@Transactional). mode = department | manager.
|
||||
* department: 부서 정보 부분 업데이트 (row 의 null/미지정 필드는 기존값 보존).
|
||||
* manager: row 에 명시된 매니저 role 만 sync (delete-all + insert-all).
|
||||
*/
|
||||
@Transactional
|
||||
public int bulkUpdate(String companyCode, String mode, List<Map<String, Object>> rows) {
|
||||
if (rows == null || rows.isEmpty()) return 0;
|
||||
if (rows.size() > BULK_MAX_ROWS) {
|
||||
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 수정 가능합니다.");
|
||||
}
|
||||
if (!"department".equals(mode) && !"manager".equals(mode)) {
|
||||
throw new IllegalArgumentException("mode 는 department | manager 여야 합니다.");
|
||||
}
|
||||
int updated = 0;
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Map<String, Object> row = rows.get(i);
|
||||
String deptCode = trimString(row.get("dept_code"));
|
||||
if (deptCode == null) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + ": 부서코드(dept_code) 필수.");
|
||||
}
|
||||
Map<String, Object> existing = getDepartment(deptCode);
|
||||
if (existing == null) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + ": 부서를 찾을 수 없습니다: " + deptCode);
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null
|
||||
? existing.get("company_code").toString() : null;
|
||||
if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + ": 다른 회사의 부서입니다: " + deptCode);
|
||||
}
|
||||
try {
|
||||
if ("department".equals(mode)) {
|
||||
Map<String, Object> merged = buildMergedDeptBody(existing, row);
|
||||
Map<String, Object> result = updateDepartment(deptCode, merged);
|
||||
if (result == null) {
|
||||
throw new IllegalStateException("수정 실패: " + deptCode);
|
||||
}
|
||||
} else {
|
||||
// manager mode — row 에 명시된 role 만 sync
|
||||
if (row.containsKey("approval_managers")) {
|
||||
syncManagers(deptCode, companyCode, row, "approval");
|
||||
}
|
||||
if (row.containsKey("dept_managers")) {
|
||||
syncManagers(deptCode, companyCode, row, "dept");
|
||||
}
|
||||
if (row.containsKey("org_leaders")) {
|
||||
syncManagers(deptCode, companyCode, row, "org_leader");
|
||||
}
|
||||
}
|
||||
updated++;
|
||||
} catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + " (" + deptCode + "): " + e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info("부서 일괄수정 성공: company={}, mode={}, updated={}", companyCode, mode, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** company 의 active 부서명 lowercase set — 일괄등록 중복검증용 */
|
||||
private Set<String> collectActiveDeptNames(String companyCode) {
|
||||
Set<String> names = new HashSet<>();
|
||||
for (Map<String, Object> d : getDepartments(companyCode, false, null)) {
|
||||
Object name = d.get("dept_name");
|
||||
if (name != null) names.add(name.toString().trim().toLowerCase());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄등록 row 검증. null = ok. 에러 메시지 반환 시 해당 row 는 error.
|
||||
*/
|
||||
private String validateBulkCreateRow(Map<String, Object> row, String companyCode,
|
||||
Set<String> existingNames, Set<String> batchNames) {
|
||||
String deptName = trimString(row.get("dept_name"));
|
||||
if (deptName == null || deptName.isEmpty()) return "부서명은 필수입니다.";
|
||||
String lower = deptName.toLowerCase();
|
||||
if (batchNames.contains(lower)) return "동일 일괄 내 부서명 중복: " + deptName;
|
||||
if (existingNames.contains(lower)) return "이미 존재하는 부서명: " + deptName;
|
||||
|
||||
String dt = trimString(row.get("dept_type"));
|
||||
if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) {
|
||||
return "부서유형은 dept|team|temp 중 하나: " + dt;
|
||||
}
|
||||
String parent = trimString(row.get("parent_dept_code"));
|
||||
String parentErr = validateParentForBulk(parent, companyCode);
|
||||
if (parentErr != null) return parentErr;
|
||||
|
||||
String dateErr = validateDateRange(row);
|
||||
if (dateErr != null) return dateErr;
|
||||
|
||||
String mgrErr = validateManagerIds(row, companyCode);
|
||||
if (mgrErr != null) return mgrErr;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 row 검증. dept_code 필수 + 회사 격리 + (department mode 한정) 부서명/유형/날짜/부모 검증.
|
||||
*/
|
||||
private String validateBulkUpdateRow(Map<String, Object> row, String companyCode, String mode) {
|
||||
String deptCode = trimString(row.get("dept_code"));
|
||||
if (deptCode == null) return "부서코드(dept_code) 필수.";
|
||||
Map<String, Object> existing = getDepartment(deptCode);
|
||||
if (existing == null) return "부서를 찾을 수 없습니다: " + deptCode;
|
||||
String deptCompanyCode = existing.get("company_code") != null
|
||||
? existing.get("company_code").toString() : null;
|
||||
if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) {
|
||||
return "다른 회사의 부서: " + deptCode;
|
||||
}
|
||||
if ("department".equals(mode)) {
|
||||
String newName = trimString(row.get("dept_name"));
|
||||
if (newName != null && !newName.isEmpty()) {
|
||||
String existingName = existing.get("dept_name") != null
|
||||
? existing.get("dept_name").toString().trim() : "";
|
||||
if (!newName.equalsIgnoreCase(existingName)) {
|
||||
Map<String, Object> dupParams = new HashMap<>();
|
||||
dupParams.put("company_code", companyCode);
|
||||
dupParams.put("dept_name", newName);
|
||||
Map<String, Object> dup = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
|
||||
if (dup != null && !deptCode.equals(dup.get("dept_code"))) {
|
||||
return "이미 존재하는 부서명: " + newName;
|
||||
}
|
||||
}
|
||||
}
|
||||
String dt = trimString(row.get("dept_type"));
|
||||
if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) {
|
||||
return "부서유형은 dept|team|temp 중 하나: " + dt;
|
||||
}
|
||||
String dateErr = validateDateRange(row);
|
||||
if (dateErr != null) return dateErr;
|
||||
String parent = trimString(row.get("parent_dept_code"));
|
||||
String parentErr = validateParentForBulk(parent, companyCode);
|
||||
if (parentErr != null) return parentErr;
|
||||
}
|
||||
return validateManagerIds(row, companyCode);
|
||||
}
|
||||
|
||||
private String validateParentForBulk(String parent, String companyCode) {
|
||||
if (parent == null) return null;
|
||||
Map<String, Object> p = sqlSession.selectOne(
|
||||
"department.selectDepartmentByCodeIncludingDeleted", Map.of("dept_code", parent));
|
||||
if (p == null) return "상위 부서를 찾을 수 없습니다: " + parent;
|
||||
if (p.get("deleted_at") != null) return "삭제된 부서를 상위로 지정할 수 없음: " + parent;
|
||||
Object pc = p.get("company_code");
|
||||
if (pc == null || (!companyCode.equals(pc.toString()) && !"*".equals(pc.toString()))) {
|
||||
return "다른 회사의 부서를 상위로 지정 불가: " + parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String validateDateRange(Map<String, Object> row) {
|
||||
String sd = trimString(row.get("start_date"));
|
||||
String ed = trimString(row.get("end_date"));
|
||||
if (sd != null && !sd.matches("\\d{4}-\\d{2}-\\d{2}")) return "시작일 형식 오류 (YYYY-MM-DD): " + sd;
|
||||
if (ed != null && !ed.matches("\\d{4}-\\d{2}-\\d{2}")) return "종료일 형식 오류 (YYYY-MM-DD): " + ed;
|
||||
if (sd != null && ed != null && sd.compareTo(ed) > 0) return "시작일이 종료일보다 늦을 수 없음.";
|
||||
return null;
|
||||
}
|
||||
|
||||
private String validateManagerIds(Map<String, Object> row, String companyCode) {
|
||||
for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) {
|
||||
Object raw = row.get(key);
|
||||
if (raw instanceof List<?> list && list.size() > 10) {
|
||||
return key + " 는 최대 10명까지 등록 가능합니다.";
|
||||
}
|
||||
}
|
||||
List<String> ids = collectManagerUserIds(row);
|
||||
if (ids.isEmpty()) return null;
|
||||
Map<String, Object> vParams = new HashMap<>();
|
||||
vParams.put("user_ids", ids);
|
||||
vParams.put("company_code", companyCode);
|
||||
List<String> valid = sqlSession.selectList("department.selectValidUserIds", vParams);
|
||||
if (valid == null || valid.size() != ids.size()) {
|
||||
Set<String> invalid = new HashSet<>(ids);
|
||||
if (valid != null) invalid.removeAll(valid);
|
||||
return "유효하지 않은 사용자 ID: " + invalid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<String> collectManagerUserIds(Map<String, Object> row) {
|
||||
List<String> ids = new ArrayList<>();
|
||||
for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) {
|
||||
Object raw = row.get(key);
|
||||
if (raw instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
String uid = null;
|
||||
if (item instanceof Map<?, ?> m) {
|
||||
Object v = m.get("user_id");
|
||||
if (v != null) uid = v.toString().trim();
|
||||
} else if (item != null) {
|
||||
uid = item.toString().trim();
|
||||
}
|
||||
if (uid != null && !uid.isEmpty() && !ids.contains(uid)) ids.add(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 department mode — 기존값 + row override 머지.
|
||||
* row 값이 null/미지정이면 기존값 보존 (PATCH semantic).
|
||||
* 매니저 매핑 키는 항상 제거 (department mode 에서는 안 다룸).
|
||||
*/
|
||||
private Map<String, Object> buildMergedDeptBody(Map<String, Object> existing, Map<String, Object> row) {
|
||||
Map<String, Object> merged = new HashMap<>();
|
||||
String[] textKeys = {
|
||||
"dept_name", "parent_dept_code", "short_name", "dept_type", "org_system",
|
||||
"approval_manager", "dept_manager", "zipcode", "address1", "address2",
|
||||
"sort_order", "status", "location"
|
||||
};
|
||||
for (String k : textKeys) merged.put(k, existing.get(k));
|
||||
merged.put("start_date", stringifyDate(existing.get("start_date")));
|
||||
merged.put("end_date", stringifyDate(existing.get("end_date")));
|
||||
for (Map.Entry<String, Object> e : row.entrySet()) {
|
||||
String k = e.getKey();
|
||||
if ("dept_code".equals(k)) continue;
|
||||
if (e.getValue() == null) continue;
|
||||
if ("approval_managers".equals(k) || "dept_managers".equals(k) || "org_leaders".equals(k)) continue;
|
||||
merged.put(k, e.getValue());
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
private String stringifyDate(Object date) {
|
||||
if (date == null) return null;
|
||||
String s = date.toString();
|
||||
return s.length() >= 10 ? s.substring(0, 10) : null;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 부서원 관리
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
ALTER TABLE MENU_INFO
|
||||
ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN MENU_INFO.IS_SOLUTION_ONLY IS '솔루션 사이트(solution.invyone.com 등 관리 호스트) 에서만 노출되는 메뉴. 테넌트 사이트에선 SQL 단계에서 제외.';
|
||||
|
||||
-- 솔루션 전용 메뉴 마킹
|
||||
UPDATE MENU_INFO SET IS_SOLUTION_ONLY = TRUE
|
||||
WHERE MENU_URL IN (
|
||||
'/admin/sysMng/subdomainList',
|
||||
'/admin/userMng/companyList',
|
||||
'/admin/audit-log'
|
||||
);
|
||||
@@ -187,6 +187,9 @@
|
||||
AND MENU.COMPANY_CODE = #{company_code}
|
||||
</otherwise>
|
||||
</choose>
|
||||
<if test='is_management_host == false'>
|
||||
AND MENU.IS_SOLUTION_ONLY = FALSE
|
||||
</if>
|
||||
|
||||
UNION ALL
|
||||
|
||||
@@ -212,6 +215,9 @@
|
||||
ON S.PARENT_OBJ_ID = V.OBJID
|
||||
WHERE S.OBJID != ALL(V.PATH)
|
||||
AND S.STATUS = 'active'
|
||||
<if test='is_management_host == false'>
|
||||
AND S.IS_SOLUTION_ONLY = FALSE
|
||||
</if>
|
||||
)
|
||||
SELECT
|
||||
V.LEV
|
||||
|
||||
Reference in New Issue
Block a user