Files
invyone/backend-spring/src/main/java/com/erp/provisioning/CompanyMgmtController.java
T
gbpark 68f85f3736 회사 관리 기능 확장 + 테넌트/비번 보안 하드닝
- 첫 로그인 비번 강제 변경 (RUN_082): FORCE_PASSWORD_CHANGE 컬럼,
  ForcePasswordChangeGuardFilter, /auth/change-password API + 페이지
- 테넌트 일관성 가드: TenantConsistencyGuardFilter 로 JWT.company_code
  ↔ 서브도메인 company_code 대조, CompanyResolver 가 (db_name, company_code)
  동시 반환
- 회사 관리 확장 (RUN_083 audit log, RUN_084 lifecycle 컬럼):
  CompanyAdmin/Members/Templates/Lifecycle/AuditLog 서비스 +
  CompanyMgmtController + SuperAdminGuard
- 회사 관리 UI: CompanyAccordionRow 탭화 + 모달 4종
  (AdminInfo/Deactivate/Delete/RecopyTemplates) + AuditLogDrawer + csvExport
- 프로비저닝 마법사: force_password_change 토글 반영
- 프론트 인증: storage 이벤트 멀티탭 동기화, 403 errorCode
  (PASSWORD_CHANGE_REQUIRED / CROSS_TENANT_REJECTED / TENANT_NOT_RESOLVED)
  전역 리다이렉트
- 기타: StartupSchemaMigrator, OS별 도커 기동 스크립트, CLAUDE.md 트래킹

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:36:05 +09:00

228 lines
11 KiB
Java

package com.erp.provisioning;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 회사 관리 화면 라이프사이클 엔드포인트.
*
* 프로비저닝(생성) 은 {@link ProvisioningController} 가 담당. 이 컨트롤러는 그 이후의
* 관리 액션 — 비활성/재활성/삭제, 관리자 계정 관리, 구성원 조회, 템플릿 재복제, 감사 로그.
*
* 전부 SUPER_ADMIN 전용 ({@link SuperAdminGuard}).
*/
@RestController
@RequestMapping("/api/admin/provisioning")
@RequiredArgsConstructor
@Slf4j
public class CompanyMgmtController {
private final SuperAdminGuard guard;
private final CompanyLifecycleService lifecycleService;
private final CompanyAdminService adminService;
private final CompanyMembersService membersService;
private final CompanyTemplatesService templatesService;
private final CompanyAuditLogService auditLogService;
@Value("${spring.datasource.url}")
private String metaJdbcUrl;
// ─────────────── 관리자 계정 ───────────────
@GetMapping("/companies/{companyCode}/admin")
public ResponseEntity<Map<String, Object>> getAdmin(HttpServletRequest req, @PathVariable String companyCode) {
guard.enforce(req);
Map<String, Object> company = lifecycleService.getCompany(companyCode);
if (company == null) return ResponseEntity.notFound().build();
String dbName = (String) company.get("db_name");
Map<String, Object> admin = adminService.getAdmin(dbName);
admin.put("company_code", companyCode);
admin.put("db_name", dbName);
return ResponseEntity.ok(admin);
}
@PostMapping("/companies/{companyCode}/admin/reset-password")
public ResponseEntity<Map<String, Object>> resetAdminPassword(HttpServletRequest req,
@PathVariable String companyCode) {
guard.enforce(req);
Map<String, Object> company = lifecycleService.getCompany(companyCode);
if (company == null) return ResponseEntity.notFound().build();
String dbName = (String) company.get("db_name");
Map<String, Object> result = adminService.resetAdminPassword(dbName);
String actor = guard.actorUserId(req);
if (result.containsKey("error")) {
auditLogService.logFailure(companyCode, actor, CompanyAuditLogService.ACTION_PW_RESET,
dbName, Map.of("error_kind", result.get("error")), (String) result.get("error"));
return ResponseEntity.status(500).body(result);
}
auditLogService.log(companyCode, actor, CompanyAuditLogService.ACTION_PW_RESET,
(String) result.get("admin_user_id"), Map.of("db_name", dbName));
return ResponseEntity.ok()
.header("Cache-Control", "no-store")
.header("Pragma", "no-cache")
.body(result);
}
// ─────────────── 구성원 ───────────────
@GetMapping("/companies/{companyCode}/members")
public ResponseEntity<Map<String, Object>> listMembers(HttpServletRequest req,
@PathVariable String companyCode) {
guard.enforce(req);
Map<String, Object> company = lifecycleService.getCompany(companyCode);
if (company == null) return ResponseEntity.notFound().build();
String dbName = (String) company.get("db_name");
List<Map<String, Object>> members = membersService.listMembers(dbName);
Map<String, Object> out = new LinkedHashMap<>();
out.put("company_code", companyCode);
out.put("db_name", dbName);
out.put("members", members);
out.put("total", members.size());
return ResponseEntity.ok(out);
}
// ─────────────── 템플릿 ───────────────
@GetMapping("/companies/{companyCode}/installed-groups")
public ResponseEntity<List<Map<String, Object>>> installedGroups(HttpServletRequest req,
@PathVariable String companyCode) {
guard.enforce(req);
return ResponseEntity.ok(templatesService.listInstalledGroups(companyCode));
}
@PostMapping("/companies/{companyCode}/re-copy")
public ResponseEntity<Map<String, Object>> recopyTemplates(HttpServletRequest req,
@PathVariable String companyCode,
@RequestBody Map<String, Object> body) {
guard.enforce(req);
Map<String, Object> company = lifecycleService.getCompany(companyCode);
if (company == null) return ResponseEntity.notFound().build();
String dstDb = (String) company.get("db_name");
String srcDb = resolveMetaDbName();
@SuppressWarnings("unchecked")
List<String> groups = (List<String>) body.getOrDefault("selected_groups", List.of());
if (groups.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "selected_groups required"));
}
Map<String, Object> result = templatesService.recopy(srcDb, dstDb, groups);
String actor = guard.actorUserId(req);
List<?> errors = (List<?>) result.get("errors");
if (errors != null && !errors.isEmpty()) {
auditLogService.logFailure(companyCode, actor, CompanyAuditLogService.ACTION_RECOPY,
dstDb, Map.of("selected_groups", groups, "errors", errors),
"partial_failure: " + errors.size() + " table(s)");
} else {
auditLogService.log(companyCode, actor, CompanyAuditLogService.ACTION_RECOPY, dstDb,
Map.of("selected_groups", groups, "total_inserted", result.get("total_inserted")));
}
return ResponseEntity.ok(result);
}
// ─────────────── 라이프사이클 ───────────────
@PatchMapping("/companies/{companyCode}/status")
public ResponseEntity<Map<String, Object>> changeStatus(HttpServletRequest req,
@PathVariable String companyCode,
@RequestBody Map<String, Object> body) {
guard.enforce(req);
String target = (String) body.get("status");
String reason = (String) body.get("reason");
if (target == null) {
return ResponseEntity.badRequest().body(Map.of("error", "status required"));
}
String actor = guard.actorUserId(req);
Map<String, Object> result;
String action;
if ("suspended".equals(target)) {
result = lifecycleService.deactivate(companyCode, reason);
action = CompanyAuditLogService.ACTION_DEACTIVATE;
} else if ("active".equals(target)) {
result = lifecycleService.reactivate(companyCode);
action = CompanyAuditLogService.ACTION_REACTIVATE;
} else {
return ResponseEntity.badRequest().body(Map.of("error", "unsupported status: " + target));
}
if (result.containsKey("error")) {
auditLogService.logFailure(companyCode, actor, action, null,
Map.of("target_status", target, "reason", reason == null ? "" : reason),
String.valueOf(result.get("error")));
return ResponseEntity.badRequest().body(result);
}
auditLogService.log(companyCode, actor, action, (String) result.get("subdomain"),
Map.of("target_status", target, "reason", reason == null ? "" : reason));
return ResponseEntity.ok(result);
}
/**
* 영구 삭제. 안전장치: 요청 본문에 {@code confirm_subdomain} 을 담아 보내야 함 (프론트 타이핑 확인).
* 이 값이 현재 서브도메인과 정확히 일치해야 실행.
*/
@DeleteMapping("/companies/{companyCode}")
public ResponseEntity<Map<String, Object>> deleteCompany(HttpServletRequest req,
@PathVariable String companyCode,
@RequestBody(required = false) Map<String, Object> body) {
guard.enforce(req);
Map<String, Object> company = lifecycleService.getCompany(companyCode);
if (company == null) return ResponseEntity.notFound().build();
String currentSub = (String) company.get("subdomain");
String confirmSub = body == null ? null : (String) body.get("confirm_subdomain");
if (confirmSub == null || !confirmSub.equals(currentSub)) {
return ResponseEntity.status(400).body(Map.of(
"error", "confirmation_mismatch",
"message", "confirm_subdomain 이 실제 서브도메인과 일치해야 함"));
}
String actor = guard.actorUserId(req);
Map<String, Object> result = lifecycleService.delete(companyCode);
auditLogService.log(companyCode, actor, CompanyAuditLogService.ACTION_DELETE,
(String) result.get("db_name"),
Map.of("subdomain", result.get("subdomain"),
"meta_rows_removed", result.get("meta_rows_removed")));
return ResponseEntity.ok(result);
}
// ─────────────── 감사 로그 ───────────────
@GetMapping("/companies/{companyCode}/audit-log")
public ResponseEntity<Map<String, Object>> companyAuditLog(HttpServletRequest req,
@PathVariable String companyCode,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "50") int limit) {
guard.enforce(req);
return ResponseEntity.ok(auditLogService.listByCompany(companyCode, page, limit));
}
@GetMapping("/audit-log")
public ResponseEntity<Map<String, Object>> globalAuditLog(HttpServletRequest req,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "50") int limit,
@RequestParam(required = false) String action) {
guard.enforce(req);
return ResponseEntity.ok(auditLogService.listAll(page, limit, action));
}
// ─────────────── helpers ───────────────
/** jdbc:postgresql://host:port/{db_name}?... 에서 db_name 추출. */
private String resolveMetaDbName() {
int slash = metaJdbcUrl.lastIndexOf('/');
if (slash < 0) return "invyone";
String tail = metaJdbcUrl.substring(slash + 1);
int q = tail.indexOf('?');
return q < 0 ? tail : tail.substring(0, q);
}
}