fix(테이블관리): 블로커 5건 일괄 수정 (PR-A)

종합 감사 결과 발견된 블로커 5건:

1. AddColumnModal / CreateTableModal prop 미스매치 — 호출부 tableName/sourceTableName(camelCase)
   vs 인터페이스 table_name/source_table_name(snake_case). 결과적으로 컬럼 추가 기능이 실제로
   동작하지 않았음 (/api/ddl/tables/undefined/columns). 호출부를 인터페이스에 맞춰 통일.

2. IS_NULLABLE 강제 'Y' 덮어쓰기 (backend mapper) — upsertColumnSettings 의 VALUES 절이
   모든 케이스에서 'Y' 리터럴을 박음. NN 토글 후 "컬럼 설정 저장" 누르면 필수 입력 제약이
   조용히 사라짐. COALESCE(#{is_nullable}, 'Y') 로 변경 + ON CONFLICT 절에 IS_NULLABLE
   COALESCE 추가. Service 도 settings 에서 is_nullable 을 읽어 'Y'/'N' 으로 정규화 후 전달.

3. 저장 안 한 편집 침묵 손실 — 우측 패널에서 편집 후 저장 안 누르고 다른 테이블로 이동 시
   변경 사항 소실. hasUnsavedChanges memo 추가 (columns vs originalColumns JSON 비교).
   handleTableSelect 에서 dirty 면 confirm 다이얼로그.

4. PK / NN / IDX / UQ 위치별 비대칭 저장 — 그리드 행 칩은 즉시 저장하는데 우측 상세 패널
   토글은 메모리만 변경하고 저장 버튼 필요. 두 위치 모두 즉시 저장으로 통일 (상세 패널의
   onColumnChange 가 is_nullable / is_unique 필드를 받으면 handleNullableToggle /
   handleUniqueToggle 호출하도록).

5. SUPER_ADMIN role 검증 누락 (backend 보안) — DdlController 의 isSuperAdmin 이 company_code
   == "*" 만 보고 role 클레임 무시. 토큰 변조 또는 "*" 회사 소속만으로 운영 DB DROP / ALTER
   가능. company_code "*" AND role "SUPER_ADMIN" 둘 다 충족 시에만 통과로 변경. 11개 엔드포인트
   에 @RequestAttribute("role") 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 14:43:15 +09:00
parent 99487049fb
commit f530b3cf31
4 changed files with 83 additions and 20 deletions
@@ -29,10 +29,11 @@ public class DdlController {
@PostMapping("/tables")
public ResponseEntity<ApiResponse<?>> createTable(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestAttribute("user_id") String userId,
@RequestBody Map<String, Object> body) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -65,10 +66,11 @@ public class DdlController {
public ResponseEntity<ApiResponse<?>> addColumn(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestAttribute("user_id") String userId,
@RequestBody Map<String, Object> body) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -99,9 +101,10 @@ public class DdlController {
@PathVariable String tableName,
@PathVariable String columnName,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestAttribute("user_id") String userId) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -124,9 +127,10 @@ public class DdlController {
public ResponseEntity<ApiResponse<?>> dropTable(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestAttribute("user_id") String userId) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -147,9 +151,10 @@ public class DdlController {
@PostMapping("/validate/table")
public ResponseEntity<ApiResponse<?>> validateTableCreation(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestBody Map<String, Object> body) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -176,11 +181,12 @@ public class DdlController {
@GetMapping("/logs")
public ResponseEntity<ApiResponse<?>> getDdlLogs(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestParam(required = false, defaultValue = "50") int limit,
@RequestParam(required = false) String userId,
@RequestParam(required = false) String ddlType) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -195,10 +201,11 @@ public class DdlController {
@GetMapping("/statistics")
public ResponseEntity<ApiResponse<?>> getDdlStatistics(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestParam(required = false) String fromDate,
@RequestParam(required = false) String toDate) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -212,9 +219,10 @@ public class DdlController {
@GetMapping("/tables/{tableName}/history")
public ResponseEntity<ApiResponse<?>> getTableDdlHistory(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode) {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -230,9 +238,10 @@ public class DdlController {
@GetMapping("/tables/{tableName}/info")
public ResponseEntity<ApiResponse<?>> getTableInfo(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode) {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -255,9 +264,10 @@ public class DdlController {
@DeleteMapping("/logs/cleanup")
public ResponseEntity<ApiResponse<?>> cleanupOldLogs(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestParam(required = false, defaultValue = "90") int retentionDays) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -272,9 +282,10 @@ public class DdlController {
*/
@GetMapping("/info")
public ResponseEntity<ApiResponse<?>> getInfo(
@RequestAttribute("company_code") String companyCode) {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role) {
if (!isSuperAdmin(companyCode)) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@@ -318,7 +329,9 @@ public class DdlController {
// 내부 유틸
// ─────────────────────────────────────────────────────────────────────────
private boolean isSuperAdmin(String companyCode) {
return "*".equals(companyCode);
private boolean isSuperAdmin(String companyCode, String role) {
// company_code 가 '*' 이고 role 이 SUPER_ADMIN 둘 다 충족해야 통과 (이중 체크).
// 토큰 변조 또는 회사코드만으로 super 권한이 발급되는 사고 방지.
return "*".equals(companyCode) && "SUPER_ADMIN".equals(role);
}
}
@@ -176,6 +176,21 @@ public class TableManagementService extends BaseService {
params.put("display_column", "entity".equals(inputType) ? settings.get("display_column") : null);
params.put("display_order", settings.getOrDefault("display_order", 0));
params.put("is_visible", settings.getOrDefault("is_visible", true));
// is_nullable: 'Y'/'N' 또는 null. null 이면 mapper 의 COALESCE 로 기존 값 유지.
Object rawIsNullable = settings.get("is_nullable");
if (rawIsNullable != null) {
String s = rawIsNullable.toString();
// 프론트가 'YES'/'NO' 또는 'Y'/'N' 어느 쪽이든 보낼 수 있어 정규화
if ("NO".equalsIgnoreCase(s) || "N".equalsIgnoreCase(s) || "FALSE".equalsIgnoreCase(s)) {
params.put("is_nullable", "N");
} else if ("YES".equalsIgnoreCase(s) || "Y".equalsIgnoreCase(s) || "TRUE".equalsIgnoreCase(s)) {
params.put("is_nullable", "Y");
} else {
params.put("is_nullable", null);
}
} else {
params.put("is_nullable", null);
}
params.put("company_code", companyCode);
params.put("category_ref", "category".equals(inputType) ? settings.get("category_ref") : null);
sqlSession.update(NS + "upsertColumnSettings", params);
@@ -300,7 +300,7 @@
, #{display_column}
, #{display_order}
, #{is_visible}
, 'Y'
, COALESCE(#{is_nullable}, 'Y')
, #{company_code}
, #{category_ref}
, NOW()
@@ -318,6 +318,7 @@
, DISPLAY_COLUMN = EXCLUDED.DISPLAY_COLUMN
, DISPLAY_ORDER = COALESCE(EXCLUDED.DISPLAY_ORDER, TABLE_TYPE_COLUMNS.DISPLAY_ORDER)
, IS_VISIBLE = COALESCE(EXCLUDED.IS_VISIBLE, TABLE_TYPE_COLUMNS.IS_VISIBLE)
, IS_NULLABLE = COALESCE(EXCLUDED.IS_NULLABLE, TABLE_TYPE_COLUMNS.IS_NULLABLE)
, CATEGORY_REF = EXCLUDED.CATEGORY_REF
, UPDATED_DATE = NOW()
</insert>
@@ -399,9 +399,28 @@ export default function TableManagementPage() {
}
}, []);
// 저장 안 한 변경 사항이 있는지 — columns 와 originalColumns 의 reference 비교 (immutable update 패턴 의존)
const hasUnsavedChanges = useMemo(() => {
if (columns.length === 0 || originalColumns.length === 0) return false;
if (columns.length !== originalColumns.length) return true;
// 직렬화 비교 (얕은 ref 만으론 부족 — handleColumnChange 가 새 객체를 만들지만 다른 필드는 같은 ref 일 수 있어서)
try {
return JSON.stringify(columns) !== JSON.stringify(originalColumns);
} catch {
return false;
}
}, [columns, originalColumns]);
// 테이블 선택
const handleTableSelect = useCallback(
(tableName: string) => {
if (tableName === selectedTable) return;
if (hasUnsavedChanges) {
const ok = typeof window !== "undefined"
? window.confirm("저장하지 않은 컬럼 변경 사항이 있습니다. 이동하면 변경 내용이 사라집니다. 계속할까요?")
: true;
if (!ok) return;
}
setSelectedTable(tableName);
setCurrentPage(1);
setColumns([]);
@@ -416,7 +435,7 @@ export default function TableManagementPage() {
loadColumnTypes(tableName, 1, pageSize);
loadConstraints(tableName);
},
[loadColumnTypes, loadConstraints, pageSize, tables],
[hasUnsavedChanges, loadColumnTypes, loadConstraints, pageSize, selectedTable, tables],
);
// 입력 타입 변경 - 이전 타입의 설정값 초기화 포함
@@ -1808,6 +1827,21 @@ export default function TableManagementPage() {
handleInputTypeChange(selectedColumn, value as string);
return;
}
// 그리드 칩과 동일하게 is_nullable/is_unique 는 즉시 저장
if (field === "is_nullable") {
const currentColumn = columns.find((c) => c.column_name === selectedColumn);
if (currentColumn) {
handleNullableToggle(selectedColumn, currentColumn.is_nullable || "YES");
}
return;
}
if (field === "is_unique") {
const currentColumn = columns.find((c) => c.column_name === selectedColumn);
if (currentColumn) {
handleUniqueToggle(selectedColumn, currentColumn.is_unique || "NO");
}
return;
}
if (field === "reference_table" && value) {
loadReferenceTableColumns(value as string);
}
@@ -1857,13 +1891,13 @@ export default function TableManagementPage() {
setDuplicateSourceTable(null);
}}
mode={duplicateModalMode}
sourceTableName={duplicateSourceTable || undefined}
source_table_name={duplicateSourceTable || undefined}
/>
<AddColumnModal
isOpen={addColumnModalOpen}
onClose={() => setAddColumnModalOpen(false)}
tableName={selectedTable || ""}
table_name={selectedTable || ""}
onSuccess={async (result) => {
toast.success("컬럼이 성공적으로 추가되었습니다!");
// 테이블 목록 새로고침 (컬럼 수 업데이트)