Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Loading