feat(cross-tenant): SUPER_ADMIN 의 회사별 사용자 WRITE (Phase 1)

지금까지 cross-tenant 는 READ 전용. admin 도메인에서 사용자 등록하면
JWT.company_code='*' 가 그대로 박혀 메타 DB 에 INSERT 되던 버그 해결.
이제 SUPER_ADMIN 이 폼의 "회사" 드롭다운에서 TEST01/TEST02 등 선택하면
그 회사 DB 에 정확히 INSERT.

신규 백엔드
- crosstenant/CrossTenantExecutor.java  — 회사 컨텍스트 임시 전환 헬퍼
  (company_code → db_name → ensureTenantPool → set → run → restore)
- crosstenant/CrossTenantUserController.java  — /api/admin/cross-tenant/users
  9개 endpoint (POST/PUT/DELETE/PATCH/with-dept/check-duplicate/단건/이력)
- mapper/provisioning.xml — resolveDbNameByCompanyCode (active 회사만)

기존 단일 회사 모드 (POST /admin/users 등) 무수정 — 회사 도메인
컨텍스트에서 회귀 0.

프론트
- lib/api/user.ts — createUser/updateUser/updateUserStatus/checkDuplicateUserId/
  saveUserWithDept 가 isCrossTenantMode() 면 새 endpoint + body.company_code 로 분기
- UserFormModal — checkDuplicateId 호출 시 formData.company_code 같이 전달
- useUserManagement — status toggle 시 row 의 company_code 같이 전달

검증 (curl, SUPER_ADMIN 토큰):
- 토큰 없음 → 403 super_admin_required
- company_code 없음 → 400 "company_code 가 비어있음"
- 잘못된 company_code → 400 "등록되지 않았거나 비활성 회사"
- check-duplicate: TEST01.test02_admin → not_dup, TEST02.test02_admin → dup ✓
- POST 사용자 → TEST02 USER_INFO +1, TEST01·메타 격리 ✓
- /users fan-out: by={'*':8, 'TEST01':1, 'TEST02':2}, hjtest_ct_001 in TEST02만 ✓
- DELETE → status=inactive (soft) ✓

미구현 (Phase 1 후속):
- 부서 dropdown (cross-tenant department endpoint 별도 필요)
- 비밀번호 초기화 모달의 cross-tenant 분기 (UserPasswordResetModal)
- Phase 2 권한관리 (별도 커밋)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-04-29 17:22:52 +09:00
parent cdc55dfd48
commit a41f99c579
6 changed files with 459 additions and 11 deletions
@@ -0,0 +1,116 @@
package com.erp.crosstenant;
import com.erp.tenant.DbContextHolder;
import com.erp.tenant.TenantDataSourceFactory;
import com.erp.tenant.TenantDbSettings;
import com.erp.tenant.TenantRoutingDataSource;
import com.zaxxer.hikari.HikariDataSource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;
/**
* SUPER_ADMIN 의 cross-tenant WRITE 트랙 — 회사 컨텍스트 임시 전환 + 작업 실행 + 복원.
*
* READ 트랙({@link CrossTenantAggregator}) 와 달리 fan-out 안 함. 호출자가 명시한
* 단일 회사(company_code) DB 컨텍스트로 잠깐 전환해서 INSERT/UPDATE/DELETE 실행.
*
* 사용 패턴 (컨트롤러):
* <pre>
* if (!CrossTenantContext.isSuperAdmin(request)) return forbidden();
* if (!CrossTenantContext.isMetaContext()) return badRequest();
*
* String targetCompany = (String) body.get("company_code");
* Map&lt;String,Object&gt; result = executor.runInCompany(targetCompany, () -&gt;
* adminService.saveUser(body)
* );
* </pre>
*
* 핵심 보장:
* - 회사 풀 lazy 생성 (없으면 만들고, 이미 있으면 no-op). minIdle=0 정책 그대로.
* - finally 에서 prev 컨텍스트 복원 — 누수되면 후속 요청이 엉뚱한 회사 DB 로 라우팅.
* - 알 수 없는 company_code 면 IllegalArgumentException — 컨트롤러가 400 으로 변환.
*
* @see CrossTenantAggregator // READ 트랙 (fan-out)
* @see CrossTenantContext // 가드
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CrossTenantExecutor {
private final SqlSession sqlSession;
private final TenantRoutingDataSource routingDataSource;
private final TenantDbSettings tenantDbSettings;
/**
* 지정 회사 DB 컨텍스트로 작업 실행. 결과 반환.
*
* @throws IllegalArgumentException company_code 가 비어있거나 active 회사가 아닐 때
*/
public <T> T runInCompany(String companyCode, Supplier<T> work) {
String dbName = resolveDbName(companyCode);
ensureTenantPool(dbName);
String prev = DbContextHolder.get();
try {
DbContextHolder.set(dbName);
log.info("[CrossTenant/Write] enter company={} db={}", companyCode, dbName);
return work.get();
} finally {
if (prev == null) {
DbContextHolder.clear();
} else {
DbContextHolder.set(prev);
}
}
}
/** 결과 없는 작업용 (Runnable 형태). */
public void runInCompany(String companyCode, Runnable work) {
runInCompany(companyCode, () -> {
work.run();
return null;
});
}
/**
* company_code → db_name 매핑. META DB 의 COMPANY_MNG 에서 active 행만 인정.
* 컨텍스트 전환 전에 호출돼야 하므로 META 컨텍스트(현재 컨텍스트)에서 실행.
*/
private String resolveDbName(String companyCode) {
if (companyCode == null || companyCode.isBlank()) {
throw new IllegalArgumentException("company_code 가 비어있음");
}
if ("*".equals(companyCode)) {
throw new IllegalArgumentException("'*' 는 cross-tenant write 대상이 아님 (메타 = SUPER_ADMIN 자신)");
}
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
String dbName = sqlSession.selectOne("provisioning.resolveDbNameByCompanyCode", params);
if (dbName == null) {
throw new IllegalArgumentException("등록되지 않았거나 비활성 회사: company_code=" + companyCode);
}
return dbName;
}
/** 회사 풀이 없으면 최초 1회 생성. SubdomainResolverFilter / Aggregator 와 동일 패턴. */
private void ensureTenantPool(String dbName) {
if (routingDataSource.hasTenant(dbName)) return;
synchronized (routingDataSource) {
if (routingDataSource.hasTenant(dbName)) return;
HikariDataSource ds = TenantDataSourceFactory.createTenant(
tenantDbSettings.buildJdbcUrl(dbName),
tenantDbSettings.username(),
tenantDbSettings.password(),
dbName);
routingDataSource.addTenant(dbName, ds);
}
}
}
@@ -0,0 +1,286 @@
package com.erp.crosstenant;
import com.erp.dto.ApiResponse;
import com.erp.service.AdminService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* SUPER_ADMIN 의 cross-tenant USER WRITE 엔드포인트.
*
* 기본 패턴:
* 1. {@link CrossTenantContext#isSuperAdmin} + {@link CrossTenantContext#isMetaContext} 가드
* 2. 요청 body/path/query 에서 target {@code company_code} 추출 (필수)
* 3. {@link CrossTenantExecutor#runInCompany} 로 그 회사 DB 컨텍스트 임시 전환
* 4. 기존 {@link AdminService} 의 user write 메서드 호출 (재사용)
* 5. finally 에서 컨텍스트 복원
*
* 기존 {@code POST /api/admin/users} 등 단일 회사 모드 엔드포인트는 무수정 — 회사 도메인
* 컨텍스트에서 그대로 동작.
*
* @see CrossTenantExecutor
* @see com.erp.controller.AdminController // 단일 회사 모드 원본
*/
@RestController
@RequestMapping("/api/admin/cross-tenant/users")
@RequiredArgsConstructor
@Slf4j
public class CrossTenantUserController {
private final CrossTenantExecutor executor;
private final AdminService adminService;
// ── 등록 / 수정 ─────────────────────────────────────────────────────
/**
* POST /api/admin/cross-tenant/users
* SUPER_ADMIN 이 특정 회사 사용자 등록/수정 (회사는 body.company_code 로 명시).
*/
@PostMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> saveUser(
HttpServletRequest request,
@RequestBody Map<String, Object> body) {
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
if (guard != null) return guard;
String targetCompanyCode = (String) body.get("company_code");
try {
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
() -> adminService.saveUser(body));
return ResponseEntity.ok(ApiResponse.success(result, "사용자 저장 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
}
}
/**
* POST /api/admin/cross-tenant/users/with-dept
* 사원+부서 통합 저장 (cross-tenant).
*/
@PostMapping("/with-dept")
public ResponseEntity<ApiResponse<Map<String, Object>>> saveUserWithDept(
HttpServletRequest request,
@RequestBody Map<String, Object> body) {
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
if (guard != null) return guard;
String targetCompanyCode = (String) body.get("company_code");
try {
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
() -> adminService.saveUserWithDept(body));
return ResponseEntity.ok(ApiResponse.success(result, "사원+부서 저장 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
}
}
/**
* PUT /api/admin/cross-tenant/users/{userId}
* 사용자 수정 (REST). target company_code 는 body 에 명시.
*/
@PutMapping("/{userId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateUser(
HttpServletRequest request,
@PathVariable String userId,
@RequestBody Map<String, Object> body) {
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
if (guard != null) return guard;
String targetCompanyCode = (String) body.get("company_code");
body.put("user_id", userId);
try {
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
() -> adminService.saveUser(body));
return ResponseEntity.ok(ApiResponse.success(result, "사용자 수정 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
}
}
/**
* DELETE /api/admin/cross-tenant/users/{userId}?company_code=TEST01
* 사용자 삭제 (비활성화). target company_code 는 query param.
*/
@DeleteMapping("/{userId}")
public ResponseEntity<ApiResponse<Void>> deleteUser(
HttpServletRequest request,
@PathVariable String userId,
@RequestParam("company_code") String companyCode) {
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
if (guard != null) return guard;
try {
executor.runInCompany(companyCode, () -> {
Map<String, Object> existing = adminService.getUserInfo(userId);
if (existing == null) {
throw new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId);
}
adminService.changeUserStatus(userId, "inactive");
});
return ResponseEntity.ok(ApiResponse.success(null, "사용자 삭제 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
}
}
/**
* PATCH /api/admin/cross-tenant/users/{userId}/status
* 사용자 상태 변경. body: { "status": "active|inactive", "company_code": "TEST01" }
*/
@PatchMapping("/{userId}/status")
public ResponseEntity<ApiResponse<Void>> changeUserStatus(
HttpServletRequest request,
@PathVariable String userId,
@RequestBody Map<String, Object> body) {
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
if (guard != null) return guard;
String targetCompanyCode = (String) body.get("company_code");
String status = (String) body.get("status");
try {
executor.runInCompany(targetCompanyCode, () ->
adminService.changeUserStatus(userId, status));
return ResponseEntity.ok(ApiResponse.success(null, "사용자 상태 변경 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
}
}
/**
* POST /api/admin/cross-tenant/users/reset-password
* body: { "user_id": "...", "company_code": "TEST01" }
*/
@PostMapping("/reset-password")
public ResponseEntity<ApiResponse<Void>> resetUserPassword(
HttpServletRequest request,
@RequestBody Map<String, Object> body) {
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
if (guard != null) return guard;
String targetCompanyCode = (String) body.get("company_code");
String userId = (String) body.get("user_id");
try {
executor.runInCompany(targetCompanyCode, () ->
adminService.resetUserPassword(userId));
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
}
}
// ── READ 보강 (단건 조회 / 중복확인 / 이력) ───────────────────────
/**
* GET /api/admin/cross-tenant/users/{userId}?company_code=TEST01
* 단건 조회 — 회사 컨텍스트로 가서 USER_INFO 단건.
*/
@GetMapping("/{userId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getUserInfo(
HttpServletRequest request,
@PathVariable String userId,
@RequestParam("company_code") String companyCode) {
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
if (guard != null) return guard;
try {
Map<String, Object> info = executor.runInCompany(companyCode,
() -> adminService.getUserInfo(userId));
if (info == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("사용자를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(info));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
}
}
/**
* GET /api/admin/cross-tenant/users/{userId}/with-dept?company_code=TEST01
*/
@GetMapping("/{userId}/with-dept")
public ResponseEntity<ApiResponse<Map<String, Object>>> getUserWithDept(
HttpServletRequest request,
@PathVariable String userId,
@RequestParam("company_code") String companyCode) {
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
if (guard != null) return guard;
try {
Map<String, Object> result = executor.runInCompany(companyCode, () -> {
Map<String, Object> p = new HashMap<>();
p.put("company_code", companyCode);
p.put("user_id", userId);
return adminService.getUserWithDept(p);
});
if (result == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("사용자를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
}
}
/**
* POST /api/admin/cross-tenant/users/check-duplicate
* body: { "user_id": "...", "company_code": "TEST01" }
*/
@PostMapping("/check-duplicate")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkDuplicateUserId(
HttpServletRequest request,
@RequestBody Map<String, Object> body) {
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
if (guard != null) return guard;
String targetCompanyCode = (String) body.get("company_code");
String userId = (String) body.get("user_id");
try {
Map<String, Object> result = executor.runInCompany(targetCompanyCode, () -> {
Map<String, Object> existing = adminService.getUserInfo(userId);
Map<String, Object> out = new HashMap<>();
out.put("is_duplicate", existing != null);
return out;
});
return ResponseEntity.ok(ApiResponse.success(result, "아이디 중복 확인 완료"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
}
}
// ── 가드 헬퍼 ───────────────────────────────────────────────────
/** Map<String,Object> 응답용 가드 — null 이면 통과, 아니면 그대로 반환. */
private ResponseEntity<ApiResponse<Map<String, Object>>> guard(HttpServletRequest request) {
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
}
if (!CrossTenantContext.isMetaContext()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
}
return null;
}
/** Void 응답용 가드 (제네릭만 다름). */
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
}
if (!CrossTenantContext.isMetaContext()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
}
return null;
}
}
@@ -218,4 +218,18 @@
ORDER BY COMPANY_CODE
</select>
<!--
company_code 로 db_name 직접 조회 (cross-tenant write 라우팅용).
SUPER_ADMIN 이 admin 도메인에서 특정 회사 DB 로 임시 컨텍스트 전환할 때
CrossTenantExecutor 가 호출. active 회사만 라우팅 허용.
-->
<select id="resolveDbNameByCompanyCode" parameterType="map" resultType="string">
SELECT DB_NAME
FROM COMPANY_MNG
WHERE COMPANY_CODE = #{company_code}
AND DB_STATUS = 'active'
AND DB_NAME IS NOT NULL
LIMIT 1
</select>
</mapper>
+3 -1
View File
@@ -272,7 +272,9 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
}
try {
const response = await userAPI.checkDuplicateId(formData.user_id);
// cross-tenant 모드: 회사별 USER_INFO 라 그 회사 코드와 함께 중복 체크.
// 단일 모드: 두번째 인자 무시 (백엔드가 JWT.company_code 사용).
const response = await userAPI.checkDuplicateId(formData.user_id, formData.company_code);
if (response.success && response.data) {
// 백엔드 API 응답 구조: { is_duplicate: boolean, message: string }
const isDuplicate = response.data.is_duplicate;
+3 -1
View File
@@ -269,7 +269,9 @@ export const useUserManagement = () => {
console.log(`🎛️ 상태 변경: ${user.user_name} (${user.user_id}) → ${newStatus}`);
// 백엔드 API 호출
const response = await userAPI.updateStatus(user.user_id, newStatus);
// cross-tenant 모드: row 의 company_code 가 어느 회사 DB 사용자인지 알려줌.
// 단일 모드: 세번째 인자 무시 (백엔드가 JWT.company_code 사용).
const response = await userAPI.updateStatus(user.user_id, newStatus, (user as any).company_code);
// 백엔드 응답 구조: { result: boolean, msg: string }
if (response && typeof response === "object" && "result" in response) {
+35 -7
View File
@@ -102,10 +102,14 @@ export async function getUserInfo(userId: string) {
/**
* 사용자 등록
*
* cross-tenant 모드: body 의 company_code 가 가리키는 회사 DB 에 INSERT.
* 단일 모드: 현재 컨텍스트 (JWT company_code) 의 회사 DB.
*/
export async function createUser(userData: any) {
try {
const response = await apiClient.post("/admin/users", userData);
const endpoint = isCrossTenantMode() ? "/admin/cross-tenant/users" : "/admin/users";
const response = await apiClient.post(endpoint, userData);
// 백엔드에서 result 필드를 사용하므로 이에 맞춰 처리
if (response.data.result === true || response.data.success === true) {
@@ -127,6 +131,8 @@ export async function createUser(userData: any) {
/**
* 사용자 정보 수정
*
* cross-tenant 모드: body.company_code 가 가리키는 회사 DB 의 USER_INFO 수정.
*/
export async function updateUser(userData: {
user_id: string;
@@ -137,17 +143,27 @@ export async function updateUser(userData: {
status?: string;
[key: string]: any;
}) {
const response = await apiClient.put(`/admin/users/${userData.user_id}`, userData);
const endpoint = isCrossTenantMode()
? `/admin/cross-tenant/users/${userData.user_id}`
: `/admin/users/${userData.user_id}`;
const response = await apiClient.put(endpoint, userData);
return response.data;
}
/**
* 사용자 상태 변경 (부분 수정)
*
* cross-tenant 모드: companyCode 필수 (어느 회사 DB 의 사용자인지 알아야 라우팅).
*/
export async function updateUserStatus(userId: string, status: string) {
export async function updateUserStatus(userId: string, status: string, companyCode?: string) {
if (isCrossTenantMode()) {
const response = await apiClient.patch(`/admin/cross-tenant/users/${userId}/status`, {
status,
company_code: companyCode,
});
return response.data;
}
const response = await apiClient.patch(`/admin/users/${userId}/status`, { status });
return response.data;
}
@@ -219,8 +235,18 @@ export async function getDepartmentList(companyCode?: string) {
/**
* 사용자 ID 중복 체크
*
* cross-tenant 모드: companyCode 필수 — 그 회사 DB 안에서만 중복 체크.
* (회사간 같은 user_id 가 다른 사람이라는 멀티테넌시 전제 — 설계서 §12)
*/
export async function checkDuplicateUserId(userId: string) {
export async function checkDuplicateUserId(userId: string, companyCode?: string) {
if (isCrossTenantMode()) {
const response = await apiClient.post("/admin/cross-tenant/users/check-duplicate", {
user_id: userId,
company_code: companyCode,
});
return response.data;
}
const response = await apiClient.post("/admin/users/check-duplicate", { userId });
return response.data;
}
@@ -297,7 +323,9 @@ export async function saveUserWithDept(data: SaveUserWithDeptRequest): Promise<A
try {
console.log("사원+부서 통합 저장 API 호출:", data);
const response = await apiClient.post("/admin/users/with-dept", data);
// cross-tenant 모드: data.userInfo 안에 company_code 필수 (회사 DB 라우팅용)
const endpoint = isCrossTenantMode() ? "/admin/cross-tenant/users/with-dept" : "/admin/users/with-dept";
const response = await apiClient.post(endpoint, data);
console.log("사원+부서 통합 저장 API 응답:", response.data);
return response.data;