fix(부서관리): 보안 + 운영 데이터 버그 8건 (PR #18/#19 후속) #20
@@ -18,6 +18,9 @@ public class DepartmentController {
|
||||
|
||||
private final DepartmentService departmentService;
|
||||
|
||||
private static final java.util.regex.Pattern ISO_DATE_PATTERN =
|
||||
java.util.regex.Pattern.compile("\\d{4}-\\d{2}-\\d{2}");
|
||||
|
||||
/**
|
||||
* 부서 목록 조회 (회사별).
|
||||
* 기본은 active 부서만. ?include_deleted=true 시 soft-delete 된 부서도 포함.
|
||||
@@ -35,6 +38,10 @@ public class DepartmentController {
|
||||
return ResponseEntity.status(403)
|
||||
.body(ApiResponse.error("해당 회사의 부서를 조회할 권한이 없습니다."));
|
||||
}
|
||||
if (baseDate != null && !baseDate.isBlank() && !ISO_DATE_PATTERN.matcher(baseDate).matches()) {
|
||||
return ResponseEntity.status(400)
|
||||
.body(ApiResponse.error("base_date 는 YYYY-MM-DD 형식이어야 합니다."));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted, baseDate);
|
||||
return ResponseEntity.ok(ApiResponse.success(departments, "부서 목록 조회 성공"));
|
||||
|
||||
@@ -226,9 +226,18 @@ public class StartupSchemaMigrator {
|
||||
}
|
||||
|
||||
int ok = 0, fail = 0;
|
||||
List<String> failedDbs = new java.util.ArrayList<>();
|
||||
for (String db : tenantDbs) {
|
||||
if (db == null || db.isBlank() || db.equalsIgnoreCase(metaDb)) continue;
|
||||
if (applyTo(db, "tenant")) ok++; else fail++;
|
||||
if (applyTo(db, "tenant")) {
|
||||
ok++;
|
||||
} else {
|
||||
fail++;
|
||||
failedDbs.add(db);
|
||||
}
|
||||
}
|
||||
if (!failedDbs.isEmpty()) {
|
||||
log.error("[SchemaMigrator] 마이그레이션 실패 테넌트 DB ({}건): {}", failedDbs.size(), failedDbs);
|
||||
}
|
||||
log.info("[SchemaMigrator] done — meta=done, tenants ok={}, fail={}", ok, fail);
|
||||
}
|
||||
|
||||
@@ -150,9 +150,9 @@ public class DepartmentService extends BaseService {
|
||||
insertParams.put("location", nullIfBlank(bodyParam(body, "location", "location")));
|
||||
sqlSession.insert("department.insertDepartment", insertParams);
|
||||
|
||||
syncManagers(deptCode, body, "approval");
|
||||
syncManagers(deptCode, body, "dept");
|
||||
syncManagers(deptCode, body, "org_leader");
|
||||
syncManagers(deptCode, companyCode, body, "approval");
|
||||
syncManagers(deptCode, companyCode, body, "dept");
|
||||
syncManagers(deptCode, companyCode, body, "org_leader");
|
||||
|
||||
log.info("부서 생성 성공: deptCode={}, deptName={}", deptCode, deptName);
|
||||
|
||||
@@ -221,9 +221,9 @@ public class DepartmentService extends BaseService {
|
||||
return null;
|
||||
}
|
||||
|
||||
syncManagers(deptCode, body, "approval");
|
||||
syncManagers(deptCode, body, "dept");
|
||||
syncManagers(deptCode, body, "org_leader");
|
||||
syncManagers(deptCode, deptCompanyCode, body, "approval");
|
||||
syncManagers(deptCode, deptCompanyCode, body, "dept");
|
||||
syncManagers(deptCode, deptCompanyCode, body, "org_leader");
|
||||
|
||||
log.info("부서 수정 성공: deptCode={}", deptCode);
|
||||
return getDepartment(deptCode);
|
||||
@@ -504,18 +504,29 @@ public class DepartmentService extends BaseService {
|
||||
private static final com.fasterxml.jackson.databind.ObjectMapper JSON_MAPPER =
|
||||
new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
|
||||
private static final int MAX_MANAGERS_JSON_BYTES = 64 * 1024;
|
||||
|
||||
private void parseManagersJson(Map<String, Object> dept, String key) {
|
||||
Object raw = dept.get(key);
|
||||
if (raw == null) {
|
||||
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
|
||||
return;
|
||||
}
|
||||
String s = raw.toString();
|
||||
if (s.length() > MAX_MANAGERS_JSON_BYTES) {
|
||||
log.warn("parseManagersJson 크기 초과 dept_code={} key={} len={}",
|
||||
dept.get("dept_code"), key, s.length());
|
||||
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.List<Map<String, Object>> parsed = JSON_MAPPER.readValue(raw.toString(),
|
||||
java.util.List<Map<String, Object>> parsed = JSON_MAPPER.readValue(s,
|
||||
new com.fasterxml.jackson.core.type.TypeReference<java.util.List<Map<String, Object>>>() {});
|
||||
dept.put(key, parsed);
|
||||
} catch (Exception e) {
|
||||
log.warn("parseManagersJson 실패 dept_code={} key={} err={}",
|
||||
dept.get("dept_code"), key, e.getMessage());
|
||||
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
|
||||
}
|
||||
}
|
||||
@@ -526,13 +537,18 @@ public class DepartmentService extends BaseService {
|
||||
* 각 값은 List<Map> 형태이며 각 element 에서 "user_id" 만 추출.
|
||||
* 최대 10명 검증 + 빈 user_id 무시.
|
||||
*/
|
||||
private void syncManagers(String deptCode, Map<String, Object> body, String role) {
|
||||
private void syncManagers(String deptCode, String companyCode, Map<String, Object> body, String role) {
|
||||
String bodyKey = switch (role) {
|
||||
case "approval" -> "approval_managers";
|
||||
case "dept" -> "dept_managers";
|
||||
case "org_leader" -> "org_leaders";
|
||||
default -> throw new IllegalArgumentException("Unknown role: " + role);
|
||||
};
|
||||
// PUT partial update: 키가 명시적으로 존재할 때만 sync.
|
||||
// body 에 키 자체가 없으면 기존 매핑 보존 (partial update 의도).
|
||||
if (!body.containsKey(bodyKey)) {
|
||||
return;
|
||||
}
|
||||
Object raw = body.get(bodyKey);
|
||||
java.util.List<String> userIds = new java.util.ArrayList<>();
|
||||
if (raw instanceof java.util.List<?> list) {
|
||||
@@ -558,6 +574,18 @@ public class DepartmentService extends BaseService {
|
||||
};
|
||||
throw new IllegalArgumentException(roleLabel + " 는 최대 10명까지 등록 가능합니다.");
|
||||
}
|
||||
// user_id 가 같은 회사 (or '*') 에 실존하는지 검증 — cross-tenant 차단
|
||||
if (!userIds.isEmpty()) {
|
||||
Map<String, Object> vParams = new HashMap<>();
|
||||
vParams.put("user_ids", userIds);
|
||||
vParams.put("company_code", companyCode);
|
||||
List<String> validUserIds = sqlSession.selectList("department.selectValidUserIds", vParams);
|
||||
if (validUserIds == null || validUserIds.size() != userIds.size()) {
|
||||
Set<String> invalid = new HashSet<>(userIds);
|
||||
if (validUserIds != null) invalid.removeAll(validUserIds);
|
||||
throw new IllegalArgumentException("유효하지 않은 사용자 ID: " + invalid);
|
||||
}
|
||||
}
|
||||
// delete-all
|
||||
Map<String, Object> delParams = new HashMap<>();
|
||||
delParams.put("dept_code", deptCode);
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
AND D.DELETED_AT IS NULL
|
||||
</if>
|
||||
<if test="base_date != null and base_date != ''">
|
||||
AND D.START_DATE <= #{base_date}::date
|
||||
AND (D.START_DATE IS NULL OR D.START_DATE <= #{base_date}::date)
|
||||
AND (D.END_DATE IS NULL OR D.END_DATE >= #{base_date}::date)
|
||||
</if>
|
||||
GROUP BY
|
||||
@@ -339,4 +339,12 @@
|
||||
</foreach>
|
||||
</insert>
|
||||
|
||||
<!-- 사용자 ID 들이 같은 회사(또는 글로벌 *) 에 실존하는지 검증 — cross-tenant injection 방지 -->
|
||||
<select id="selectValidUserIds" parameterType="map" resultType="string">
|
||||
SELECT USER_ID FROM USER_INFO
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
AND USER_ID IN
|
||||
<foreach collection="user_ids" item="u" open="(" separator="," close=")">#{u}</foreach>
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -313,8 +313,17 @@ export default function DeptMngListPage() {
|
||||
end_date: (dept.end_date ?? "").slice(0, 10),
|
||||
sort_order: dept.sort_order ?? 10,
|
||||
status: (dept.status as "active" | "inactive") ?? "active",
|
||||
approval_managers: ((dept as any).approval_managers || []).map((m: any) => m.user_id).filter(Boolean),
|
||||
dept_managers: ((dept as any).dept_managers || []).map((m: any) => m.user_id).filter(Boolean),
|
||||
approval_managers: (() => {
|
||||
const arr = ((dept as any).approval_managers || []).map((m: any) => m.user_id).filter(Boolean);
|
||||
// 신규 매핑이 비어있고 옛날 단일 컬럼에 값 있으면 seed (PR #19 이전 데이터 호환)
|
||||
if (arr.length === 0 && dept.approval_manager) return [dept.approval_manager];
|
||||
return arr;
|
||||
})(),
|
||||
dept_managers: (() => {
|
||||
const arr = ((dept as any).dept_managers || []).map((m: any) => m.user_id).filter(Boolean);
|
||||
if (arr.length === 0 && dept.dept_manager) return [dept.dept_manager];
|
||||
return arr;
|
||||
})(),
|
||||
org_leaders: ((dept as any).org_leaders || []).map((m: any) => m.user_id).filter(Boolean),
|
||||
};
|
||||
setDraft(loaded);
|
||||
@@ -489,6 +498,14 @@ export default function DeptMngListPage() {
|
||||
toast({ title: "회사를 선택해주세요", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
// 시작일/종료일 정합성 검증
|
||||
if (draft.start_date && draft.end_date && draft.start_date > draft.end_date) {
|
||||
toast({
|
||||
title: "시작일은 종료일보다 빠르거나 같아야 합니다",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 기본정보 탭 전체 필드를 payload 로 전달 — dept_info 스키마와 1:1 (V019 정리 후)
|
||||
const payload = {
|
||||
|
||||
Reference in New Issue
Block a user