fix(테이블관리): 중요 4건 일괄 수정 (PR-C)

1. getColumnGroup metaCols 확장 — 시스템 자동 생성 컬럼이 5개만 인식되어
   사용자 컬럼에 섞여 보이던 문제. objid / tenant_id / creator / modifier /
   created_at / updated_at 추가 (총 11개).

2. updateColumnWebType 회사코드 격리 — 모든 호출이 company_code='*' 로 저장돼
   회사 관리자가 자기 회사 전용 web_type 변경 시 모든 회사 공통 설정을 건드림.
   컨트롤러에서 @RequestAttribute("company_code") 받아 service 에 전달.

3. validateUniqueConstraints N+1 해소 — hasColumn 이 루프 안에서 매번 호출되어
   UNIQUE 컬럼 N 개일 때 N 번의 information_schema 조회. 루프 밖으로 빼서 1 번.

4. sanitize 강화 — 빈 문자열 / 숫자 시작 / 63자 초과 / SQL 예약어 모두
   IllegalArgumentException 으로 거부. 빈 식별자가 동적 SQL 에 끼어들어 500
   에러 노출되던 패턴 방지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 15:04:02 +09:00
parent 8a9285f13e
commit ff95c1950e
3 changed files with 61 additions and 10 deletions
@@ -187,7 +187,8 @@ public class TableManagementController {
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
@@ -197,7 +198,8 @@ public class TableManagementController {
}
@SuppressWarnings("unchecked")
Map<String, Object> detailSettings = (Map<String, Object>) body.get("detail_settings");
tableManagementService.updateColumnWebType(tableName, columnName, webType, detailSettings);
// 멀티테넌트 격리: SUPER_ADMIN(company_code='*') 가 아니면 자기 회사 코드로 저장
tableManagementService.updateColumnWebType(tableName, columnName, webType, detailSettings, companyCode);
return ResponseEntity.ok(ApiResponse.success(null, "컬럼 웹타입이 설정되었습니다."));
}
@@ -215,19 +215,21 @@ public class TableManagementService extends BaseService {
@Transactional
public void updateColumnWebType(String tableName, String columnName,
String webType, Map<String, Object> detailSettings) {
String webType, Map<String, Object> detailSettings,
String companyCode) {
String finalType = normalizeInputType(webType);
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("column_name", columnName);
params.put("input_type", finalType);
params.put("detail_settings", detailSettings != null ? toJsonString(detailSettings) : "{}");
params.put("company_code", "*");
// 멀티테넌트 격리: SUPER_ADMIN("*") 은 공통 설정, 그 외는 회사별 설정
params.put("company_code", companyCode != null ? companyCode : "*");
params.put("clear_entity", false);
params.put("clear_code", false);
params.put("clear_category", false);
sqlSession.update(NS + "upsertColumnInputType", params);
log.info("컬럼 웹타입 설정: {}.{} = {}", tableName, columnName, finalType);
log.info("컬럼 웹타입 설정: {}.{} = {} (company={})", tableName, columnName, finalType, companyCode);
}
@Transactional
@@ -398,12 +400,14 @@ public class TableManagementService extends BaseService {
String safeTable = sanitize(tableName);
List<String> violations = new ArrayList<>();
// N+N → N+1 최적화: hasColumn 은 information_schema 조회라 비싸. 루프 밖에서 한 번만 수행.
boolean hasCompanyCode = hasColumn(safeTable, "company_code");
for (Map<String, Object> col : uniqueCols) {
String colName = (String) col.get("column_name");
Object val = data.get(colName);
if (val == null) continue;
boolean hasCompanyCode = hasColumn(safeTable, "company_code");
String sql;
List<Object> sqlParams = new ArrayList<>();
@@ -1267,9 +1271,40 @@ public class TableManagementService extends BaseService {
}
/** SQL injection 방지용 식별자 정리 */
/**
* SQL 식별자(테이블/컬럼명) 살균.
* - 영숫자/언더스코어만 허용 (PostgreSQL identifier 규칙)
* - 빈 문자열, 숫자로 시작, 63자 초과, SQL 예약어 거부 → IllegalArgumentException
*
* 이렇게 가드해두지 않으면 동적 SQL 에 빈 식별자가 들어가거나 예약어가 통과해
* 의도치 않은 컬럼에 접근하거나 SQL 문법 깨짐(500) 이 생김.
*/
private static final java.util.Set<String> SQL_RESERVED_WORDS = java.util.Set.of(
"user", "order", "group", "table", "column", "index", "select", "insert",
"update", "delete", "from", "where", "join", "on", "as", "and", "or", "not",
"null", "true", "false", "create", "alter", "drop", "primary", "key",
"foreign", "references", "constraint", "default", "unique", "check",
"view", "procedure", "function"
);
private String sanitize(String name) {
if (name == null) return "";
return name.replaceAll("[^a-zA-Z0-9_]", "");
if (name == null) {
throw new IllegalArgumentException("식별자가 null 입니다.");
}
String cleaned = name.replaceAll("[^a-zA-Z0-9_]", "");
if (cleaned.isEmpty()) {
throw new IllegalArgumentException("식별자가 비어있거나 유효하지 않습니다: " + name);
}
if (cleaned.length() > 63) {
throw new IllegalArgumentException("식별자가 63자를 초과합니다: " + cleaned);
}
if (Character.isDigit(cleaned.charAt(0))) {
throw new IllegalArgumentException("식별자는 숫자로 시작할 수 없습니다: " + cleaned);
}
if (SQL_RESERVED_WORDS.contains(cleaned.toLowerCase())) {
throw new IllegalArgumentException("'" + cleaned + "' 은 SQL 예약어라 식별자로 사용할 수 없습니다.");
}
return cleaned;
}
/** "direct" / "auto" → "text" 변환 (legacy 호출처 보호 — system-normalize 동작) */
+16 -2
View File
@@ -108,9 +108,23 @@ export const USER_SELECTABLE_INPUT_TYPE_COLORS = USER_SELECTABLE_INPUT_TYPE_ORDE
{} as Record<string, TypeColorConfig>,
);
/** 컬럼 그룹 판별 */
/** 컬럼 그룹 판별 — 시스템 자동 생성 컬럼은 meta 로 분류 (사용자가 거의 수정하지 않으므로 시각 분리) */
export function getColumnGroup(col: ColumnTypeInfo): ColumnGroup {
const metaCols = ["id", "created_date", "updated_date", "writer", "company_code"];
// 시스템 컬럼: invyone 자동 생성 (id/날짜/작성자/회사) 외에 VEX 계승 (objid), 멀티테넌트 (tenant_id),
// 수정자/생성자 변형 (creator/modifier/created_at/updated_at) 까지 모두 포함
const metaCols = [
"id",
"objid",
"tenant_id",
"created_date",
"updated_date",
"created_at",
"updated_at",
"writer",
"creator",
"modifier",
"company_code",
];
if (metaCols.includes(col.column_name)) return "meta";
if (["entity", "code", "category"].includes(col.input_type)) return "reference";
return "basic";