Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f5c70bf
feat: 복합 PK 클래스 추가 (OrganizationMember, TenantWorkerMap, WorkerRole)
YuSung011017 Dec 14, 2025
d3c45ee
feat: Worker 기반 멀티 테넌트 엔티티 추가
YuSung011017 Dec 14, 2025
122a3fd
feat: Worker 관련 Repository 추가
YuSung011017 Dec 14, 2025
ffc7c62
feat: WorkerErrorCode enum 추가
YuSung011017 Dec 14, 2025
de06132
feat: Worker 기반 멀티 테넌트 서비스 구현
YuSung011017 Dec 14, 2025
2d017b5
refactor: User 엔티티에서 tenant, organization 필드 제거
YuSung011017 Dec 14, 2025
7e53567
refactor: Organization 엔티티 리팩토링 (설계 B 기준)
YuSung011017 Dec 14, 2025
eb7d751
refactor: Tenant 엔티티 수정 (설계 B 기준)
YuSung011017 Dec 14, 2025
f4e61a3
refactor: OrganizationRepository Deprecated 메서드 처리
YuSung011017 Dec 14, 2025
dcc5f70
refactor: UserRepository에서 Deprecated 필드 사용 메서드 제거
YuSung011017 Dec 14, 2025
d46ad2b
refactor: OrganizationService 리팩토링 (설계 B 기준)
YuSung011017 Dec 14, 2025
b422cc3
refactor: OrganizationAwareAuthorizationService에서 OrganizationMember 사용
YuSung011017 Dec 14, 2025
335c0a3
refactor: UserService에서 Deprecated 필드 사용 제거
YuSung011017 Dec 14, 2025
1c62917
refactor: OrganizationResponse 수정 (설계 B 기준)
YuSung011017 Dec 14, 2025
efceb2c
refactor: JWT 및 인증 서비스에서 user.getTenant() 제거
YuSung011017 Dec 14, 2025
ac3046d
refactor: 모니터링 서비스에서 user.getTenant() 제거
YuSung011017 Dec 14, 2025
6db7920
test: WorkerServiceIntegrationTest 추가
YuSung011017 Dec 14, 2025
0adaf26
refactor: Deprecated 테스트 파일 임시 이동
YuSung011017 Dec 14, 2025
779f77b
fix: Organization ↔ Tenant 1:1 관계 구현
YuSung011017 Dec 14, 2025
37b21ae
feat: Phase 5 - DTO 및 Response 구현
YuSung011017 Dec 14, 2025
4298a25
feat: Phase 6 - Controller 구현
YuSung011017 Dec 14, 2025
ef8e0a1
feat: Phase 7 - 데이터베이스 마이그레이션 스크립트 작성
YuSung011017 Dec 14, 2025
360c3b7
test: Phase 8 - Controller 단위 테스트 작성
YuSung011017 Dec 14, 2025
2d73933
refactor: 설계 C안으로 전환 - Worker 엔티티 및 서비스 수정
YuSung011017 Dec 19, 2025
f24b22e
feat: CloudResourceWorkerMap 추가 및 TenantWorkerMap Deprecated 처리
YuSung011017 Dec 19, 2025
8c47b3a
refactor: WorkerRole에서 tenant_id 제거
YuSung011017 Dec 19, 2025
022c220
feat: Role 및 Permission 엔티티에 UNIQUE 제약조건 추가
YuSung011017 Dec 19, 2025
3daf0ee
test: 설계 C안에 맞게 테스트 코드 수정
YuSung011017 Dec 19, 2025
e17e910
feat: OrganizationController에 Worker 생성 API 추가
YuSung011017 Dec 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 202 additions & 0 deletions docker/mysql/init/04-worker-multitenant.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
-- Worker 기반 멀티 테넌트 구조 마이그레이션 스크립트
-- 이슈 #172: Organization Business 로직 리팩토링 (설계 B 기준)
--
-- 주의사항:
-- 1. 마이그레이션 전 반드시 데이터 백업
-- 2. 단계별 검증 후 다음 단계 진행
-- 3. 롤백 스크립트 준비 권장

USE agenticcp;

-- ========== 1. 새로운 테이블 생성 ==========

-- Worker 테이블 (설계 B 기준)
-- User 1:N Worker 관계, (user_id, tenant_id) 복합 Unique 제약
CREATE TABLE IF NOT EXISTS workers (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by VARCHAR(100),
updated_by VARCHAR(100),
is_deleted BOOLEAN DEFAULT FALSE,

CONSTRAINT fk_worker_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT fk_worker_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT uk_worker_user_tenant UNIQUE (user_id, tenant_id)
);

CREATE INDEX IF NOT EXISTS idx_worker_user ON workers(user_id);
CREATE INDEX IF NOT EXISTS idx_worker_tenant ON workers(tenant_id);
CREATE INDEX IF NOT EXISTS idx_worker_deleted ON workers(is_deleted);

-- OrganizationMember 테이블 (복합 PK)
-- (organization_id, user_id)가 복합 PK
CREATE TABLE IF NOT EXISTS organization_member (
organization_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
role VARCHAR(50),
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

PRIMARY KEY (organization_id, user_id),
CONSTRAINT fk_org_member_org FOREIGN KEY (organization_id) REFERENCES organizations(id),
CONSTRAINT fk_org_member_user FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE INDEX IF NOT EXISTS idx_org_member_org ON organization_member(organization_id);
CREATE INDEX IF NOT EXISTS idx_org_member_user ON organization_member(user_id);

-- TenantWorkerMap 테이블 (복합 PK)
-- Shared Tenant 접근 관리
CREATE TABLE IF NOT EXISTS tenant_worker_map (
tenant_id BIGINT NOT NULL,
worker_id BIGINT NOT NULL,
access_scope VARCHAR(30),
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by VARCHAR(100),
updated_by VARCHAR(100),
is_deleted BOOLEAN DEFAULT FALSE,

PRIMARY KEY (tenant_id, worker_id),
CONSTRAINT fk_tenant_worker_map_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_tenant_worker_map_worker FOREIGN KEY (worker_id) REFERENCES workers(id)
);

CREATE INDEX IF NOT EXISTS idx_tenant_worker_map_tenant ON tenant_worker_map(tenant_id);
CREATE INDEX IF NOT EXISTS idx_tenant_worker_map_worker ON tenant_worker_map(worker_id);
CREATE INDEX IF NOT EXISTS idx_tenant_worker_map_deleted ON tenant_worker_map(is_deleted);

-- WorkerRole 테이블 (복합 PK)
-- Worker 역할 관리: (worker_id, role_id, tenant_id)가 복합 PK
CREATE TABLE IF NOT EXISTS worker_role (
worker_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by VARCHAR(100),
updated_by VARCHAR(100),
is_deleted BOOLEAN DEFAULT FALSE,

PRIMARY KEY (worker_id, role_id, tenant_id),
CONSTRAINT fk_worker_role_worker FOREIGN KEY (worker_id) REFERENCES workers(id),
CONSTRAINT fk_worker_role_role FOREIGN KEY (role_id) REFERENCES roles(id),
CONSTRAINT fk_worker_role_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);

CREATE INDEX IF NOT EXISTS idx_worker_role_worker ON worker_role(worker_id);
CREATE INDEX IF NOT EXISTS idx_worker_role_tenant ON worker_role(tenant_id);
CREATE INDEX IF NOT EXISTS idx_worker_role_role ON worker_role(role_id);
CREATE INDEX IF NOT EXISTS idx_worker_role_deleted ON worker_role(is_deleted);

-- ========== 2. Tenant 테이블 수정 ==========
-- 설계 B: Organization ↔ Tenant 1:1 관계
-- tenant_type은 이미 존재하므로 유지
-- owner_org_id는 제거 (1:1 관계로 organization_id로 관리)

-- tenant_type 컬럼이 없으면 추가
-- MySQL 8.0.19 이전 버전 호환을 위해 프로시저 사용
SET @dbname = DATABASE();
SET @tablename = 'tenants';
SET @columnname = 'tenant_type';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(20) CHECK (tenant_type IN (''DEDICATED'',''SHARED''))')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;

-- ========== 3. 기존 데이터 마이그레이션 ==========

-- 3.1. User.tenant_id를 기반으로 Worker 생성
-- User 1:N Worker 관계 구현
-- 주의: User.tenant_id가 NULL인 경우는 제외
INSERT INTO workers (user_id, tenant_id, created_at, updated_at, created_by)
SELECT
id AS user_id,
tenant_id,
created_at,
updated_at,
created_by
FROM users
WHERE tenant_id IS NOT NULL
ON DUPLICATE KEY UPDATE
updated_at = CURRENT_TIMESTAMP;

-- 3.2. User.organization_id를 organization_member로 이관
-- 주의: User.organization_id가 NULL인 경우는 제외
INSERT INTO organization_member (organization_id, user_id, role, joined_at, created_at, updated_at)
SELECT
organization_id,
id AS user_id,
NULL AS role, -- 기존 데이터에 role 정보가 없으므로 NULL
created_at AS joined_at,
created_at,
updated_at
FROM users
WHERE organization_id IS NOT NULL
ON DUPLICATE KEY UPDATE
updated_at = CURRENT_TIMESTAMP;

-- 3.3. Dedicated Tenant의 경우 Worker가 자동으로 소속되므로 별도 작업 불필요
-- Shared Tenant의 경우 TenantWorkerMap은 수동으로 할당해야 함

-- ========== 4. 데이터 검증 쿼리 ==========

-- Worker 생성 확인
SELECT
'Workers created' AS status,
COUNT(*) AS count
FROM workers;

-- OrganizationMember 생성 확인
SELECT
'OrganizationMembers created' AS status,
COUNT(*) AS count
FROM organization_member;

-- User별 Worker 수 확인
SELECT
u.id AS user_id,
u.username,
COUNT(w.id) AS worker_count
FROM users u
LEFT JOIN workers w ON u.id = w.user_id
GROUP BY u.id, u.username
ORDER BY worker_count DESC;

-- Tenant별 Worker 수 확인
SELECT
t.id AS tenant_id,
t.tenant_key,
t.tenant_type,
COUNT(w.id) AS worker_count
FROM tenants t
LEFT JOIN workers w ON t.id = w.tenant_id
GROUP BY t.id, t.tenant_key, t.tenant_type
ORDER BY worker_count DESC;

-- ========== 5. 롤백 스크립트 (참고용) ==========
-- 주의: 실제 롤백 시에는 데이터 백업에서 복원하는 것을 권장

/*
-- 롤백 순서 (역순)
DROP TABLE IF EXISTS worker_role;
DROP TABLE IF EXISTS tenant_worker_map;
DROP TABLE IF EXISTS organization_member;
DROP TABLE IF EXISTS workers;
*/

Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ public String generateAccessToken(User user) {
claims.put(CLAIM_USERNAME, user.getUsername());
claims.put(CLAIM_EMAIL, user.getEmail());
claims.put(CLAIM_ROLE, user.getRole().name());
claims.put(CLAIM_TENANT_ID, user.getTenant() != null ? user.getTenant().getId() : null);
claims.put(CLAIM_TENANT_KEY, user.getTenant() != null ? user.getTenant().getTenantKey() : null);
// 설계 B: User는 전역 계정이므로 tenant 정보는 Worker를 통해 가져와야 함
// TODO: 현재 활성 테넌트 컨텍스트에서 가져오거나 Worker 목록에서 선택
claims.put(CLAIM_TENANT_ID, null); // TODO: Worker를 통해 현재 테넌트 정보 가져오기
claims.put(CLAIM_TENANT_KEY, null); // TODO: Worker를 통해 현재 테넌트 정보 가져오기

List<String> permissions = getUserPermissions(user);
if (!permissions.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,20 @@ public TokenResponse register(RegisterRequest request, HttpServletRequest httpRe
boolean twoFactorRequired = true; // 기본값: 2FA 필수
Status initialStatus = twoFactorRequired ? Status.PENDING : Status.ACTIVE;

// 설계 B: User는 전역 계정이므로 tenant 필드 제거
User newUser = User.builder()
.username(request.getUsername())
.email(request.getEmail())
.passwordHash(encodedPassword)
.name(request.getName())
.role(UserRole.VIEWER) // 기본 역할 부여
.status(initialStatus) // 2FA 정책에 따라 PENDING 또는 ACTIVE
.tenant(tenant)
.build();

// TODO: 설계 B - 테넌트가 제공된 경우 Worker를 생성해야 함
// if (tenant != null) {
// workerService.createWorker(newUser.getId(), tenant.getId());
// }

savedUser = userService.saveUser(newUser);
log.info("[AuthenticationService] register - User registered successfully: {}", savedUser.getUsername());
Expand Down Expand Up @@ -363,13 +368,15 @@ public UserInfoResponse getCurrentUser(String username) {
// 권한 목록 추출 (임시로 빈 리스트)
List<String> permissions = List.of();

// 설계 B: User는 전역 계정이므로 tenant 정보는 Worker를 통해 가져와야 함
// TODO: 현재 활성 테넌트 컨텍스트에서 가져오거나 Worker 목록에서 선택
return UserInfoResponse.builder()
.username(user.getUsername())
.email(user.getEmail())
.name(user.getName())
.role(user.getRole().name())
.tenantId(user.getTenant() != null ? user.getTenant().getId() : null)
.tenantKey(user.getTenant() != null ? user.getTenant().getTenantKey() : null)
.tenantId(null) // TODO: Worker를 통해 현재 테넌트 정보 가져오기
.tenantKey(null) // TODO: Worker를 통해 현재 테넌트 정보 가져오기
.permissions(permissions)
.lastLogin(user.getLastLogin())
.twoFactorEnabled(user.getTwoFactorEnabled())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package com.agenticcp.core.domain.cloud.controller;

import com.agenticcp.core.common.dto.exception.ApiResponse;
import com.agenticcp.core.domain.cloud.dto.CloudResourceWorkerMapResponse;
import com.agenticcp.core.domain.cloud.service.CloudResourceWorkerService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Positive;
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.List;
import java.util.stream.Collectors;

/**
* 클라우드 리소스-Worker 관리 컨트롤러
*
* <p>클라우드 리소스와 Worker 간의 관계를 관리하는 API입니다.
* 설계 C 기준: 리소스 단위로 Worker 접근 권한을 관리합니다.</p>
*
* @author AgenticCP Team
* @version 1.0.0
* @since 2025-12-19
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/cloud-resources/{resourceId}/workers")
@RequiredArgsConstructor
@Tag(name = "Cloud Resource Worker Management", description = "클라우드 리소스-Worker 관리 API")
public class CloudResourceWorkerController {

private final CloudResourceWorkerService cloudResourceWorkerService;

/**
* 클라우드 리소스의 Worker 목록 조회
*
* @param resourceId 클라우드 리소스 ID
* @return CloudResourceWorkerMap 목록
*/
@GetMapping
@Operation(
summary = "클라우드 리소스의 Worker 목록 조회",
description = "특정 클라우드 리소스에 할당된 Worker 목록을 조회합니다."
)
@io.swagger.v3.oas.annotations.responses.ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공",
content = @Content(schema = @Schema(implementation = CloudResourceWorkerMapResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "클라우드 리소스를 찾을 수 없음")
})
public ResponseEntity<ApiResponse<List<CloudResourceWorkerMapResponse>>> getWorkers(
@Parameter(description = "클라우드 리소스 ID", required = true, example = "1")
@PathVariable @Positive Long resourceId) {
log.info("[CloudResourceWorkerController] getWorkers - resourceId={}", resourceId);

List<CloudResourceWorkerMapResponse> responses = cloudResourceWorkerService.findByResourceId(resourceId)
.stream()
.map(CloudResourceWorkerMapResponse::from)
.collect(Collectors.toList());

return ResponseEntity.ok(ApiResponse.success(responses, "Worker 목록을 성공적으로 조회했습니다."));
}

/**
* 클라우드 리소스에 Worker 할당
*
* @param resourceId 클라우드 리소스 ID
* @param workerId Worker ID
* @return 할당된 CloudResourceWorkerMap 정보
*/
@PostMapping("/{workerId}")
@Operation(
summary = "클라우드 리소스에 Worker 할당",
description = "클라우드 리소스에 Worker를 할당합니다."
)
@io.swagger.v3.oas.annotations.responses.ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "Worker 할당 성공",
content = @Content(schema = @Schema(implementation = CloudResourceWorkerMapResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "클라우드 리소스 또는 Worker를 찾을 수 없음"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "이미 할당된 Worker")
})
public ResponseEntity<ApiResponse<CloudResourceWorkerMapResponse>> assignWorker(
@Parameter(description = "클라우드 리소스 ID", required = true, example = "1")
@PathVariable @Positive Long resourceId,
@Parameter(description = "Worker ID", required = true, example = "1")
@PathVariable @Positive Long workerId) {
log.info("[CloudResourceWorkerController] assignWorker - resourceId={}, workerId={}",
resourceId, workerId);

CloudResourceWorkerMapResponse response = CloudResourceWorkerMapResponse.from(
cloudResourceWorkerService.assignWorkerToResource(resourceId, workerId));

return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(response, "Worker가 성공적으로 할당되었습니다."));
}

/**
* 클라우드 리소스에서 Worker 제거
*
* @param resourceId 클라우드 리소스 ID
* @param workerId Worker ID
*/
@DeleteMapping("/{workerId}")
@Operation(
summary = "클라우드 리소스에서 Worker 제거",
description = "클라우드 리소스에서 Worker를 제거합니다."
)
@io.swagger.v3.oas.annotations.responses.ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "Worker 제거 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "클라우드 리소스 또는 Worker를 찾을 수 없음")
})
public ResponseEntity<Void> removeWorker(
@Parameter(description = "클라우드 리소스 ID", required = true, example = "1")
@PathVariable @Positive Long resourceId,
@Parameter(description = "Worker ID", required = true, example = "1")
@PathVariable @Positive Long workerId) {
log.info("[CloudResourceWorkerController] removeWorker - resourceId={}, workerId={}",
resourceId, workerId);

cloudResourceWorkerService.removeWorkerFromResource(resourceId, workerId);

return ResponseEntity.noContent().build();
}
}

Loading