Files
invyone/backend-spring/src/main/java/com/erp/controller/AdminController.java
T
johngreen 824a3100ce 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>
2026-05-15 10:59:15 +09:00

476 lines
19 KiB
Java

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;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
@Slf4j
public class AdminController {
private final AdminService adminService;
// ── 메뉴 관리 ──────────────────────────────────────────────────────────
/**
* GET /api/admin/menus
* 관리자 메뉴 목록 조회
*/
@GetMapping("/menus")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getAdminMenus(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role,
@RequestAttribute("user_id") String userId,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
params.put("user_type", role);
params.put("user_id", userId);
params.putIfAbsent("user_lang", "ko");
params.put("is_management_screen",
params.get("menu_type") == null || "true".equals(params.get("include_inactive")));
return ResponseEntity.ok(ApiResponse.success(adminService.getAdminMenuList(params), "관리자 메뉴 목록 조회 성공"));
}
/**
* GET /api/admin/user-menus
* 사용자 메뉴 목록 조회 (사이드바)
*/
@GetMapping("/user-menus")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getUserMenus(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role,
@RequestAttribute("user_id") String userId,
@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), "사용자 메뉴 목록 조회 성공"));
}
/**
* GET /api/admin/pop-menus
* POP 메뉴 목록 조회
*/
@GetMapping("/pop-menus")
public ResponseEntity<ApiResponse<Map<String, Object>>> getPopMenus(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("user_type", role);
return ResponseEntity.ok(ApiResponse.success(adminService.getPopMenuList(params), "POP 메뉴 목록 조회 성공"));
}
/**
* GET /api/admin/menus/{menuId}
* 메뉴 단건 조회
*/
@GetMapping("/menus/{menuId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getMenuInfo(@PathVariable String menuId) {
Map<String, Object> menu = adminService.getMenuInfo(menuId);
if (menu == null) {
return ResponseEntity.status(404).body(ApiResponse.error("메뉴를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(menu, "메뉴 정보 조회 성공"));
}
/**
* POST /api/admin/menus
* 메뉴 등록
*/
@PostMapping("/menus")
public ResponseEntity<ApiResponse<Map<String, Object>>> saveMenu(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(adminService.saveMenu(body), "메뉴 등록 성공"));
}
/**
* PUT /api/admin/menus/{menuId}
* 메뉴 수정
*/
@PutMapping("/menus/{menuId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateMenu(
@PathVariable String menuId,
@RequestBody Map<String, Object> body) {
return ResponseEntity.ok(ApiResponse.success(adminService.updateMenu(menuId, body), "메뉴 수정 성공"));
}
/**
* DELETE /api/admin/menus/{menuId}
* 메뉴 삭제
*/
@DeleteMapping("/menus/{menuId}")
public ResponseEntity<ApiResponse<Void>> deleteMenu(@PathVariable String menuId) {
adminService.deleteMenu(menuId);
return ResponseEntity.ok(ApiResponse.success(null, "메뉴 삭제 성공"));
}
/**
* PUT /api/admin/menus/{menuId}/toggle
* 메뉴 상태 토글
*/
@PutMapping("/menus/{menuId}/toggle")
public ResponseEntity<ApiResponse<Map<String, Object>>> toggleMenuStatus(@PathVariable String menuId) {
return ResponseEntity.ok(ApiResponse.success(adminService.toggleMenuStatus(menuId), "메뉴 상태 변경 성공"));
}
/**
* DELETE /api/admin/menus/batch
* 메뉴 일괄 삭제
*/
@DeleteMapping("/menus/batch")
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteMenusBatch(@RequestBody List<String> menuIds) {
int deletedCount = 0;
int failedCount = 0;
if (menuIds != null) {
for (String menuId : menuIds) {
try {
adminService.deleteMenu(menuId);
deletedCount++;
} catch (Exception e) {
failedCount++;
log.warn("메뉴 삭제 실패 menuId={} : {}", menuId, e.getMessage());
}
}
}
Map<String, Object> result = new HashMap<>();
result.put("deletedCount", deletedCount);
result.put("failedCount", failedCount);
return ResponseEntity.ok(ApiResponse.success(result, "메뉴 일괄 삭제 성공"));
}
// ── 사용자 관리 ────────────────────────────────────────────────────────
/**
* GET /api/admin/users
* 사용자 목록 조회
*/
@GetMapping("/users")
public ResponseEntity<?> getUserList(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role,
@RequestParam Map<String, Object> params) {
// SUPER_ADMIN이 아닌 경우 자사 필터 적용
if (!"SUPER_ADMIN".equals(role)) {
params.put("company_code", companyCode);
} else {
params.put("company_code", "*");
}
params.putIfAbsent("page", "1");
params.putIfAbsent("limit", "20");
Map<String, Object> result = adminService.getUserList(params);
// Node.js 응답 형식: success, data, total, searchType, pagination, message
Map<String, Object> response = new HashMap<>(result);
response.put("success", true);
response.put("message", "사용자 목록 조회 성공");
return ResponseEntity.ok(response);
}
/**
* GET /api/admin/users/{userId}
* 사용자 단건 조회
*/
@GetMapping("/users/{userId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getUserInfo(@PathVariable String userId) {
Map<String, Object> user = adminService.getUserInfo(userId);
if (user == null) {
return ResponseEntity.status(404).body(ApiResponse.error("사용자를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(user, "사용자 정보 조회 성공"));
}
/**
* GET /api/admin/users/{userId}/history
* 사용자 변경이력 조회
*/
@GetMapping("/users/{userId}/history")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getUserHistory(
@PathVariable String userId,
@RequestParam Map<String, Object> params) {
return ResponseEntity.ok(ApiResponse.success(adminService.getUserHistory(userId, params), "사용자 변경이력 조회 성공"));
}
/**
* GET /api/admin/users/{userId}/with-dept
* 사원 + 부서 정보 조회 (수정 모달용)
*/
@GetMapping("/users/{userId}/with-dept")
public ResponseEntity<ApiResponse<Map<String, Object>>> getUserWithDept(
@RequestAttribute("company_code") String companyCode,
@PathVariable String userId) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("user_id", userId);
Map<String, Object> result = adminService.getUserWithDept(params);
if (result == null) {
return ResponseEntity.status(404).body(ApiResponse.error("사용자를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(result));
}
/**
* POST /api/admin/users/with-dept
* 사원 + 부서 통합 저장
*/
@PostMapping("/users/with-dept")
public ResponseEntity<ApiResponse<Map<String, Object>>> saveUserWithDept(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(adminService.saveUserWithDept(body)));
}
/**
* POST /api/admin/users
* 사용자 등록/수정
*/
@PostMapping("/users")
public ResponseEntity<ApiResponse<Map<String, Object>>> saveUser(
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(adminService.saveUser(body), "사용자 저장 성공"));
}
/**
* PUT /api/admin/users/{userId}
* 사용자 수정 (REST)
*/
@PutMapping("/users/{userId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateUser(
@PathVariable String userId,
@RequestAttribute("company_code") String companyCode,
@RequestBody Map<String, Object> body) {
body.put("user_id", userId);
body.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(adminService.saveUser(body), "사용자 수정 성공"));
}
/**
* DELETE /api/admin/users/{userId}
* 사용자 삭제 (비활성화)
*/
@DeleteMapping("/users/{userId}")
public ResponseEntity<ApiResponse<Void>> deleteUser(
@PathVariable String userId) {
Map<String, Object> existing = adminService.getUserInfo(userId);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("사용자를 찾을 수 없습니다."));
}
adminService.changeUserStatus(userId, "inactive");
return ResponseEntity.ok(ApiResponse.success(null, "사용자 삭제 성공"));
}
/**
* PATCH /api/admin/users/{userId}/status
* 사용자 상태 변경
*/
@PatchMapping("/users/{userId}/status")
public ResponseEntity<ApiResponse<Void>> changeUserStatus(
@PathVariable String userId,
@RequestBody Map<String, Object> body) {
String status = (String) body.get("status");
adminService.changeUserStatus(userId, status);
return ResponseEntity.ok(ApiResponse.success(null, "사용자 상태 변경 성공"));
}
/**
* POST /api/admin/users/reset-password
* 비밀번호 초기화
*/
@PostMapping("/users/reset-password")
public ResponseEntity<ApiResponse<Void>> resetUserPassword(@RequestBody Map<String, Object> body) {
String userId = (String) body.get("user_id");
String newPassword = (String) body.get("new_password");
adminService.resetUserPassword(userId, newPassword);
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
}
/**
* POST /api/admin/users/check-duplicate
* 사용자 ID 중복 확인
*/
@PostMapping("/users/check-duplicate")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkDuplicateUserId(
@RequestBody Map<String, Object> body) {
String userId = (String) body.get("user_id");
Map<String, Object> existing = adminService.getUserInfo(userId);
Map<String, Object> result = new HashMap<>();
result.put("is_duplicate", existing != null);
return ResponseEntity.ok(ApiResponse.success(result, "아이디 중복 확인 완료"));
}
// ── 프로필 수정 ────────────────────────────────────────────────────────
/**
* PUT /api/admin/profile
* 프로필 수정 (본인)
*/
@PutMapping("/profile")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateProfile(
@RequestAttribute("user_id") String userId,
@RequestBody Map<String, Object> body) {
body.put("user_id", userId);
return ResponseEntity.ok(ApiResponse.success(adminService.saveUser(body), "프로필 수정 성공"));
}
// ── 부서 관리 ──────────────────────────────────────────────────────────
/**
* GET /api/admin/departments
* 부서 목록 조회
*/
@GetMapping("/departments")
public ResponseEntity<?> getDepartmentList(
@RequestAttribute("company_code") String companyCode,
@RequestParam Map<String, Object> params) {
params.put("company_code", companyCode);
Map<String, Object> serviceResult = adminService.getDepartmentList(params);
int total = ((Number) serviceResult.get("total")).intValue();
Map<String, Object> data = new HashMap<>();
data.put("departments", serviceResult.get("departments"));
data.put("flat_list", serviceResult.get("flat_list"));
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", data);
response.put("message", "부서 목록 조회 성공");
response.put("total", total);
response.put("total_count", total);
return ResponseEntity.ok(response);
}
// ── 회사 관리 ──────────────────────────────────────────────────────────
/**
* GET /api/admin/companies
* 회사 목록 조회
*/
@GetMapping("/companies")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCompanyList() {
return ResponseEntity.ok(ApiResponse.success(adminService.getCompanyList(), "회사 목록 조회 성공"));
}
/**
* GET /api/admin/companies/db
* 실제 DB에서 회사 목록 조회
*/
@GetMapping("/companies/db")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCompanyListFromDB() {
return ResponseEntity.ok(ApiResponse.success(adminService.getCompanyList(), "회사 목록 조회 성공"));
}
/**
* GET /api/admin/companies/{companyCode}
* 회사 단건 조회
*/
@GetMapping("/companies/{companyCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyByCode(
@PathVariable String companyCode) {
Map<String, Object> company = adminService.getCompanyByCode(companyCode);
if (company == null) {
return ResponseEntity.status(404).body(ApiResponse.error("회사를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(company, "회사 조회 성공"));
}
/**
* POST /api/admin/companies
* 회사 등록
*/
@PostMapping("/companies")
public ResponseEntity<ApiResponse<Map<String, Object>>> createCompany(
@RequestBody Map<String, Object> body) {
return ResponseEntity.ok(ApiResponse.success(adminService.createCompany(body), "회사 등록 성공"));
}
/**
* PUT /api/admin/companies/{companyCode}
* 회사 수정
*/
@PutMapping("/companies/{companyCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCompany(
@PathVariable String companyCode,
@RequestBody Map<String, Object> body) {
return ResponseEntity.ok(ApiResponse.success(adminService.updateCompanyInfo(companyCode, body), "회사 수정 성공"));
}
/**
* DELETE /api/admin/companies/{companyCode}
* 회사 삭제 (비활성화)
*/
@DeleteMapping("/companies/{companyCode}")
public ResponseEntity<ApiResponse<Void>> deleteCompany(@PathVariable String companyCode) {
adminService.deleteCompany(companyCode);
return ResponseEntity.ok(ApiResponse.success(null, "회사 삭제 성공"));
}
// ── 로케일 관리 ────────────────────────────────────────────────────────
/**
* GET /api/admin/user-locale
* 사용자 로케일 조회
*/
@GetMapping("/user-locale")
public ResponseEntity<ApiResponse<Object>> getUserLocale(
@RequestAttribute("user_id") String userId) {
Map<String, Object> row = adminService.getUserLocale(userId);
Object locale = row.getOrDefault("locale", "KR");
return ResponseEntity.ok(ApiResponse.success(locale, "사용자 로케일 조회 성공"));
}
/**
* POST /api/admin/user-locale
* 사용자 로케일 설정
*/
@PostMapping("/user-locale")
public ResponseEntity<ApiResponse<Object>> setUserLocale(
@RequestAttribute("user_id") String userId,
@RequestBody Map<String, Object> body) {
String locale = (String) body.get("locale");
if (locale == null || locale.isBlank()) {
return ResponseEntity.badRequest().body(ApiResponse.error("로케일이 필요합니다."));
}
try {
adminService.setUserLocale(userId, locale);
return ResponseEntity.ok(ApiResponse.success(locale, "사용자 로케일 설정 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
}
}
// ── 테이블 스키마 ──────────────────────────────────────────────────────
/**
* GET /api/admin/tables/{tableName}/schema
* 테이블 스키마 조회
*/
@GetMapping("/tables/{tableName}/schema")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getTableSchema(
@PathVariable String tableName) {
return ResponseEntity.ok(ApiResponse.success(adminService.getTableSchema(tableName), "테이블 스키마 조회 성공"));
}
}