From 7fde3c0a87bc987c4123ec975e1a3d01e1541617 Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Tue, 14 Oct 2025 11:35:07 +0900 Subject: [PATCH 01/32] =?UTF-8?q?build:=20open=20feign=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pom.xml b/pom.xml index 7ca3507e8..7ba0a8399 100644 --- a/pom.xml +++ b/pom.xml @@ -175,6 +175,13 @@ h2 test + + + + org.springframework.cloud + spring-cloud-starter-openfeign + 4.1.3 + org.testcontainers From ac7d8f2305bf3f97ca151a56d1754335f0a7d9f7 Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Tue, 14 Oct 2025 11:35:31 +0900 Subject: [PATCH 02/32] =?UTF-8?q?feat:=20=EC=95=A0=ED=94=8C=EB=A6=AC?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=85=98=EC=97=90=EC=84=9C=20openfeign=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/agenticcp/core/AgenticCpCoreApplication.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/agenticcp/core/AgenticCpCoreApplication.java b/src/main/java/com/agenticcp/core/AgenticCpCoreApplication.java index a423dc06d..ff8d6ed0d 100644 --- a/src/main/java/com/agenticcp/core/AgenticCpCoreApplication.java +++ b/src/main/java/com/agenticcp/core/AgenticCpCoreApplication.java @@ -2,12 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication @EnableJpaRepositories @EnableAsync +@EnableFeignClients public class AgenticCpCoreApplication { public static void main(String[] args) { From 4822282beb0d8afa636790f45e21be7e7a600916 Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Tue, 14 Oct 2025 11:35:58 +0900 Subject: [PATCH 03/32] =?UTF-8?q?feat:=20SampleClient=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tenant/controller/cloud/SampleClient.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/SampleClient.java diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/SampleClient.java b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/SampleClient.java new file mode 100644 index 000000000..fdc94a108 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/SampleClient.java @@ -0,0 +1,20 @@ +package com.agenticcp.core.domain.tenant.controller.cloud; + +import com.agenticcp.core.domain.tenant.controller.cloud.dto.CloudResourceResult; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@FeignClient( + name = "", + url = "" +) +public interface SampleClient { + + @PostMapping("/api/{samplePathVariable}") + CloudResourceResult createVpc( + @PathVariable String samplePathVariable, + @RequestBody String sampleRequestBody + ); +} From f9bb453cb499a02110453068a98a7a44b02d7f54 Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Tue, 14 Oct 2025 12:00:24 +0900 Subject: [PATCH 04/32] feat: vpc, subnet, sg requestDto --- .../controller/cloud/CloudProviderType.java | 11 +++++++++++ .../cloud/dto/SecurityGroupRequest.java | 19 +++++++++++++++++++ .../controller/cloud/dto/SubnetRequest.java | 17 +++++++++++++++++ .../controller/cloud/dto/VpcRequest.java | 10 ++++++++++ 4 files changed, 57 insertions(+) create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/CloudProviderType.java create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/SecurityGroupRequest.java create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/SubnetRequest.java create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/VpcRequest.java diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/CloudProviderType.java b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/CloudProviderType.java new file mode 100644 index 000000000..149c570ad --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/CloudProviderType.java @@ -0,0 +1,11 @@ +package com.agenticcp.core.domain.tenant.controller.cloud; + +import lombok.Getter; + +@Getter +public enum CloudProviderType { + AWS, + AZURE, + GCP, + OTHER +} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/SecurityGroupRequest.java b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/SecurityGroupRequest.java new file mode 100644 index 000000000..6a2717b8f --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/SecurityGroupRequest.java @@ -0,0 +1,19 @@ +package com.agenticcp.core.domain.tenant.controller.cloud.dto; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record SecurityGroupRequest( + String vpcId, + String name, + List securityGroupRules +) { + public record SecurityGroupRule( + String protocol, + String portRange, + String cidrIp + ) { + } +} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/SubnetRequest.java b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/SubnetRequest.java new file mode 100644 index 000000000..5831841bf --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/SubnetRequest.java @@ -0,0 +1,17 @@ +package com.agenticcp.core.domain.tenant.controller.cloud.dto; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record SubnetRequest( + String vpcId, + List subnets +) { + public record SubnetConfig( + String subnetId, + String availabilityZone + ) { + } +} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/VpcRequest.java b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/VpcRequest.java new file mode 100644 index 000000000..e36c6cb0f --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/VpcRequest.java @@ -0,0 +1,10 @@ +package com.agenticcp.core.domain.tenant.controller.cloud.dto; + +import java.util.Map; + +public record VpcRequest( + String cidrBlock, + String region, + Map tags +) { +} From 0999889667c74f2b89160f88606e364823cf22cb Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Tue, 14 Oct 2025 12:05:16 +0900 Subject: [PATCH 05/32] =?UTF-8?q?feat:=20CloudProviderService=20interface?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cloud/service/CloudProviderService.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/CloudProviderService.java diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/CloudProviderService.java b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/CloudProviderService.java new file mode 100644 index 000000000..33e94882f --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/CloudProviderService.java @@ -0,0 +1,24 @@ +package com.agenticcp.core.domain.tenant.controller.cloud.service; + + +import com.agenticcp.core.domain.tenant.controller.cloud.CloudProviderType; +import com.agenticcp.core.domain.tenant.controller.cloud.dto.CloudResourceResult; +import com.agenticcp.core.domain.tenant.controller.cloud.dto.SecurityGroupRequest; +import com.agenticcp.core.domain.tenant.controller.cloud.dto.SubnetRequest; +import com.agenticcp.core.domain.tenant.controller.cloud.dto.VpcRequest; + +public interface CloudProviderService { + + CloudProviderType getCloudProviderType(); + + CloudResourceResult createVpc(String tenantKey, VpcRequest vpcRequest); + + CloudResourceResult createSubnets(String tenantKey, SubnetRequest subnetRequest); + + CloudResourceResult createSecurityGroups(String tenantKey, SecurityGroupRequest securityGroupRequest); + + + + + +} From 77ca004319be695c00282e5cbd25b3f9b2f484fc Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Tue, 14 Oct 2025 12:05:49 +0900 Subject: [PATCH 06/32] =?UTF-8?q?feat:=20SampleCloudProviderService=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/SampleCloudProviderService.java | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/SampleCloudProviderService.java diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/SampleCloudProviderService.java b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/SampleCloudProviderService.java new file mode 100644 index 000000000..e6b983893 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/SampleCloudProviderService.java @@ -0,0 +1,64 @@ +package com.agenticcp.core.domain.tenant.controller.cloud.service; + +import com.agenticcp.core.domain.tenant.controller.cloud.CloudProviderType; +import com.agenticcp.core.domain.tenant.controller.cloud.dto.CloudResourceResult; +import com.agenticcp.core.domain.tenant.controller.cloud.dto.SecurityGroupRequest; +import com.agenticcp.core.domain.tenant.controller.cloud.dto.SubnetRequest; +import com.agenticcp.core.domain.tenant.controller.cloud.dto.VpcRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class SampleCloudProviderService implements CloudProviderService{ + + + @Override + public CloudProviderType getCloudProviderType() { + return CloudProviderType.OTHER; + } + + @Override + public CloudResourceResult createVpc(String tenantKey, VpcRequest vpcRequest) { + + + return CloudResourceResult.builder() + .resourceId(tenantKey) + .metadata(Map.of( + "name", tenantKey, + "cidrBlock", vpcRequest.cidrBlock(), + "region", vpcRequest.region())) + .build(); + } + + + + @Override + public CloudResourceResult createSubnets(String tenantKey, SubnetRequest subnetRequest) { + + + return CloudResourceResult.builder() + .resourceId(tenantKey) + .metadata(Map.of( + "name", tenantKey, + "vpcId", subnetRequest.vpcId(), + "subnets", subnetRequest.subnets().stream() + .map(subnet -> Map.of( + "subnetId", subnet.subnetId(), + "availabilityZone", subnet.availabilityZone() + )) + .toList() + )) + .build(); + } + + @Override + public CloudResourceResult createSecurityGroups(String tenantKey, SecurityGroupRequest securityGroupRequest){ + + return CloudResourceResult.builder() + .resourceId() + } + +} From 46fade21a85b6b43e249c758c87b5d9bbbb24d3f Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Tue, 14 Oct 2025 12:06:08 +0900 Subject: [PATCH 07/32] =?UTF-8?q?feat:=20=ED=85=8C=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=A9=EB=A6=AC=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EC=99=80=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=EB=84=88=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/TenantIsolationAppliedEvent.java | 18 ++++++++++++++ .../TenantIsolationEventListener.java | 24 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/event/TenantIsolationAppliedEvent.java create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/listener/TenantIsolationEventListener.java diff --git a/src/main/java/com/agenticcp/core/domain/tenant/event/TenantIsolationAppliedEvent.java b/src/main/java/com/agenticcp/core/domain/tenant/event/TenantIsolationAppliedEvent.java new file mode 100644 index 000000000..f4ca986a8 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/event/TenantIsolationAppliedEvent.java @@ -0,0 +1,18 @@ +package com.agenticcp.core.domain.tenant.event; + +import com.agenticcp.core.domain.tenant.entity.TenantIsolation; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class TenantIsolationAppliedEvent extends ApplicationEvent { + + private final String tenantKey; + private final TenantIsolation isolationLevel; + + public TenantIsolationAppliedEvent(Object source, String tenantKey, TenantIsolation isolationLevel) { + super(source); + this.tenantKey = tenantKey; + this.isolationLevel = isolationLevel; + } +} \ No newline at end of file diff --git a/src/main/java/com/agenticcp/core/domain/tenant/listener/TenantIsolationEventListener.java b/src/main/java/com/agenticcp/core/domain/tenant/listener/TenantIsolationEventListener.java new file mode 100644 index 000000000..e8854b190 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/listener/TenantIsolationEventListener.java @@ -0,0 +1,24 @@ +package com.agenticcp.core.domain.tenant.listener; + +import com.agenticcp.core.domain.tenant.event.TenantIsolationAppliedEvent; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class TenantIsolationEventListener { + + @EventListener + @Async("tenantTaskExecutor") + public void handleTenantIsolationAppliedEvent(TenantIsolationAppliedEvent event) { + // 현재 리스너는 로깅용으로 사용, 추후 알림 발송 등으로 확장 + String tenantKey = event.getTenantKey(); + String isolationLevel = event.getIsolationLevel(); + + log.info("tenant isolation applied - tenantKey={}, isolationLevel={}", tenantKey, isolationLevel + ""); + + } + +} From 41b7420dfa096211d20f9f69ea2894f39f9e09d1 Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Tue, 14 Oct 2025 12:06:55 +0900 Subject: [PATCH 08/32] =?UTF-8?q?feat:=20TenantIsolationService=20?= =?UTF-8?q?=ED=85=8C=EB=84=8C=ED=8A=B8=20=EA=B2=A9=EB=A6=AC=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EC=84=9C=EB=B9=84=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/TenantIsolationService.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/service/TenantIsolationService.java diff --git a/src/main/java/com/agenticcp/core/domain/tenant/service/TenantIsolationService.java b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantIsolationService.java new file mode 100644 index 000000000..cf311f955 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantIsolationService.java @@ -0,0 +1,29 @@ +package com.agenticcp.core.domain.tenant.service; + +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.tenant.entity.TenantIsolation; +import com.agenticcp.core.domain.tenant.event.TenantIsolationAppliedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TenantIsolationService { + + private final TenantService tenantService; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public void applyIsolationPolicy(String tenantKey, TenantIsolation isolation) { + Tenant tenant = tenantService.getTenantByKey(tenantKey) + .orElseThrow(() -> new IllegalStateException("Invalid tenant key: " + tenantKey)); + switch (isolation.getIsolationLevel()) { + case SHARED -> + + } + eventPublisher.publishEvent(new TenantIsolationAppliedEvent(this, tenantKey, isolation)); + + } +} From c5517849418d3fe00b733d252346560445c76dbc Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Tue, 14 Oct 2025 12:09:45 +0900 Subject: [PATCH 09/32] =?UTF-8?q?feat:=20CloudResourceResult=20Dto=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/cloud/dto/CloudResourceResult.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/CloudResourceResult.java diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/CloudResourceResult.java b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/CloudResourceResult.java new file mode 100644 index 000000000..4e4267daa --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/CloudResourceResult.java @@ -0,0 +1,12 @@ +package com.agenticcp.core.domain.tenant.controller.cloud.dto; + +import lombok.Builder; + +import java.util.Map; + +@Builder +public record CloudResourceResult( + String resourceId, + Map metadata +) { +} From 9dfbe0b165cec9bb81c7b9e9a89b305a4a4693dd Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Wed, 15 Oct 2025 19:38:06 +0900 Subject: [PATCH 10/32] =?UTF-8?q?feat:=20SampleCloudProviderService=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=EA=B7=B8=EB=A3=B9=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cloud/service/SampleCloudProviderService.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/SampleCloudProviderService.java b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/SampleCloudProviderService.java index e6b983893..1d5ae13ef 100644 --- a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/SampleCloudProviderService.java +++ b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/SampleCloudProviderService.java @@ -58,7 +58,20 @@ public CloudResourceResult createSubnets(String tenantKey, SubnetRequest subnetR public CloudResourceResult createSecurityGroups(String tenantKey, SecurityGroupRequest securityGroupRequest){ return CloudResourceResult.builder() - .resourceId() + .resourceId(tenantKey) + .metadata(Map.of( + "name", tenantKey, + "vpcId", securityGroupRequest.vpcId(), + "sg", securityGroupRequest.securityGroupRules().stream() + .map(sg -> Map.of( + "protocol", sg.protocol(), + "portRange", sg.portRange(), + "cidrIp", sg.cidrIp() + )) + .toList() + )) + .build(); + } } From a7459f5b883fee86fb924b1f4698758d31c8656c Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Wed, 15 Oct 2025 19:38:26 +0900 Subject: [PATCH 11/32] =?UTF-8?q?feat:=20=EA=B2=A9=EB=A6=AC=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EC=99=80=20=EC=83=81=ED=83=9C=EC=9A=A9=20DTO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tenant/adapter/dto/IsolationResult.java | 44 +++++++++++++++++++ .../tenant/adapter/dto/IsolationStatus.java | 29 ++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/adapter/dto/IsolationResult.java create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/adapter/dto/IsolationStatus.java diff --git a/src/main/java/com/agenticcp/core/domain/tenant/adapter/dto/IsolationResult.java b/src/main/java/com/agenticcp/core/domain/tenant/adapter/dto/IsolationResult.java new file mode 100644 index 000000000..c247ade9d --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/adapter/dto/IsolationResult.java @@ -0,0 +1,44 @@ +package com.agenticcp.core.domain.tenant.adapter.dto; + +import com.agenticcp.core.domain.tenant.entity.TenantIsolation; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 격리 정책 적용 결과를 담는 DTO + */ +@Builder +public record IsolationResult( + boolean success, + String message, + String tenantKey, + TenantIsolation.IsolationLevel isolationLevel, + LocalDateTime appliedAt, + Map resourceDetails, // CSP별 생성된 리소스 정보 + Map errors // 오류 정보 +) { + + public static IsolationResult success(String tenantKey, TenantIsolation.IsolationLevel level, + Map resourceDetails) { + return IsolationResult.builder() + .success(true) + .message("Isolation policy applied successfully") + .tenantKey(tenantKey) + .isolationLevel(level) + .appliedAt(LocalDateTime.now()) + .resourceDetails(resourceDetails) + .build(); + } + + public static IsolationResult failure(String tenantKey, String message, Map errors) { + return IsolationResult.builder() + .success(false) + .message(message) + .tenantKey(tenantKey) + .appliedAt(LocalDateTime.now()) + .errors(errors) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/agenticcp/core/domain/tenant/adapter/dto/IsolationStatus.java b/src/main/java/com/agenticcp/core/domain/tenant/adapter/dto/IsolationStatus.java new file mode 100644 index 000000000..b7d42310d --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/adapter/dto/IsolationStatus.java @@ -0,0 +1,29 @@ +package com.agenticcp.core.domain.tenant.adapter.dto; + +import com.agenticcp.core.domain.tenant.entity.TenantIsolation; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 테넌트의 격리 상태 정보를 담는 DTO + */ +@Builder +public record IsolationStatus( + String tenantKey, + TenantIsolation.IsolationLevel isolationLevel, + boolean isActive, + LocalDateTime lastUpdated, + Map resourceStatus, // CSP별 리소스 상태 + Map configuration // 현재 설정 정보 +) { + + public static IsolationStatus inactive(String tenantKey) { + return IsolationStatus.builder() + .tenantKey(tenantKey) + .isActive(false) + .lastUpdated(LocalDateTime.now()) + .build(); + } +} \ No newline at end of file From 35db24ef4d9dd41954d21bfe634c074a51d2fe5b Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Wed, 15 Oct 2025 19:38:48 +0900 Subject: [PATCH 12/32] =?UTF-8?q?feat:=20=ED=85=8C=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=A9=EB=A6=AC=20=EC=A0=95=EC=B1=85=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/TenantIsolationAdapter.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/adapter/TenantIsolationAdapter.java diff --git a/src/main/java/com/agenticcp/core/domain/tenant/adapter/TenantIsolationAdapter.java b/src/main/java/com/agenticcp/core/domain/tenant/adapter/TenantIsolationAdapter.java new file mode 100644 index 000000000..2ec53935c --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/adapter/TenantIsolationAdapter.java @@ -0,0 +1,51 @@ +package com.agenticcp.core.domain.tenant.adapter; + +import com.agenticcp.core.domain.tenant.entity.TenantIsolation; +import com.agenticcp.core.domain.tenant.adapter.dto.IsolationResult; +import com.agenticcp.core.domain.tenant.adapter.dto.IsolationStatus; + +/** + * 테넌트 격리 정책을 적용하는 핵심 인터페이스 + * Adapter 패턴의 Target Interface 역할 + */ +public interface TenantIsolationAdapter { + + /** + * 테넌트에 격리 정책을 적용합니다. + * + * @param tenantKey 테넌트 키 + * @param isolation 격리 정책 정보 + * @return 격리 정책 적용 결과 + */ + IsolationResult applyIsolationPolicy(String tenantKey, TenantIsolation isolation); + + /** + * 테넌트의 격리 정책을 제거합니다. + * + * @param tenantKey 테넌트 키 + */ + void removeIsolationPolicy(String tenantKey); + + /** + * 테넌트의 현재 격리 상태를 조회합니다. + * + * @param tenantKey 테넌트 키 + * @return 격리 상태 정보 + */ + IsolationStatus getIsolationStatus(String tenantKey); + + /** + * 특정 격리 레벨을 지원하는지 확인합니다. + * + * @param isolationLevel 격리 레벨 + * @return 지원 여부 + */ + boolean supportsIsolationLevel(TenantIsolation.IsolationLevel isolationLevel); + + /** + * 이 Adapter가 지원하는 클라우드 프로바이더 타입을 반환합니다. + * + * @return 클라우드 프로바이더 타입 + */ + String getSupportedCloudProvider(); +} \ No newline at end of file From e08f2c811910501adc19f41aa52f0419cf9948b8 Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Wed, 15 Oct 2025 19:38:58 +0900 Subject: [PATCH 13/32] =?UTF-8?q?feat:=20=ED=85=8C=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=A9=EB=A6=AC=20=EC=A0=95=EC=B1=85=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=83=9D=EC=84=B1=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=ED=8C=A9=ED=86=A0=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TenantIsolationAdapterFactory.java | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/adapter/TenantIsolationAdapterFactory.java diff --git a/src/main/java/com/agenticcp/core/domain/tenant/adapter/TenantIsolationAdapterFactory.java b/src/main/java/com/agenticcp/core/domain/tenant/adapter/TenantIsolationAdapterFactory.java new file mode 100644 index 000000000..abf97218b --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/adapter/TenantIsolationAdapterFactory.java @@ -0,0 +1,64 @@ +package com.agenticcp.core.domain.tenant.adapter; + +import com.agenticcp.core.domain.tenant.controller.cloud.CloudProviderType; +import com.agenticcp.core.domain.tenant.entity.TenantIsolation; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * TenantIsolationAdapter 인스턴스를 생성하는 Factory + * Adapter 패턴의 Factory 역할 + */ +@Component +public class TenantIsolationAdapterFactory { + + private final Map adapters; + + public TenantIsolationAdapterFactory(List adapterList) { + this.adapters = adapterList.stream() + .collect(Collectors.toMap( + adapter -> CloudProviderType.valueOf(adapter.getSupportedCloudProvider()), + Function.identity() + )); + } + + /** + * 클라우드 프로바이더 타입에 해당하는 Adapter를 반환합니다. + * + * @param providerType 클라우드 프로바이더 타입 + * @return 해당하는 Adapter 인스턴스 + * @throws IllegalArgumentException 지원하지 않는 프로바이더 타입인 경우 + */ + public TenantIsolationAdapter getAdapter(CloudProviderType providerType) { + TenantIsolationAdapter adapter = adapters.get(providerType); + if (adapter == null) { + throw new IllegalArgumentException("Unsupported cloud provider type: " + providerType); + } + return adapter; + } + + /** + * 특정 격리 레벨을 지원하는 Adapter 목록을 반환합니다. + * + * @param isolationLevel 격리 레벨 + * @return 해당 격리 레벨을 지원하는 Adapter 목록 + */ + public List getAdaptersSupporting(TenantIsolation.IsolationLevel isolationLevel) { + return adapters.values().stream() + .filter(adapter -> adapter.supportsIsolationLevel(isolationLevel)) + .toList(); + } + + /** + * 사용 가능한 모든 Adapter 목록을 반환합니다. + * + * @return 모든 Adapter 목록 + */ + public List getAllAdapters() { + return List.copyOf(adapters.values()); + } +} \ No newline at end of file From 0188b520b520214851f111767df2331b47496972 Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Wed, 15 Oct 2025 19:40:20 +0900 Subject: [PATCH 14/32] =?UTF-8?q?feat:=20TenantIsolationService=EC=97=90?= =?UTF-8?q?=20=EA=B2=A9=EB=A6=AC=20=EC=A0=95=EC=B1=85=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=20=EB=A9=94=EC=84=9C=EB=93=9C=EA=B9=8C=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/TenantIsolationService.java | 191 +++++++++++++++++- 1 file changed, 185 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/agenticcp/core/domain/tenant/service/TenantIsolationService.java b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantIsolationService.java index cf311f955..4d358c506 100644 --- a/src/main/java/com/agenticcp/core/domain/tenant/service/TenantIsolationService.java +++ b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantIsolationService.java @@ -1,29 +1,208 @@ package com.agenticcp.core.domain.tenant.service; +import com.agenticcp.core.domain.tenant.adapter.TenantIsolationAdapter; +import com.agenticcp.core.domain.tenant.adapter.TenantIsolationAdapterFactory; +import com.agenticcp.core.domain.tenant.adapter.dto.IsolationResult; +import com.agenticcp.core.domain.tenant.adapter.dto.IsolationStatus; +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.common.enums.CommonErrorCode; +import com.agenticcp.core.domain.tenant.controller.cloud.CloudProviderType; import com.agenticcp.core.domain.tenant.entity.Tenant; import com.agenticcp.core.domain.tenant.entity.TenantIsolation; import com.agenticcp.core.domain.tenant.event.TenantIsolationAppliedEvent; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + +@Slf4j @Service @RequiredArgsConstructor public class TenantIsolationService { private final TenantService tenantService; + private final TenantIsolationAdapterFactory adapterFactory; private final ApplicationEventPublisher eventPublisher; + /** + * 테넌트에 격리 정책을 적용합니다. + * + * @param tenantKey 테넌트 키 + * @param isolation 격리 정책 정보 + * @param cloudProviderType 클라우드 프로바이더 타입 + * @return 격리 정책 적용 결과 + * @throws BusinessException 테넌트를 찾을 수 없거나 격리 정책 적용에 실패한 경우 + */ + @Transactional + public IsolationResult applyIsolationPolicy(String tenantKey, TenantIsolation isolation, + CloudProviderType cloudProviderType) { + try { + // 1. 테넌트 존재 여부 확인 + Tenant tenant = tenantService.getTenantByKey(tenantKey) + .orElseThrow(() -> new BusinessException(CommonErrorCode.NOT_FOUND, + "테넌트를 찾을 수 없습니다: " + tenantKey)); + + // 2. 적절한 Adapter 선택 + TenantIsolationAdapter adapter = adapterFactory.getAdapter(cloudProviderType); + + // 3. 격리 레벨 지원 여부 확인 + if (!adapter.supportsIsolationLevel(isolation.getIsolationLevel())) { + throw new BusinessException(CommonErrorCode.BAD_REQUEST, + "격리 레벨 " + isolation.getIsolationLevel() + "은(는) " + cloudProviderType + "에서 지원되지 않습니다."); + } + + // 4. 격리 정책 적용 + log.info("격리 정책 적용 시작 - 테넌트: {}, 레벨: {}, 프로바이더: {}", + tenantKey, isolation.getIsolationLevel(), cloudProviderType); + + IsolationResult result = adapter.applyIsolationPolicy(tenantKey, isolation); + + // 5. 성공 시 이벤트 발행 (TenantIsolationEventListener가 비동기로 처리) + if (result.success()) { + eventPublisher.publishEvent(new TenantIsolationAppliedEvent(this, tenantKey, isolation)); + log.info("격리 정책이 성공적으로 적용되었습니다 - 테넌트: {}", tenantKey); + } else { + log.error("격리 정책 적용 실패 - 테넌트: {}, 사유: {}", + tenantKey, result.message()); + throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR, + "격리 정책 적용에 실패했습니다: " + result.message()); + } + + return result; + + } catch (BusinessException e) { + // BusinessException은 그대로 재던지기 + throw e; + } catch (Exception e) { + log.error("격리 정책 적용 중 오류 발생 - 테넌트: {}", tenantKey, e); + throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR, + "격리 정책 적용 중 오류가 발생했습니다: " + e.getMessage()); + } + } + + /** + * 테넌트의 격리 정책을 제거합니다. + * + * @param tenantKey 테넌트 키 + * @param cloudProviderType 클라우드 프로바이더 타입 + * @throws BusinessException 격리 정책 제거에 실패한 경우 + */ @Transactional - public void applyIsolationPolicy(String tenantKey, TenantIsolation isolation) { - Tenant tenant = tenantService.getTenantByKey(tenantKey) - .orElseThrow(() -> new IllegalStateException("Invalid tenant key: " + tenantKey)); - switch (isolation.getIsolationLevel()) { - case SHARED -> + public void removeIsolationPolicy(String tenantKey, CloudProviderType cloudProviderType) { + try { + TenantIsolationAdapter adapter = adapterFactory.getAdapter(cloudProviderType); + + log.info("격리 정책 제거 시작 - 테넌트: {}, 프로바이더: {}", + tenantKey, cloudProviderType); + + adapter.removeIsolationPolicy(tenantKey); + + log.info("격리 정책이 성공적으로 제거되었습니다 - 테넌트: {}", tenantKey); + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("격리 정책 제거 중 오류 발생 - 테넌트: {}", tenantKey, e); + throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR, + "격리 정책 제거 중 오류가 발생했습니다: " + e.getMessage()); + } + } + + /** + * 테넌트의 격리 상태를 조회합니다. + * + * @param tenantKey 테넌트 키 + * @param cloudProviderType 클라우드 프로바이더 타입 + * @return 격리 상태 정보 + * @throws BusinessException 격리 상태 조회에 실패한 경우 + */ + public IsolationStatus getIsolationStatus(String tenantKey, CloudProviderType cloudProviderType) { + try { + TenantIsolationAdapter adapter = adapterFactory.getAdapter(cloudProviderType); + return adapter.getIsolationStatus(tenantKey); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("격리 상태 조회 중 오류 발생 - 테넌트: {}", tenantKey, e); + throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR, + "격리 상태 조회 중 오류가 발생했습니다: " + e.getMessage()); + } + } + + /** + * 특정 격리 레벨을 지원하는 클라우드 프로바이더 목록을 조회합니다. + * + * @param isolationLevel 격리 레벨 + * @return 지원하는 클라우드 프로바이더 목록 + * @throws BusinessException 지원 프로바이더 조회에 실패한 경우 + */ + public List getSupportedProviders(TenantIsolation.IsolationLevel isolationLevel) { + try { + return adapterFactory.getAdaptersSupporting(isolationLevel).stream() + .map(adapter -> CloudProviderType.valueOf(adapter.getSupportedCloudProvider())) + .collect(java.util.stream.Collectors.toList()); + } catch (Exception e) { + log.error("지원하는 클라우드 프로바이더 조회 중 오류 발생 - 격리 레벨: {}", isolationLevel, e); + throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR, + "지원하는 클라우드 프로바이더 조회 중 오류가 발생했습니다: " + e.getMessage()); + } + } + /** + * 테넌트의 격리 정책을 업데이트합니다. + * 기존 정책을 제거하고 새로운 정책을 적용합니다. + * + * @param tenantKey 테넌트 키 + * @param newIsolation 새로운 격리 정책 정보 + * @param cloudProviderType 클라우드 프로바이더 타입 + * @return 격리 정책 적용 결과 + * @throws BusinessException 격리 정책 업데이트에 실패한 경우 + */ + @Transactional + public IsolationResult updateIsolationPolicy(String tenantKey, TenantIsolation newIsolation, + CloudProviderType cloudProviderType) { + try { + log.info("격리 정책 업데이트 시작 - 테넌트: {}, 프로바이더: {}", tenantKey, cloudProviderType); + + // 1. 기존 격리 정책 제거 + try { + removeIsolationPolicy(tenantKey, cloudProviderType); + } catch (BusinessException e) { + // 기존 정책이 없는 경우는 정상적인 상황일 수 있음 + log.warn("기존 격리 정책 제거 중 오류 발생 (무시됨) - 테넌트: {}, 오류: {}", + tenantKey, e.getMessage()); + } + + // 2. 새로운 격리 정책 적용 + return applyIsolationPolicy(tenantKey, newIsolation, cloudProviderType); + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("격리 정책 업데이트 중 오류 발생 - 테넌트: {}", tenantKey, e); + throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR, + "격리 정책 업데이트 중 오류가 발생했습니다: " + e.getMessage()); } - eventPublisher.publishEvent(new TenantIsolationAppliedEvent(this, tenantKey, isolation)); + } + /** + * 테넌트의 격리 정책이 적용되어 있는지 확인합니다. + * + * @param tenantKey 테넌트 키 + * @param cloudProviderType 클라우드 프로바이더 타입 + * @return 격리 정책 적용 여부 + */ + public boolean isIsolationPolicyApplied(String tenantKey, CloudProviderType cloudProviderType) { + try { + IsolationStatus status = getIsolationStatus(tenantKey, cloudProviderType); + return status.isActive(); + } catch (Exception e) { + log.warn("격리 정책 적용 여부 확인 중 오류 발생 - 테넌트: {}, 오류: {}", + tenantKey, e.getMessage()); + return false; + } } } From 4143d4745ba47d6f117886d90fe289f2036e2576 Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Fri, 24 Oct 2025 16:20:35 +0900 Subject: [PATCH 15/32] =?UTF-8?q?feat:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tenant/controller/cloud/SampleClient.java | 20 ----- .../cloud/dto/SecurityGroupRequest.java | 19 ----- .../controller/cloud/dto/SubnetRequest.java | 17 ---- .../controller/cloud/dto/VpcRequest.java | 10 --- .../cloud/service/CloudProviderService.java | 24 ------ .../service/SampleCloudProviderService.java | 77 ------------------- 6 files changed, 167 deletions(-) delete mode 100644 src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/SampleClient.java delete mode 100644 src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/SecurityGroupRequest.java delete mode 100644 src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/SubnetRequest.java delete mode 100644 src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/VpcRequest.java delete mode 100644 src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/CloudProviderService.java delete mode 100644 src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/SampleCloudProviderService.java diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/SampleClient.java b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/SampleClient.java deleted file mode 100644 index fdc94a108..000000000 --- a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/SampleClient.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.agenticcp.core.domain.tenant.controller.cloud; - -import com.agenticcp.core.domain.tenant.controller.cloud.dto.CloudResourceResult; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; - -@FeignClient( - name = "", - url = "" -) -public interface SampleClient { - - @PostMapping("/api/{samplePathVariable}") - CloudResourceResult createVpc( - @PathVariable String samplePathVariable, - @RequestBody String sampleRequestBody - ); -} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/SecurityGroupRequest.java b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/SecurityGroupRequest.java deleted file mode 100644 index 6a2717b8f..000000000 --- a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/SecurityGroupRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.agenticcp.core.domain.tenant.controller.cloud.dto; - -import lombok.Builder; - -import java.util.List; - -@Builder -public record SecurityGroupRequest( - String vpcId, - String name, - List securityGroupRules -) { - public record SecurityGroupRule( - String protocol, - String portRange, - String cidrIp - ) { - } -} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/SubnetRequest.java b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/SubnetRequest.java deleted file mode 100644 index 5831841bf..000000000 --- a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/SubnetRequest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.agenticcp.core.domain.tenant.controller.cloud.dto; - -import lombok.Builder; - -import java.util.List; - -@Builder -public record SubnetRequest( - String vpcId, - List subnets -) { - public record SubnetConfig( - String subnetId, - String availabilityZone - ) { - } -} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/VpcRequest.java b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/VpcRequest.java deleted file mode 100644 index e36c6cb0f..000000000 --- a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/VpcRequest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.agenticcp.core.domain.tenant.controller.cloud.dto; - -import java.util.Map; - -public record VpcRequest( - String cidrBlock, - String region, - Map tags -) { -} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/CloudProviderService.java b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/CloudProviderService.java deleted file mode 100644 index 33e94882f..000000000 --- a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/CloudProviderService.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.agenticcp.core.domain.tenant.controller.cloud.service; - - -import com.agenticcp.core.domain.tenant.controller.cloud.CloudProviderType; -import com.agenticcp.core.domain.tenant.controller.cloud.dto.CloudResourceResult; -import com.agenticcp.core.domain.tenant.controller.cloud.dto.SecurityGroupRequest; -import com.agenticcp.core.domain.tenant.controller.cloud.dto.SubnetRequest; -import com.agenticcp.core.domain.tenant.controller.cloud.dto.VpcRequest; - -public interface CloudProviderService { - - CloudProviderType getCloudProviderType(); - - CloudResourceResult createVpc(String tenantKey, VpcRequest vpcRequest); - - CloudResourceResult createSubnets(String tenantKey, SubnetRequest subnetRequest); - - CloudResourceResult createSecurityGroups(String tenantKey, SecurityGroupRequest securityGroupRequest); - - - - - -} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/SampleCloudProviderService.java b/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/SampleCloudProviderService.java deleted file mode 100644 index 1d5ae13ef..000000000 --- a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/service/SampleCloudProviderService.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.agenticcp.core.domain.tenant.controller.cloud.service; - -import com.agenticcp.core.domain.tenant.controller.cloud.CloudProviderType; -import com.agenticcp.core.domain.tenant.controller.cloud.dto.CloudResourceResult; -import com.agenticcp.core.domain.tenant.controller.cloud.dto.SecurityGroupRequest; -import com.agenticcp.core.domain.tenant.controller.cloud.dto.SubnetRequest; -import com.agenticcp.core.domain.tenant.controller.cloud.dto.VpcRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.Map; - -@Service -@RequiredArgsConstructor -public class SampleCloudProviderService implements CloudProviderService{ - - - @Override - public CloudProviderType getCloudProviderType() { - return CloudProviderType.OTHER; - } - - @Override - public CloudResourceResult createVpc(String tenantKey, VpcRequest vpcRequest) { - - - return CloudResourceResult.builder() - .resourceId(tenantKey) - .metadata(Map.of( - "name", tenantKey, - "cidrBlock", vpcRequest.cidrBlock(), - "region", vpcRequest.region())) - .build(); - } - - - - @Override - public CloudResourceResult createSubnets(String tenantKey, SubnetRequest subnetRequest) { - - - return CloudResourceResult.builder() - .resourceId(tenantKey) - .metadata(Map.of( - "name", tenantKey, - "vpcId", subnetRequest.vpcId(), - "subnets", subnetRequest.subnets().stream() - .map(subnet -> Map.of( - "subnetId", subnet.subnetId(), - "availabilityZone", subnet.availabilityZone() - )) - .toList() - )) - .build(); - } - - @Override - public CloudResourceResult createSecurityGroups(String tenantKey, SecurityGroupRequest securityGroupRequest){ - - return CloudResourceResult.builder() - .resourceId(tenantKey) - .metadata(Map.of( - "name", tenantKey, - "vpcId", securityGroupRequest.vpcId(), - "sg", securityGroupRequest.securityGroupRules().stream() - .map(sg -> Map.of( - "protocol", sg.protocol(), - "portRange", sg.portRange(), - "cidrIp", sg.cidrIp() - )) - .toList() - )) - .build(); - - } - -} From 3fa5631190da4b007c7a17c7c51e7ba0a8c77ce2 Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Fri, 24 Oct 2025 16:21:26 +0900 Subject: [PATCH 16/32] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/TenantIsolationAdapter.java | 3 +- .../TenantIsolationAdapterFactory.java | 4 +- .../cloud/CloudProviderType.java | 2 +- .../cloud/dto/CloudResourceResult.java | 2 +- .../TenantIsolationEventListener.java | 4 +- .../service/TenantIsolationService.java | 49 +++++-------------- 6 files changed, 21 insertions(+), 43 deletions(-) rename src/main/java/com/agenticcp/core/domain/tenant/{controller => }/cloud/CloudProviderType.java (64%) rename src/main/java/com/agenticcp/core/domain/tenant/{controller => }/cloud/dto/CloudResourceResult.java (72%) diff --git a/src/main/java/com/agenticcp/core/domain/tenant/adapter/TenantIsolationAdapter.java b/src/main/java/com/agenticcp/core/domain/tenant/adapter/TenantIsolationAdapter.java index 2ec53935c..84d45a979 100644 --- a/src/main/java/com/agenticcp/core/domain/tenant/adapter/TenantIsolationAdapter.java +++ b/src/main/java/com/agenticcp/core/domain/tenant/adapter/TenantIsolationAdapter.java @@ -1,5 +1,6 @@ package com.agenticcp.core.domain.tenant.adapter; +import com.agenticcp.core.domain.tenant.cloud.CloudProviderType; import com.agenticcp.core.domain.tenant.entity.TenantIsolation; import com.agenticcp.core.domain.tenant.adapter.dto.IsolationResult; import com.agenticcp.core.domain.tenant.adapter.dto.IsolationStatus; @@ -47,5 +48,5 @@ public interface TenantIsolationAdapter { * * @return 클라우드 프로바이더 타입 */ - String getSupportedCloudProvider(); + CloudProviderType getSupportedCloudProvider(); } \ No newline at end of file diff --git a/src/main/java/com/agenticcp/core/domain/tenant/adapter/TenantIsolationAdapterFactory.java b/src/main/java/com/agenticcp/core/domain/tenant/adapter/TenantIsolationAdapterFactory.java index abf97218b..47242a3dc 100644 --- a/src/main/java/com/agenticcp/core/domain/tenant/adapter/TenantIsolationAdapterFactory.java +++ b/src/main/java/com/agenticcp/core/domain/tenant/adapter/TenantIsolationAdapterFactory.java @@ -1,6 +1,6 @@ package com.agenticcp.core.domain.tenant.adapter; -import com.agenticcp.core.domain.tenant.controller.cloud.CloudProviderType; +import com.agenticcp.core.domain.tenant.cloud.CloudProviderType; import com.agenticcp.core.domain.tenant.entity.TenantIsolation; import org.springframework.stereotype.Component; @@ -21,7 +21,7 @@ public class TenantIsolationAdapterFactory { public TenantIsolationAdapterFactory(List adapterList) { this.adapters = adapterList.stream() .collect(Collectors.toMap( - adapter -> CloudProviderType.valueOf(adapter.getSupportedCloudProvider()), + adapter -> adapter.getSupportedCloudProvider(), Function.identity() )); } diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/CloudProviderType.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/CloudProviderType.java similarity index 64% rename from src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/CloudProviderType.java rename to src/main/java/com/agenticcp/core/domain/tenant/cloud/CloudProviderType.java index 149c570ad..82c9b3e29 100644 --- a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/CloudProviderType.java +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/CloudProviderType.java @@ -1,4 +1,4 @@ -package com.agenticcp.core.domain.tenant.controller.cloud; +package com.agenticcp.core.domain.tenant.cloud; import lombok.Getter; diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/CloudResourceResult.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/dto/CloudResourceResult.java similarity index 72% rename from src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/CloudResourceResult.java rename to src/main/java/com/agenticcp/core/domain/tenant/cloud/dto/CloudResourceResult.java index 4e4267daa..82f7f0221 100644 --- a/src/main/java/com/agenticcp/core/domain/tenant/controller/cloud/dto/CloudResourceResult.java +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/dto/CloudResourceResult.java @@ -1,4 +1,4 @@ -package com.agenticcp.core.domain.tenant.controller.cloud.dto; +package com.agenticcp.core.domain.tenant.cloud.dto; import lombok.Builder; diff --git a/src/main/java/com/agenticcp/core/domain/tenant/listener/TenantIsolationEventListener.java b/src/main/java/com/agenticcp/core/domain/tenant/listener/TenantIsolationEventListener.java index e8854b190..72611ec78 100644 --- a/src/main/java/com/agenticcp/core/domain/tenant/listener/TenantIsolationEventListener.java +++ b/src/main/java/com/agenticcp/core/domain/tenant/listener/TenantIsolationEventListener.java @@ -15,9 +15,9 @@ public class TenantIsolationEventListener { public void handleTenantIsolationAppliedEvent(TenantIsolationAppliedEvent event) { // 현재 리스너는 로깅용으로 사용, 추후 알림 발송 등으로 확장 String tenantKey = event.getTenantKey(); - String isolationLevel = event.getIsolationLevel(); + String isolationLevel = event.getIsolationLevel().getIsolationLevel().toString(); - log.info("tenant isolation applied - tenantKey={}, isolationLevel={}", tenantKey, isolationLevel + ""); + log.info("tenant isolation applied - tenantKey={}, isolationLevel={}", tenantKey, isolationLevel); } diff --git a/src/main/java/com/agenticcp/core/domain/tenant/service/TenantIsolationService.java b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantIsolationService.java index 4d358c506..e5ec47e08 100644 --- a/src/main/java/com/agenticcp/core/domain/tenant/service/TenantIsolationService.java +++ b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantIsolationService.java @@ -6,8 +6,7 @@ import com.agenticcp.core.domain.tenant.adapter.dto.IsolationStatus; import com.agenticcp.core.common.exception.BusinessException; import com.agenticcp.core.common.enums.CommonErrorCode; -import com.agenticcp.core.domain.tenant.controller.cloud.CloudProviderType; -import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.tenant.cloud.CloudProviderType; import com.agenticcp.core.domain.tenant.entity.TenantIsolation; import com.agenticcp.core.domain.tenant.event.TenantIsolationAppliedEvent; import lombok.RequiredArgsConstructor; @@ -40,42 +39,26 @@ public class TenantIsolationService { public IsolationResult applyIsolationPolicy(String tenantKey, TenantIsolation isolation, CloudProviderType cloudProviderType) { try { - // 1. 테넌트 존재 여부 확인 - Tenant tenant = tenantService.getTenantByKey(tenantKey) - .orElseThrow(() -> new BusinessException(CommonErrorCode.NOT_FOUND, - "테넌트를 찾을 수 없습니다: " + tenantKey)); - - // 2. 적절한 Adapter 선택 + + // Adapter 가져오기 TenantIsolationAdapter adapter = adapterFactory.getAdapter(cloudProviderType); - - // 3. 격리 레벨 지원 여부 확인 - if (!adapter.supportsIsolationLevel(isolation.getIsolationLevel())) { - throw new BusinessException(CommonErrorCode.BAD_REQUEST, - "격리 레벨 " + isolation.getIsolationLevel() + "은(는) " + cloudProviderType + "에서 지원되지 않습니다."); - } - - // 4. 격리 정책 적용 - log.info("격리 정책 적용 시작 - 테넌트: {}, 레벨: {}, 프로바이더: {}", - tenantKey, isolation.getIsolationLevel(), cloudProviderType); - + + // Adapter 에게 위임 IsolationResult result = adapter.applyIsolationPolicy(tenantKey, isolation); - // 5. 성공 시 이벤트 발행 (TenantIsolationEventListener가 비동기로 처리) + // 성공 시 이벤트 발행 (TenantIsolationEventListener가 비동기로 처리) if (result.success()) { eventPublisher.publishEvent(new TenantIsolationAppliedEvent(this, tenantKey, isolation)); log.info("격리 정책이 성공적으로 적용되었습니다 - 테넌트: {}", tenantKey); } else { - log.error("격리 정책 적용 실패 - 테넌트: {}, 사유: {}", + log.error("격리 정책 적용 실패 - 테넌트: {}, 사유: {}", tenantKey, result.message()); throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR, "격리 정책 적용에 실패했습니다: " + result.message()); } return result; - - } catch (BusinessException e) { - // BusinessException은 그대로 재던지기 - throw e; + } catch (Exception e) { log.error("격리 정책 적용 중 오류 발생 - 테넌트: {}", tenantKey, e); throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR, @@ -94,16 +77,11 @@ public IsolationResult applyIsolationPolicy(String tenantKey, TenantIsolation is public void removeIsolationPolicy(String tenantKey, CloudProviderType cloudProviderType) { try { TenantIsolationAdapter adapter = adapterFactory.getAdapter(cloudProviderType); - - log.info("격리 정책 제거 시작 - 테넌트: {}, 프로바이더: {}", - tenantKey, cloudProviderType); - + adapter.removeIsolationPolicy(tenantKey); log.info("격리 정책이 성공적으로 제거되었습니다 - 테넌트: {}", tenantKey); - - } catch (BusinessException e) { - throw e; + } catch (Exception e) { log.error("격리 정책 제거 중 오류 발생 - 테넌트: {}", tenantKey, e); throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR, @@ -123,8 +101,7 @@ public IsolationStatus getIsolationStatus(String tenantKey, CloudProviderType cl try { TenantIsolationAdapter adapter = adapterFactory.getAdapter(cloudProviderType); return adapter.getIsolationStatus(tenantKey); - } catch (BusinessException e) { - throw e; + } catch (Exception e) { log.error("격리 상태 조회 중 오류 발생 - 테넌트: {}", tenantKey, e); throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR, @@ -142,8 +119,8 @@ public IsolationStatus getIsolationStatus(String tenantKey, CloudProviderType cl public List getSupportedProviders(TenantIsolation.IsolationLevel isolationLevel) { try { return adapterFactory.getAdaptersSupporting(isolationLevel).stream() - .map(adapter -> CloudProviderType.valueOf(adapter.getSupportedCloudProvider())) - .collect(java.util.stream.Collectors.toList()); + .map(adapter -> adapter.getSupportedCloudProvider()) + .toList(); } catch (Exception e) { log.error("지원하는 클라우드 프로바이더 조회 중 오류 발생 - 격리 레벨: {}", isolationLevel, e); throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR, From 3d41db7102befb564c4c12a9211d5cebcab68c35 Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Fri, 24 Oct 2025 16:22:15 +0900 Subject: [PATCH 17/32] =?UTF-8?q?feat:=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4,=20=EC=9A=94=EC=B2=AD,=20=EC=83=98=ED=94=8C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cloud/dto/ResourceCreateRequest.java | 11 +++++++++ .../cloud/service/CloudResourceCreator.java | 23 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/cloud/dto/ResourceCreateRequest.java create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/cloud/service/CloudResourceCreator.java diff --git a/src/main/java/com/agenticcp/core/domain/tenant/cloud/dto/ResourceCreateRequest.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/dto/ResourceCreateRequest.java new file mode 100644 index 000000000..dc60f45a9 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/dto/ResourceCreateRequest.java @@ -0,0 +1,11 @@ +package com.agenticcp.core.domain.tenant.cloud.dto; + +import lombok.Builder; + +import java.util.Map; + +@Builder +public record ResourceCreateRequest( + Map metadata +) { +} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/CloudResourceCreator.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/CloudResourceCreator.java new file mode 100644 index 000000000..be020542b --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/CloudResourceCreator.java @@ -0,0 +1,23 @@ +package com.agenticcp.core.domain.tenant.cloud.service; + + +import com.agenticcp.core.domain.tenant.cloud.CloudProviderType; +import com.agenticcp.core.domain.tenant.cloud.dto.CloudResourceResult; +import com.agenticcp.core.domain.tenant.cloud.dto.ResourceCreateRequest; +import com.agenticcp.core.domain.tenant.entity.TenantIsolation; + +public interface CloudResourceCreator { + + CloudProviderType getCloudProviderType(); + + CloudResourceResult createVpc(String tenantKey, ResourceCreateRequest request, TenantIsolation.IsolationLevel level); + + CloudResourceResult createSubnets(String tenantKey, ResourceCreateRequest request, TenantIsolation.IsolationLevel level); + + CloudResourceResult createSecurityGroups(String tenantKey, ResourceCreateRequest request, TenantIsolation.IsolationLevel level); + + + + + +} From 613e924295cad4e54c15e1f74de3b23be8cc215c Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Fri, 24 Oct 2025 16:22:44 +0900 Subject: [PATCH 18/32] =?UTF-8?q?feat:=20Isolationstrategy=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=EC=99=80=20=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cloud/service/IsolationStrategy.java | 12 +++++++ .../service/IsolationStrategyFactory.java | 36 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/cloud/service/IsolationStrategy.java create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/cloud/service/IsolationStrategyFactory.java diff --git a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/IsolationStrategy.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/IsolationStrategy.java new file mode 100644 index 000000000..a8d697d8f --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/IsolationStrategy.java @@ -0,0 +1,12 @@ +package com.agenticcp.core.domain.tenant.cloud.service; + +import com.agenticcp.core.domain.tenant.adapter.dto.IsolationResult; +import com.agenticcp.core.domain.tenant.entity.TenantIsolation; + +public interface IsolationStrategy { + + boolean supports(TenantIsolation.IsolationLevel isolationLevel); + + IsolationResult applyIsolation(String tenantKey, TenantIsolation.IsolationLevel isolation, CloudResourceCreator resourceCreator); + +} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/IsolationStrategyFactory.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/IsolationStrategyFactory.java new file mode 100644 index 000000000..0fe5e5f57 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/IsolationStrategyFactory.java @@ -0,0 +1,36 @@ +package com.agenticcp.core.domain.tenant.cloud.service; + +import com.agenticcp.core.domain.tenant.entity.TenantIsolation; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +public class IsolationStrategyFactory { + + private final Map strategies; + + public IsolationStrategyFactory(List strategyList) { + this.strategies = strategyList.stream() + .collect(Collectors.toMap( + strategy -> findSupportedLevel(strategy), + Function.identity() + )); + } + + public IsolationStrategy getStrategy(TenantIsolation.IsolationLevel isolationLevel) { + return strategies.get(isolationLevel); + } + + private TenantIsolation.IsolationLevel findSupportedLevel(IsolationStrategy st) { + return Arrays.stream(TenantIsolation.IsolationLevel.values()) + .filter(st::supports) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Isolation strategy does not support any isolation level")); + } + +} From 8e0e05ff288a8e710ccfa9d4f1b234e7488e25a4 Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Fri, 24 Oct 2025 16:23:01 +0900 Subject: [PATCH 19/32] =?UTF-8?q?feat:=20SharedIsolationStrategy=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/SharedIsolationStrategy.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/cloud/service/SharedIsolationStrategy.java diff --git a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/SharedIsolationStrategy.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/SharedIsolationStrategy.java new file mode 100644 index 000000000..27f36f2a1 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/SharedIsolationStrategy.java @@ -0,0 +1,40 @@ +package com.agenticcp.core.domain.tenant.cloud.service; + +import com.agenticcp.core.domain.tenant.adapter.dto.IsolationResult; +import com.agenticcp.core.domain.tenant.cloud.dto.CloudResourceResult; +import com.agenticcp.core.domain.tenant.cloud.dto.ResourceCreateRequest; +import com.agenticcp.core.domain.tenant.entity.TenantIsolation; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class SharedIsolationStrategy implements IsolationStrategy{ + + @Override + public boolean supports(TenantIsolation.IsolationLevel isolationLevel) { + return isolationLevel == TenantIsolation.IsolationLevel.SHARED; + } + + @Override + public IsolationResult applyIsolation(String tenantKey, TenantIsolation.IsolationLevel isolationLevel, CloudResourceCreator resourceCreator) { + + // VPC 요청 생성 + ResourceCreateRequest vpcRequest = ResourceCreateRequest.builder() + .metadata(Map.of( + "tenantKey", tenantKey, + "isolationLevel", "SHARED")) + .build(); + + // ResourceCreator 에게 VPC 리소스 생성 위임 + CloudResourceResult vpcResult = resourceCreator.createVpc(tenantKey, vpcRequest, isolationLevel.SHARED); + + // 결과값(TODO: subnet, sg 생성 결과 포함) + Map resourceDetails = Map.of( + "vpcId", vpcResult.resourceId() + ); + + return IsolationResult.success(tenantKey, isolationLevel, resourceDetails); + + } +} From b7d9c29c5463802826ddbb6d258ea60f43a76811 Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Fri, 24 Oct 2025 16:23:33 +0900 Subject: [PATCH 20/32] =?UTF-8?q?feat:=20AwstenantIsolationAdapter=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/AwsTenantIsolationAdapter.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/adapter/AwsTenantIsolationAdapter.java diff --git a/src/main/java/com/agenticcp/core/domain/tenant/adapter/AwsTenantIsolationAdapter.java b/src/main/java/com/agenticcp/core/domain/tenant/adapter/AwsTenantIsolationAdapter.java new file mode 100644 index 000000000..4d0962736 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/adapter/AwsTenantIsolationAdapter.java @@ -0,0 +1,47 @@ +package com.agenticcp.core.domain.tenant.adapter; + +import com.agenticcp.core.domain.tenant.adapter.dto.IsolationResult; +import com.agenticcp.core.domain.tenant.adapter.dto.IsolationStatus; +import com.agenticcp.core.domain.tenant.cloud.CloudProviderType; +import com.agenticcp.core.domain.tenant.cloud.service.AwsCloudResourceCreator; +import com.agenticcp.core.domain.tenant.cloud.service.IsolationStrategy; +import com.agenticcp.core.domain.tenant.cloud.service.IsolationStrategyFactory; +import com.agenticcp.core.domain.tenant.entity.TenantIsolation; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AwsTenantIsolationAdapter implements TenantIsolationAdapter{ + + private final IsolationStrategyFactory strategyFactory; + private final AwsCloudResourceCreator resourceCreator; + + @Override + public IsolationResult applyIsolationPolicy(String tenantKey, TenantIsolation isolation) { + // Strategy(격리 수준) 가져오기 + IsolationStrategy strategy = strategyFactory.getStrategy(isolation.getIsolationLevel()); + + // Strategy 에 ResourceCreator 전달 + return strategy.applyIsolation(tenantKey, isolation.getIsolationLevel(), resourceCreator); + + } + + @Override + public void removeIsolationPolicy(String tenantKey) { + + } + @Override + public IsolationStatus getIsolationStatus(String tenantKey) { + return null; + } + @Override + public boolean supportsIsolationLevel(TenantIsolation.IsolationLevel isolationLevel) { + return isolationLevel == TenantIsolation.IsolationLevel.SHARED; + } + + @Override + public CloudProviderType getSupportedCloudProvider(){ + return CloudProviderType.AWS; + } +} From 38c9af4a98f6c895444eca29ae7e52623deeac63 Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Fri, 24 Oct 2025 16:23:54 +0900 Subject: [PATCH 21/32] =?UTF-8?q?feat:=20AwsCloudResourceCreator=20?= =?UTF-8?q?=EC=83=98=ED=94=8C=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EA=B5=AC=ED=98=84=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AwsCloudResourceCreator.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/cloud/service/AwsCloudResourceCreator.java diff --git a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/AwsCloudResourceCreator.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/AwsCloudResourceCreator.java new file mode 100644 index 000000000..bc1ef8998 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/AwsCloudResourceCreator.java @@ -0,0 +1,27 @@ +package com.agenticcp.core.domain.tenant.cloud.service; + +import com.agenticcp.core.domain.tenant.cloud.dto.CloudResourceResult; +import com.agenticcp.core.domain.tenant.cloud.dto.ResourceCreateRequest; +import com.agenticcp.core.domain.tenant.entity.TenantIsolation; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +// 샘플 +@Component +public class AwsCloudResourceCreator implements CloudResourceCreator { + + @Override + public CloudResourceResult createVpc(String tenantKey, ResourceCreateRequest request, TenantIsolation.IsolationLevel level) { + // Strategy 의 추상적 요청을 AWS 형식으로 변환 + Map awsMetadata = getAwsMetadata(request); + + // TODO: 실제 AWS API 호출 구현 + return null; + } + + private Map getAwsMetadata(ResourceCreateRequest request) { + return new HashMap<>(request.metadata()); + } +} From 2694e7e7f27fc5a2a8f4ede7e3cfd7b50b64599b Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Fri, 24 Oct 2025 16:35:40 +0900 Subject: [PATCH 22/32] =?UTF-8?q?feat:=20IsolationStrategyFactory=20?= =?UTF-8?q?=EB=A7=B5=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cloud/service/IsolationStrategyFactory.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/IsolationStrategyFactory.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/IsolationStrategyFactory.java index 0fe5e5f57..e4e160365 100644 --- a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/IsolationStrategyFactory.java +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/IsolationStrategyFactory.java @@ -6,31 +6,33 @@ import java.util.Arrays; import java.util.List; import java.util.Map; -import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; @Component public class IsolationStrategyFactory { private final Map strategies; + // 빠른 조회를 위해 생성자에서 맵을 만듦 public IsolationStrategyFactory(List strategyList) { this.strategies = strategyList.stream() + .flatMap(this::findSupportedLevel) .collect(Collectors.toMap( - strategy -> findSupportedLevel(strategy), - Function.identity() + Map.Entry::getKey, + Map.Entry::getValue )); } + // 격리 수준 조회 public IsolationStrategy getStrategy(TenantIsolation.IsolationLevel isolationLevel) { return strategies.get(isolationLevel); } - private TenantIsolation.IsolationLevel findSupportedLevel(IsolationStrategy st) { + private Stream> findSupportedLevel(IsolationStrategy st) { return Arrays.stream(TenantIsolation.IsolationLevel.values()) .filter(st::supports) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Isolation strategy does not support any isolation level")); + .map(level -> Map.entry(level, st)); } } From d5e682612966d18e21de713b2e1be4cd15ba9dcd Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Fri, 24 Oct 2025 22:46:07 +0900 Subject: [PATCH 23/32] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cloud/service/{ => isolation}/IsolationStrategy.java | 3 ++- .../service/{ => isolation}/IsolationStrategyFactory.java | 2 +- .../cloud/service/{ => isolation}/SharedIsolationStrategy.java | 3 ++- .../cloud/service/{ => resource}/AwsCloudResourceCreator.java | 2 +- .../cloud/service/{ => resource}/CloudResourceCreator.java | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) rename src/main/java/com/agenticcp/core/domain/tenant/cloud/service/{ => isolation}/IsolationStrategy.java (71%) rename src/main/java/com/agenticcp/core/domain/tenant/cloud/service/{ => isolation}/IsolationStrategyFactory.java (94%) rename src/main/java/com/agenticcp/core/domain/tenant/cloud/service/{ => isolation}/SharedIsolationStrategy.java (90%) rename src/main/java/com/agenticcp/core/domain/tenant/cloud/service/{ => resource}/AwsCloudResourceCreator.java (93%) rename src/main/java/com/agenticcp/core/domain/tenant/cloud/service/{ => resource}/CloudResourceCreator.java (92%) diff --git a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/IsolationStrategy.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/isolation/IsolationStrategy.java similarity index 71% rename from src/main/java/com/agenticcp/core/domain/tenant/cloud/service/IsolationStrategy.java rename to src/main/java/com/agenticcp/core/domain/tenant/cloud/service/isolation/IsolationStrategy.java index a8d697d8f..7c3f0e084 100644 --- a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/IsolationStrategy.java +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/isolation/IsolationStrategy.java @@ -1,6 +1,7 @@ -package com.agenticcp.core.domain.tenant.cloud.service; +package com.agenticcp.core.domain.tenant.cloud.service.isolation; import com.agenticcp.core.domain.tenant.adapter.dto.IsolationResult; +import com.agenticcp.core.domain.tenant.cloud.service.resource.CloudResourceCreator; import com.agenticcp.core.domain.tenant.entity.TenantIsolation; public interface IsolationStrategy { diff --git a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/IsolationStrategyFactory.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/isolation/IsolationStrategyFactory.java similarity index 94% rename from src/main/java/com/agenticcp/core/domain/tenant/cloud/service/IsolationStrategyFactory.java rename to src/main/java/com/agenticcp/core/domain/tenant/cloud/service/isolation/IsolationStrategyFactory.java index e4e160365..f96e1427c 100644 --- a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/IsolationStrategyFactory.java +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/isolation/IsolationStrategyFactory.java @@ -1,4 +1,4 @@ -package com.agenticcp.core.domain.tenant.cloud.service; +package com.agenticcp.core.domain.tenant.cloud.service.isolation; import com.agenticcp.core.domain.tenant.entity.TenantIsolation; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/SharedIsolationStrategy.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/isolation/SharedIsolationStrategy.java similarity index 90% rename from src/main/java/com/agenticcp/core/domain/tenant/cloud/service/SharedIsolationStrategy.java rename to src/main/java/com/agenticcp/core/domain/tenant/cloud/service/isolation/SharedIsolationStrategy.java index 27f36f2a1..f5f03b00a 100644 --- a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/SharedIsolationStrategy.java +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/isolation/SharedIsolationStrategy.java @@ -1,8 +1,9 @@ -package com.agenticcp.core.domain.tenant.cloud.service; +package com.agenticcp.core.domain.tenant.cloud.service.isolation; import com.agenticcp.core.domain.tenant.adapter.dto.IsolationResult; import com.agenticcp.core.domain.tenant.cloud.dto.CloudResourceResult; import com.agenticcp.core.domain.tenant.cloud.dto.ResourceCreateRequest; +import com.agenticcp.core.domain.tenant.cloud.service.resource.CloudResourceCreator; import com.agenticcp.core.domain.tenant.entity.TenantIsolation; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/AwsCloudResourceCreator.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/resource/AwsCloudResourceCreator.java similarity index 93% rename from src/main/java/com/agenticcp/core/domain/tenant/cloud/service/AwsCloudResourceCreator.java rename to src/main/java/com/agenticcp/core/domain/tenant/cloud/service/resource/AwsCloudResourceCreator.java index bc1ef8998..ea27ab85f 100644 --- a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/AwsCloudResourceCreator.java +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/resource/AwsCloudResourceCreator.java @@ -1,4 +1,4 @@ -package com.agenticcp.core.domain.tenant.cloud.service; +package com.agenticcp.core.domain.tenant.cloud.service.resource; import com.agenticcp.core.domain.tenant.cloud.dto.CloudResourceResult; import com.agenticcp.core.domain.tenant.cloud.dto.ResourceCreateRequest; diff --git a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/CloudResourceCreator.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/resource/CloudResourceCreator.java similarity index 92% rename from src/main/java/com/agenticcp/core/domain/tenant/cloud/service/CloudResourceCreator.java rename to src/main/java/com/agenticcp/core/domain/tenant/cloud/service/resource/CloudResourceCreator.java index be020542b..f44847b96 100644 --- a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/CloudResourceCreator.java +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/resource/CloudResourceCreator.java @@ -1,4 +1,4 @@ -package com.agenticcp.core.domain.tenant.cloud.service; +package com.agenticcp.core.domain.tenant.cloud.service.resource; import com.agenticcp.core.domain.tenant.cloud.CloudProviderType; From e7e5366b8f29f075e8dca6afc0964e13d22de353 Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Mon, 24 Nov 2025 19:32:46 +0900 Subject: [PATCH 24/32] =?UTF-8?q?refactor:=20Organization-Tenant=20?= =?UTF-8?q?=EA=B4=80=EA=B3=84=EB=A5=BC=201:N=EC=97=90=EC=84=9C=201:1?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/OrganizationController.java | 76 +----- .../organization/entity/Organization.java | 7 +- .../repository/OrganizationRepository.java | 9 +- .../service/OrganizationService.java | 116 +++------ .../core/domain/tenant/entity/Tenant.java | 6 +- .../OrganizationTenantServiceTest.java | 221 ------------------ 6 files changed, 47 insertions(+), 388 deletions(-) delete mode 100644 src/test/java/com/agenticcp/core/domain/organization/service/OrganizationTenantServiceTest.java diff --git a/src/main/java/com/agenticcp/core/domain/organization/controller/OrganizationController.java b/src/main/java/com/agenticcp/core/domain/organization/controller/OrganizationController.java index 4da95f805..46689f065 100644 --- a/src/main/java/com/agenticcp/core/domain/organization/controller/OrganizationController.java +++ b/src/main/java/com/agenticcp/core/domain/organization/controller/OrganizationController.java @@ -477,83 +477,27 @@ public ResponseEntity> removeUserFromOrganization( // ========== 조직-테넌트 관계 관리 API ========== /** - * 조직별 테넌트 목록 조회 + * 조직별 테넌트 조회 (1:1 관계) * * @param id 조직 ID - * @return 테넌트 목록 + * @return 테넌트 정보 */ - @GetMapping("/{id}/tenants") + @GetMapping("/{id}/tenant") @Operation( - summary = "조직별 테넌트 목록 조회", - description = "특정 조직에 속한 테넌트 목록을 조회합니다." + summary = "조직별 테넌트 조회", + description = "특정 조직에 속한 테넌트를 조회합니다. (1:1 관계)" ) @io.swagger.v3.oas.annotations.responses.ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "조직을 찾을 수 없음") - }) - public ResponseEntity>> getOrganizationTenants( - @Parameter(description = "조직 ID", required = true, example = "1") - @PathVariable @Positive Long id) { - log.info("[OrganizationController] getOrganizationTenants - id={}", id); - - List tenants = organizationService.getOrganizationTenants(id); - - return ResponseEntity.ok(ApiResponse.success(tenants, "조직 테넌트 목록을 성공적으로 조회했습니다.")); - } - - /** - * 조직별 테넌트 수 조회 - * - * @param id 조직 ID - * @return 테넌트 수 통계 - */ - @GetMapping("/{id}/tenants/count") - @Operation( - summary = "조직별 테넌트 수 조회", - description = "특정 조직에 속한 테넌트 수를 조회합니다." - ) - @io.swagger.v3.oas.annotations.responses.ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "조직을 찾을 수 없음") - }) - public ResponseEntity>> getOrganizationTenantCount( - @Parameter(description = "조직 ID", required = true, example = "1") - @PathVariable @Positive Long id) { - log.info("[OrganizationController] getOrganizationTenantCount - id={}", id); - - long totalCount = organizationService.getOrganizationTenantCount(id); - long activeCount = organizationService.getActiveTenantCount(id); - - Map response = new HashMap<>(); - response.put("totalTenants", totalCount); - response.put("activeTenants", activeCount); - response.put("inactiveTenants", totalCount - activeCount); - - return ResponseEntity.ok(ApiResponse.success(response, "조직 테넌트 수를 성공적으로 조회했습니다.")); - } - - /** - * 조직별 테넌트 통계 조회 - * - * @param id 조직 ID - * @return 테넌트 통계 정보 - */ - @GetMapping("/{id}/tenants/stats") - @Operation( - summary = "조직별 테넌트 통계 조회", - description = "특정 조직의 테넌트 관련 상세 통계를 조회합니다." - ) - @io.swagger.v3.oas.annotations.responses.ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "조직을 찾을 수 없음") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "조직 또는 테넌트를 찾을 수 없음") }) - public ResponseEntity>> getOrganizationTenantStats( + public ResponseEntity> getOrganizationTenant( @Parameter(description = "조직 ID", required = true, example = "1") @PathVariable @Positive Long id) { - log.info("[OrganizationController] getOrganizationTenantStats - id={}", id); + log.info("[OrganizationController] getOrganizationTenant - id={}", id); - Map stats = organizationService.getOrganizationTenantStats(id); + Tenant tenant = organizationService.getOrganizationTenantOrThrow(id); - return ResponseEntity.ok(ApiResponse.success(stats, "조직 테넌트 통계를 성공적으로 조회했습니다.")); + return ResponseEntity.ok(ApiResponse.success(tenant, "조직 테넌트를 성공적으로 조회했습니다.")); } } \ No newline at end of file diff --git a/src/main/java/com/agenticcp/core/domain/organization/entity/Organization.java b/src/main/java/com/agenticcp/core/domain/organization/entity/Organization.java index 867dd96bc..a388d12fb 100644 --- a/src/main/java/com/agenticcp/core/domain/organization/entity/Organization.java +++ b/src/main/java/com/agenticcp/core/domain/organization/entity/Organization.java @@ -15,7 +15,6 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; -import java.util.List; /** * 조직 엔티티 @@ -57,9 +56,9 @@ public class Organization extends BaseEntity { @Column(name = "description", columnDefinition = "TEXT") private String description; - /** 테넌트 목록 */ - @OneToMany(mappedBy = "organization", cascade = CascadeType.ALL, fetch = FetchType.LAZY) - private List tenants; + /** 테넌트 (1:1 관계) */ + @OneToOne(mappedBy = "organization", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private Tenant tenant; /** 상위 조직 */ @ManyToOne(fetch = FetchType.LAZY) diff --git a/src/main/java/com/agenticcp/core/domain/organization/repository/OrganizationRepository.java b/src/main/java/com/agenticcp/core/domain/organization/repository/OrganizationRepository.java index b4b633718..b2914ef7e 100644 --- a/src/main/java/com/agenticcp/core/domain/organization/repository/OrganizationRepository.java +++ b/src/main/java/com/agenticcp/core/domain/organization/repository/OrganizationRepository.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; /** * 조직 Repository @@ -70,10 +71,10 @@ public interface OrganizationRepository extends JpaRepository findTenantsByOrganizationId(@Param("organizationId") Long organizationId); + @Query("SELECT o.tenant FROM Organization o WHERE o.id = :organizationId") + Optional findTenantByOrganizationId(@Param("organizationId") Long organizationId); } diff --git a/src/main/java/com/agenticcp/core/domain/organization/service/OrganizationService.java b/src/main/java/com/agenticcp/core/domain/organization/service/OrganizationService.java index 76331a6b7..c93151da6 100644 --- a/src/main/java/com/agenticcp/core/domain/organization/service/OrganizationService.java +++ b/src/main/java/com/agenticcp/core/domain/organization/service/OrganizationService.java @@ -3,6 +3,7 @@ import com.agenticcp.core.common.enums.Status; import com.agenticcp.core.common.enums.CommonErrorCode; import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.common.exception.ResourceNotFoundException; import com.agenticcp.core.domain.organization.dto.CreateOrganizationRequest; import com.agenticcp.core.domain.organization.dto.OrganizationResponse; import com.agenticcp.core.domain.organization.dto.UpdateOrganizationRequest; @@ -25,6 +26,7 @@ import java.util.*; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; /** @@ -607,116 +609,50 @@ private UserResponse convertToUserResponse(User user) { .build(); } - // ========== 조직-테넌트 관계 관리 ========== + // ========== 조직-테넌트 관계 관리 (1:1) ========== /** - * 조직별 테넌트 목록 조회 + * 조직에 속한 테넌트 조회 (1:1 관계) * * @param organizationId 조직 ID - * @return 조직에 속한 테넌트 목록 - * @throws BusinessException 조직을 찾을 수 없는 경우 + * @return 조직에 속한 테넌트 (Optional) + * @throws ResourceNotFoundException 조직을 찾을 수 없는 경우 */ - public List getOrganizationTenants(Long organizationId) { - log.info("[OrganizationService] getOrganizationTenants - organizationId={}", organizationId); + public Optional getOrganizationTenant(Long organizationId) { + log.info("[OrganizationService] getOrganizationTenant - organizationId={}", organizationId); // 조직 존재 확인 organizationRepository.findById(organizationId) - .orElseThrow(() -> new BusinessException(CommonErrorCode.NOT_FOUND, "존재하지 않는 조직입니다: " + organizationId)); + .orElseThrow(() -> new ResourceNotFoundException("Organization", "id", organizationId.toString())); - // 조직의 테넌트 목록 조회 - List tenants = organizationRepository.findTenantsByOrganizationId(organizationId); + // 조직의 테넌트 조회 (1:1 관계) + Optional tenant = organizationRepository.findTenantByOrganizationId(organizationId); - log.info("[OrganizationService] getOrganizationTenants - success organizationId={}, count={}", - organizationId, tenants.size()); - return tenants; + log.info("[OrganizationService] getOrganizationTenant - success organizationId={}, tenantPresent={}", + organizationId, tenant.isPresent()); + return tenant; } /** - * 조직별 테넌트 수 조회 + * 조직에 속한 테넌트 조회 (1:1 관계, 없으면 예외) * * @param organizationId 조직 ID - * @return 조직에 속한 테넌트 수 - * @throws BusinessException 조직을 찾을 수 없는 경우 - */ - public long getOrganizationTenantCount(Long organizationId) { - log.info("[OrganizationService] getOrganizationTenantCount - organizationId={}", organizationId); - - // 조직 존재 확인 - organizationRepository.findById(organizationId) - .orElseThrow(() -> new BusinessException(CommonErrorCode.NOT_FOUND, "존재하지 않는 조직입니다: " + organizationId)); - - // 조직의 테넌트 수 조회 - List tenants = organizationRepository.findTenantsByOrganizationId(organizationId); - long count = tenants.size(); - - log.info("[OrganizationService] getOrganizationTenantCount - success organizationId={}, count={}", - organizationId, count); - return count; - } - - /** - * 조직별 활성 테넌트 수 조회 - * - * @param organizationId 조직 ID - * @return 조직에 속한 활성 테넌트 수 - * @throws BusinessException 조직을 찾을 수 없는 경우 + * @return 조직에 속한 테넌트 + * @throws ResourceNotFoundException 조직 또는 테넌트를 찾을 수 없는 경우 */ - public long getActiveTenantCount(Long organizationId) { - log.info("[OrganizationService] getActiveTenantCount - organizationId={}", organizationId); + public Tenant getOrganizationTenantOrThrow(Long organizationId) { + log.info("[OrganizationService] getOrganizationTenantOrThrow - organizationId={}", organizationId); // 조직 존재 확인 organizationRepository.findById(organizationId) - .orElseThrow(() -> new BusinessException(CommonErrorCode.NOT_FOUND, "존재하지 않는 조직입니다: " + organizationId)); - - // 조직의 활성 테넌트 수 조회 - List tenants = organizationRepository.findTenantsByOrganizationId(organizationId); - long count = tenants.stream() - .filter(tenant -> tenant.getStatus() == Status.ACTIVE) - .count(); - - log.info("[OrganizationService] getActiveTenantCount - success organizationId={}, count={}", - organizationId, count); - return count; - } - - /** - * 조직별 테넌트 통계 조회 - * - * @param organizationId 조직 ID - * @return 조직의 테넌트 통계 정보 - * @throws BusinessException 조직을 찾을 수 없는 경우 - */ - public Map getOrganizationTenantStats(Long organizationId) { - log.info("[OrganizationService] getOrganizationTenantStats - organizationId={}", organizationId); - - // 조직 존재 확인 - Organization organization = organizationRepository.findById(organizationId) - .orElseThrow(() -> new BusinessException(CommonErrorCode.NOT_FOUND, "존재하지 않는 조직입니다: " + organizationId)); - - // 조직의 테넌트 목록 조회 - List tenants = organizationRepository.findTenantsByOrganizationId(organizationId); - - // 통계 계산 - long totalTenants = tenants.size(); - long activeTenants = tenants.stream() - .filter(tenant -> tenant.getStatus() == Status.ACTIVE) - .count(); - long inactiveTenants = totalTenants - activeTenants; - - int totalMaxUsers = tenants.stream() - .mapToInt(tenant -> tenant.getMaxUsers() != null ? tenant.getMaxUsers() : 0) - .sum(); + .orElseThrow(() -> new ResourceNotFoundException("Organization", "id", organizationId.toString())); - Map stats = new HashMap<>(); - stats.put("totalTenants", totalTenants); - stats.put("activeTenants", activeTenants); - stats.put("inactiveTenants", inactiveTenants); - stats.put("totalMaxUsers", totalMaxUsers); - stats.put("organizationId", organizationId); - stats.put("organizationName", organization.getOrgName()); + // 조직의 테넌트 조회 (1:1 관계) + Tenant tenant = organizationRepository.findTenantByOrganizationId(organizationId) + .orElseThrow(() -> new ResourceNotFoundException("Tenant", "organizationId", organizationId.toString())); - log.info("[OrganizationService] getOrganizationTenantStats - success organizationId={}, totalTenants={}, activeTenants={}", - organizationId, totalTenants, activeTenants); - return stats; + log.info("[OrganizationService] getOrganizationTenantOrThrow - success organizationId={}, tenantId={}", + organizationId, tenant.getId()); + return tenant; } } \ No newline at end of file diff --git a/src/main/java/com/agenticcp/core/domain/tenant/entity/Tenant.java b/src/main/java/com/agenticcp/core/domain/tenant/entity/Tenant.java index 085a5db36..f7dc077cd 100644 --- a/src/main/java/com/agenticcp/core/domain/tenant/entity/Tenant.java +++ b/src/main/java/com/agenticcp/core/domain/tenant/entity/Tenant.java @@ -28,9 +28,9 @@ public class Tenant extends BaseEntity { @Column(name = "description") private String description; - // Organization과의 관계 (N:1) - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "organization_id", nullable = false) + // Organization과의 관계 (1:1) + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organization_id", nullable = false, unique = true) private Organization organization; @Column(name = "status") diff --git a/src/test/java/com/agenticcp/core/domain/organization/service/OrganizationTenantServiceTest.java b/src/test/java/com/agenticcp/core/domain/organization/service/OrganizationTenantServiceTest.java deleted file mode 100644 index 715db0e6e..000000000 --- a/src/test/java/com/agenticcp/core/domain/organization/service/OrganizationTenantServiceTest.java +++ /dev/null @@ -1,221 +0,0 @@ -package com.agenticcp.core.domain.organization.service; - -import com.agenticcp.core.common.enums.Status; -import com.agenticcp.core.domain.organization.entity.Organization; -import com.agenticcp.core.domain.organization.repository.OrganizationRepository; -import com.agenticcp.core.domain.tenant.entity.Tenant; -import com.agenticcp.core.domain.tenant.repository.TenantRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.LocalDateTime; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -@DisplayName("Organization-Tenant 관계 테스트") -class OrganizationTenantServiceTest { - - @Mock - private OrganizationRepository organizationRepository; - - @Mock - private TenantRepository tenantRepository; - - @InjectMocks - private OrganizationService organizationService; - - private Organization testOrganization; - private Tenant testTenant1; - private Tenant testTenant2; - - @BeforeEach - void setUp() { - // 테스트 조직 생성 - testOrganization = Organization.builder() - .orgKey("TEST_ORG") - .orgName("테스트 조직") - .description("테스트용 조직") - .status(Status.ACTIVE) - .orgType(Organization.OrganizationType.COMPANY) - .contactEmail("test@test.com") - .maxUsers(100) - .establishedDate(LocalDateTime.now()) - .build(); - testOrganization.setId(1L); - - // 테스트 테넌트 1 생성 - testTenant1 = Tenant.builder() - .tenantKey("TENANT_A") - .tenantName("테넌트 A") - .description("테스트 테넌트 A") - .status(Status.ACTIVE) - .maxUsers(50) - .organization(testOrganization) - .build(); - testTenant1.setId(1L); - - // 테스트 테넌트 2 생성 - testTenant2 = Tenant.builder() - .tenantKey("TENANT_B") - .tenantName("테넌트 B") - .description("테스트 테넌트 B") - .status(Status.ACTIVE) - .maxUsers(30) - .organization(testOrganization) - .build(); - testTenant2.setId(2L); - - // 조직에 테넌트들 설정 - testOrganization.setTenants(Arrays.asList(testTenant1, testTenant2)); - } - - @Test - @DisplayName("조직에 속한 테넌트 목록 조회 성공") - void 조직에_속한_테넌트_목록_조회_성공() { - // Given - Long organizationId = 1L; - List tenants = Arrays.asList(testTenant1, testTenant2); - - when(organizationRepository.findTenantsByOrganizationId(organizationId)) - .thenReturn(tenants); - - // When - List result = organizationRepository.findTenantsByOrganizationId(organizationId); - - // Then - assertThat(result).hasSize(2); - assertThat(result.get(0).getTenantKey()).isEqualTo("TENANT_A"); - assertThat(result.get(1).getTenantKey()).isEqualTo("TENANT_B"); - - verify(organizationRepository).findTenantsByOrganizationId(organizationId); - } - - @Test - @DisplayName("조직에 속한 테넌트 수 조회 성공") - void 조직에_속한_테넌트_수_조회_성공() { - // Given - Long organizationId = 1L; - List tenants = Arrays.asList(testTenant1, testTenant2); - - when(organizationRepository.findTenantsByOrganizationId(organizationId)) - .thenReturn(tenants); - - // When - List result = organizationRepository.findTenantsByOrganizationId(organizationId); - - // Then - assertThat(result).hasSize(2); - verify(organizationRepository).findTenantsByOrganizationId(organizationId); - } - - @Test - @DisplayName("조직에 테넌트가 없는 경우") - void 조직에_테넌트가_없는_경우() { - // Given - Long organizationId = 999L; - List emptyTenants = Arrays.asList(); - - when(organizationRepository.findTenantsByOrganizationId(organizationId)) - .thenReturn(emptyTenants); - - // When - List result = organizationRepository.findTenantsByOrganizationId(organizationId); - - // Then - assertThat(result).isEmpty(); - verify(organizationRepository).findTenantsByOrganizationId(organizationId); - } - - @Test - @DisplayName("조직-테넌트 관계 검증") - void 조직_테넌트_관계_검증() { - // Given - Organization org = testOrganization; - Tenant tenant = testTenant1; - - // When & Then - // 조직이 테넌트를 포함하는지 확인 - assertThat(org.getTenants()).contains(tenant); - assertThat(org.getTenants()).hasSize(2); - - // 테넌트가 조직을 참조하는지 확인 - assertThat(tenant.getOrganization()).isEqualTo(org); - assertThat(tenant.getOrganization().getId()).isEqualTo(org.getId()); - } - - @Test - @DisplayName("조직 삭제 시 연관된 테넌트들도 삭제되는지 확인") - void 조직_삭제_시_연관된_테넌트들_삭제_확인() { - // Given - Long organizationId = 1L; - - // When - organizationRepository.deleteById(organizationId); - - // Then - verify(organizationRepository).deleteById(organizationId); - // CascadeType.ALL로 설정되어 있어서 연관된 테넌트들도 삭제됨 - } - - @Test - @DisplayName("테넌트를 다른 조직으로 이동") - void 테넌트를_다른_조직으로_이동() { - // Given - Organization newOrganization = Organization.builder() - .orgKey("NEW_ORG") - .orgName("새 조직") - .status(Status.ACTIVE) - .build(); - newOrganization.setId(2L); - - Tenant tenant = testTenant1; - - // When - tenant.setOrganization(newOrganization); - tenantRepository.save(tenant); - - // Then - assertThat(tenant.getOrganization()).isEqualTo(newOrganization); - assertThat(tenant.getOrganization().getId()).isEqualTo(2L); - verify(tenantRepository).save(tenant); - } - - @Test - @DisplayName("조직별 테넌트 통계 조회") - void 조직별_테넌트_통계_조회() { - // Given - Long organizationId = 1L; - List tenants = Arrays.asList(testTenant1, testTenant2); - - when(organizationRepository.findTenantsByOrganizationId(organizationId)) - .thenReturn(tenants); - - // When - List result = organizationRepository.findTenantsByOrganizationId(organizationId); - - // Then - assertThat(result).hasSize(2); - - // 통계 계산 - long activeTenantCount = result.stream() - .filter(tenant -> tenant.getStatus() == Status.ACTIVE) - .count(); - - int totalMaxUsers = result.stream() - .mapToInt(tenant -> tenant.getMaxUsers() != null ? tenant.getMaxUsers() : 0) - .sum(); - - assertThat(activeTenantCount).isEqualTo(2); - assertThat(totalMaxUsers).isEqualTo(80); // 50 + 30 - } -} From 42952f325ab7afecceb26add9a6533ab9455aff7 Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Mon, 24 Nov 2025 19:33:44 +0900 Subject: [PATCH 25/32] =?UTF-8?q?feat:=20TenantIsolationService=20?= =?UTF-8?q?=EC=9E=AC=EC=9E=91=EC=84=B1=20=EB=B0=8F=20Repository=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/TenantIsolationRepository.java | 35 +++ .../service/TenantIsolationService.java | 228 +++++++----------- 2 files changed, 120 insertions(+), 143 deletions(-) create mode 100644 src/main/java/com/agenticcp/core/domain/tenant/repository/TenantIsolationRepository.java diff --git a/src/main/java/com/agenticcp/core/domain/tenant/repository/TenantIsolationRepository.java b/src/main/java/com/agenticcp/core/domain/tenant/repository/TenantIsolationRepository.java new file mode 100644 index 000000000..7eeef3830 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/repository/TenantIsolationRepository.java @@ -0,0 +1,35 @@ +package com.agenticcp.core.domain.tenant.repository; + +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.tenant.entity.TenantIsolation; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 테넌트 격리 Repository + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Repository +public interface TenantIsolationRepository extends JpaRepository { + + /** + * 테넌트로 격리 정보 조회 + * + * @param tenant 테넌트 + * @return 격리 정보 + */ + Optional findByTenantAndIsDeletedFalse(Tenant tenant); + + /** + * 테넌트 ID로 격리 정보 조회 + * + * @param tenantId 테넌트 ID + * @return 격리 정보 + */ + Optional findByTenantIdAndIsDeletedFalse(Long tenantId); +} + diff --git a/src/main/java/com/agenticcp/core/domain/tenant/service/TenantIsolationService.java b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantIsolationService.java index e5ec47e08..f76f4c2ef 100644 --- a/src/main/java/com/agenticcp/core/domain/tenant/service/TenantIsolationService.java +++ b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantIsolationService.java @@ -1,185 +1,127 @@ package com.agenticcp.core.domain.tenant.service; -import com.agenticcp.core.domain.tenant.adapter.TenantIsolationAdapter; -import com.agenticcp.core.domain.tenant.adapter.TenantIsolationAdapterFactory; -import com.agenticcp.core.domain.tenant.adapter.dto.IsolationResult; -import com.agenticcp.core.domain.tenant.adapter.dto.IsolationStatus; import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.common.exception.ResourceNotFoundException; import com.agenticcp.core.common.enums.CommonErrorCode; -import com.agenticcp.core.domain.tenant.cloud.CloudProviderType; +import com.agenticcp.core.domain.tenant.entity.Tenant; import com.agenticcp.core.domain.tenant.entity.TenantIsolation; -import com.agenticcp.core.domain.tenant.event.TenantIsolationAppliedEvent; +import com.agenticcp.core.domain.tenant.repository.TenantIsolationRepository; +import com.agenticcp.core.common.util.LogMaskingUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; +import java.util.Optional; +/** + * 테넌트 격리 수준 관리 서비스 + * + *

테넌트의 격리 수준(SHARED/DEDICATED)을 조회하고 설정합니다.

+ * + * @author AgenticCP Team + * @version 1.0.0 + */ @Slf4j @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class TenantIsolationService { + private final TenantIsolationRepository tenantIsolationRepository; private final TenantService tenantService; - private final TenantIsolationAdapterFactory adapterFactory; - private final ApplicationEventPublisher eventPublisher; /** - * 테넌트에 격리 정책을 적용합니다. + * 테넌트의 격리 수준 조회 * - * @param tenantKey 테넌트 키 - * @param isolation 격리 정책 정보 - * @param cloudProviderType 클라우드 프로바이더 타입 - * @return 격리 정책 적용 결과 - * @throws BusinessException 테넌트를 찾을 수 없거나 격리 정책 적용에 실패한 경우 + * @param tenant 테넌트 + * @return 격리 수준 (없으면 null) */ - @Transactional - public IsolationResult applyIsolationPolicy(String tenantKey, TenantIsolation isolation, - CloudProviderType cloudProviderType) { - try { - - // Adapter 가져오기 - TenantIsolationAdapter adapter = adapterFactory.getAdapter(cloudProviderType); - - // Adapter 에게 위임 - IsolationResult result = adapter.applyIsolationPolicy(tenantKey, isolation); - - // 성공 시 이벤트 발행 (TenantIsolationEventListener가 비동기로 처리) - if (result.success()) { - eventPublisher.publishEvent(new TenantIsolationAppliedEvent(this, tenantKey, isolation)); - log.info("격리 정책이 성공적으로 적용되었습니다 - 테넌트: {}", tenantKey); - } else { - log.error("격리 정책 적용 실패 - 테넌트: {}, 사유: {}", - tenantKey, result.message()); - throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR, - "격리 정책 적용에 실패했습니다: " + result.message()); - } - - return result; - - } catch (Exception e) { - log.error("격리 정책 적용 중 오류 발생 - 테넌트: {}", tenantKey, e); - throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR, - "격리 정책 적용 중 오류가 발생했습니다: " + e.getMessage()); + public TenantIsolation.IsolationLevel getIsolationLevel(Tenant tenant) { + log.debug("[TenantIsolationService] getIsolationLevel - tenantId={}", tenant.getId()); + + Optional isolation = tenantIsolationRepository.findByTenantAndIsDeletedFalse(tenant); + + if (isolation.isPresent()) { + TenantIsolation.IsolationLevel level = isolation.get().getIsolationLevel(); + log.debug("[TenantIsolationService] getIsolationLevel - success level={}, tenantId={}", + level, tenant.getId()); + return level; } + + log.debug("[TenantIsolationService] getIsolationLevel - not found tenantId={}", tenant.getId()); + return null; } /** - * 테넌트의 격리 정책을 제거합니다. + * 테넌트의 격리 정보 조회 * - * @param tenantKey 테넌트 키 - * @param cloudProviderType 클라우드 프로바이더 타입 - * @throws BusinessException 격리 정책 제거에 실패한 경우 + * @param tenant 테넌트 + * @return 격리 정보 */ - @Transactional - public void removeIsolationPolicy(String tenantKey, CloudProviderType cloudProviderType) { - try { - TenantIsolationAdapter adapter = adapterFactory.getAdapter(cloudProviderType); - - adapter.removeIsolationPolicy(tenantKey); - - log.info("격리 정책이 성공적으로 제거되었습니다 - 테넌트: {}", tenantKey); - - } catch (Exception e) { - log.error("격리 정책 제거 중 오류 발생 - 테넌트: {}", tenantKey, e); - throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR, - "격리 정책 제거 중 오류가 발생했습니다: " + e.getMessage()); - } + public Optional getTenantIsolation(Tenant tenant) { + log.info("[TenantIsolationService] getTenantIsolation - tenantKey={}", + LogMaskingUtils.maskTenantKey(tenant.getTenantKey())); + + Optional isolation = tenantIsolationRepository.findByTenantAndIsDeletedFalse(tenant); + + log.info("[TenantIsolationService] getTenantIsolation - found={}, tenantKey={}", + isolation.isPresent(), LogMaskingUtils.maskTenantKey(tenant.getTenantKey())); + return isolation; } /** - * 테넌트의 격리 상태를 조회합니다. + * 테넌트의 격리 수준 설정 * - * @param tenantKey 테넌트 키 - * @param cloudProviderType 클라우드 프로바이더 타입 - * @return 격리 상태 정보 - * @throws BusinessException 격리 상태 조회에 실패한 경우 + * @param tenant 테넌트 + * @param isolationLevel 격리 수준 + * @return 저장된 격리 정보 */ - public IsolationStatus getIsolationStatus(String tenantKey, CloudProviderType cloudProviderType) { - try { - TenantIsolationAdapter adapter = adapterFactory.getAdapter(cloudProviderType); - return adapter.getIsolationStatus(tenantKey); - - } catch (Exception e) { - log.error("격리 상태 조회 중 오류 발생 - 테넌트: {}", tenantKey, e); - throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR, - "격리 상태 조회 중 오류가 발생했습니다: " + e.getMessage()); + @Transactional + public TenantIsolation setIsolationLevel(Tenant tenant, TenantIsolation.IsolationLevel isolationLevel) { + log.info("[TenantIsolationService] setIsolationLevel - tenantKey={}, level={}", + LogMaskingUtils.maskTenantKey(tenant.getTenantKey()), isolationLevel); + + Optional existing = tenantIsolationRepository.findByTenantAndIsDeletedFalse(tenant); + + TenantIsolation isolation; + if (existing.isPresent()) { + isolation = existing.get(); + isolation.setIsolationLevel(isolationLevel); + log.info("[TenantIsolationService] setIsolationLevel - updated tenantKey={}, level={}", + LogMaskingUtils.maskTenantKey(tenant.getTenantKey()), isolationLevel); + } else { + isolation = TenantIsolation.builder() + .tenant(tenant) + .isolationLevel(isolationLevel) + .build(); + log.info("[TenantIsolationService] setIsolationLevel - created tenantKey={}, level={}", + LogMaskingUtils.maskTenantKey(tenant.getTenantKey()), isolationLevel); } - } - /** - * 특정 격리 레벨을 지원하는 클라우드 프로바이더 목록을 조회합니다. - * - * @param isolationLevel 격리 레벨 - * @return 지원하는 클라우드 프로바이더 목록 - * @throws BusinessException 지원 프로바이더 조회에 실패한 경우 - */ - public List getSupportedProviders(TenantIsolation.IsolationLevel isolationLevel) { - try { - return adapterFactory.getAdaptersSupporting(isolationLevel).stream() - .map(adapter -> adapter.getSupportedCloudProvider()) - .toList(); - } catch (Exception e) { - log.error("지원하는 클라우드 프로바이더 조회 중 오류 발생 - 격리 레벨: {}", isolationLevel, e); - throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR, - "지원하는 클라우드 프로바이더 조회 중 오류가 발생했습니다: " + e.getMessage()); - } + TenantIsolation saved = tenantIsolationRepository.save(isolation); + log.info("[TenantIsolationService] setIsolationLevel - success tenantKey={}, level={}", + LogMaskingUtils.maskTenantKey(tenant.getTenantKey()), isolationLevel); + return saved; } /** - * 테넌트의 격리 정책을 업데이트합니다. - * 기존 정책을 제거하고 새로운 정책을 적용합니다. + * 테넌트의 격리 정보 저장 * - * @param tenantKey 테넌트 키 - * @param newIsolation 새로운 격리 정책 정보 - * @param cloudProviderType 클라우드 프로바이더 타입 - * @return 격리 정책 적용 결과 - * @throws BusinessException 격리 정책 업데이트에 실패한 경우 + * @param tenantIsolation 격리 정보 + * @return 저장된 격리 정보 */ @Transactional - public IsolationResult updateIsolationPolicy(String tenantKey, TenantIsolation newIsolation, - CloudProviderType cloudProviderType) { - try { - log.info("격리 정책 업데이트 시작 - 테넌트: {}, 프로바이더: {}", tenantKey, cloudProviderType); - - // 1. 기존 격리 정책 제거 - try { - removeIsolationPolicy(tenantKey, cloudProviderType); - } catch (BusinessException e) { - // 기존 정책이 없는 경우는 정상적인 상황일 수 있음 - log.warn("기존 격리 정책 제거 중 오류 발생 (무시됨) - 테넌트: {}, 오류: {}", - tenantKey, e.getMessage()); - } - - // 2. 새로운 격리 정책 적용 - return applyIsolationPolicy(tenantKey, newIsolation, cloudProviderType); - - } catch (BusinessException e) { - throw e; - } catch (Exception e) { - log.error("격리 정책 업데이트 중 오류 발생 - 테넌트: {}", tenantKey, e); - throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR, - "격리 정책 업데이트 중 오류가 발생했습니다: " + e.getMessage()); - } - } - - /** - * 테넌트의 격리 정책이 적용되어 있는지 확인합니다. - * - * @param tenantKey 테넌트 키 - * @param cloudProviderType 클라우드 프로바이더 타입 - * @return 격리 정책 적용 여부 - */ - public boolean isIsolationPolicyApplied(String tenantKey, CloudProviderType cloudProviderType) { - try { - IsolationStatus status = getIsolationStatus(tenantKey, cloudProviderType); - return status.isActive(); - } catch (Exception e) { - log.warn("격리 정책 적용 여부 확인 중 오류 발생 - 테넌트: {}, 오류: {}", - tenantKey, e.getMessage()); - return false; - } + public TenantIsolation saveTenantIsolation(TenantIsolation tenantIsolation) { + log.info("[TenantIsolationService] saveTenantIsolation - tenantKey={}, level={}", + LogMaskingUtils.maskTenantKey(tenantIsolation.getTenant().getTenantKey()), + tenantIsolation.getIsolationLevel()); + + TenantIsolation saved = tenantIsolationRepository.save(tenantIsolation); + + log.info("[TenantIsolationService] saveTenantIsolation - success tenantKey={}, level={}", + LogMaskingUtils.maskTenantKey(saved.getTenant().getTenantKey()), + saved.getIsolationLevel()); + return saved; } } From 16a1614ea034e1cdaaa53f792113f4b61f1b162c Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Mon, 24 Nov 2025 19:34:12 +0900 Subject: [PATCH 26/32] =?UTF-8?q?feat:=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20?= =?UTF-8?q?=EC=86=8C=EC=9C=A0=EA=B6=8C=20=EA=B4=80=EB=A6=AC=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/cloud/entity/ResourceOwner.java | 66 ++++++ .../repository/ResourceOwnerRepository.java | 105 +++++++++ .../cloud/service/ResourceOwnerService.java | 201 ++++++++++++++++++ 3 files changed, 372 insertions(+) create mode 100644 src/main/java/com/agenticcp/core/domain/cloud/entity/ResourceOwner.java create mode 100644 src/main/java/com/agenticcp/core/domain/cloud/repository/ResourceOwnerRepository.java create mode 100644 src/main/java/com/agenticcp/core/domain/cloud/service/ResourceOwnerService.java diff --git a/src/main/java/com/agenticcp/core/domain/cloud/entity/ResourceOwner.java b/src/main/java/com/agenticcp/core/domain/cloud/entity/ResourceOwner.java new file mode 100644 index 000000000..2f588cc03 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/entity/ResourceOwner.java @@ -0,0 +1,66 @@ +package com.agenticcp.core.domain.cloud.entity; + +import com.agenticcp.core.common.entity.BaseEntity; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 리소스 소유자 엔티티 + * + *

리소스와 사용자 간의 소유권 관계를 관리하는 중간 테이블입니다. + * DEDICATED 격리 모드에서 리소스 접근 권한을 제어하는데 사용됩니다.

+ * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Entity +@Table(name = "resource_owners", + uniqueConstraints = @UniqueConstraint( + name = "uk_resource_user_deleted", + columnNames = {"resource_id", "user_id", "is_deleted"} + ), + indexes = { + @Index(name = "idx_resource_owner_user_tenant", columnList = "user_id, tenant_id, is_deleted"), + @Index(name = "idx_resource_owner_resource_tenant", columnList = "resource_id, tenant_id, is_deleted") + }) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ResourceOwner extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "resource_id", nullable = false) + private CloudResource resource; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tenant_id", nullable = false) + private Tenant tenant; + + @Enumerated(EnumType.STRING) + @Column(name = "access_type", nullable = false, length = 20) + @Builder.Default + private AccessType accessType = AccessType.OWNER; + + /** + * 접근 타입 열거형 + */ + public enum AccessType { + /** 소유자 (리소스 생성자) */ + OWNER, + /** 공유 접근 (SHARED 모드에서 자동 추가, 향후 확장용) */ + SHARED, + /** 읽기 전용 (향후 확장용) */ + READ_ONLY + } +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/repository/ResourceOwnerRepository.java b/src/main/java/com/agenticcp/core/domain/cloud/repository/ResourceOwnerRepository.java new file mode 100644 index 000000000..a235414c2 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/repository/ResourceOwnerRepository.java @@ -0,0 +1,105 @@ +package com.agenticcp.core.domain.cloud.repository; + +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.entity.ResourceOwner; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 리소스 소유자 Repository + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Repository +public interface ResourceOwnerRepository extends JpaRepository { + + /** + * 사용자가 소유한 리소스 목록 조회 (JOIN 최적화) + * + * @param user 사용자 + * @param tenant 테넌트 + * @return 소유한 리소스 목록 + */ + @Query("SELECT DISTINCT ro.resource FROM ResourceOwner ro " + + "LEFT JOIN FETCH ro.resource.provider " + + "LEFT JOIN FETCH ro.resource.region " + + "LEFT JOIN FETCH ro.resource.service " + + "LEFT JOIN FETCH ro.resource.tenant " + + "WHERE ro.user = :user AND ro.tenant = :tenant AND ro.isDeleted = false " + + "AND ro.resource.isDeleted = false") + List findResourcesByOwner(@Param("user") User user, + @Param("tenant") Tenant tenant); + + /** + * 리소스 소유권 확인 + * + * @param resource 리소스 + * @param user 사용자 + * @return 소유권 존재 여부 + */ + boolean existsByResourceAndUserAndIsDeletedFalse(CloudResource resource, User user); + + /** + * 리소스 소유권 조회 + * + * @param resource 리소스 + * @param user 사용자 + * @return 리소스 소유권 정보 + */ + Optional findByResourceAndUserAndIsDeletedFalse(CloudResource resource, User user); + + /** + * 리소스의 모든 소유자 조회 + * + * @param resource 리소스 + * @return 소유자 목록 + */ + List findByResourceAndIsDeletedFalse(CloudResource resource); + + /** + * 사용자와 테넌트로 소유권 목록 조회 + * + * @param user 사용자 + * @param tenant 테넌트 + * @return 소유권 목록 + */ + List findByUserAndTenantAndIsDeletedFalse(User user, Tenant tenant); + + /** + * 사용자가 소유한 리소스 ID 목록 조회 (배치 최적화) + * + * @param user 사용자 + * @param tenant 테넌트 + * @return 소유한 리소스 ID 목록 + */ + @Query("SELECT ro.resource.id FROM ResourceOwner ro " + + "WHERE ro.user = :user AND ro.tenant = :tenant AND ro.isDeleted = false") + List findResourceIdsByOwner(@Param("user") User user, + @Param("tenant") Tenant tenant); + + /** + * 여러 리소스에 대한 사용자 소유권 일괄 확인 (배치 최적화) + * + * @param user 사용자 + * @param resourceIds 리소스 ID 목록 + * @param tenant 테넌트 + * @return 소유한 리소스 ID 목록 + */ + @Query("SELECT ro.resource.id FROM ResourceOwner ro " + + "WHERE ro.user = :user " + + "AND ro.resource.id IN :resourceIds " + + "AND ro.tenant = :tenant " + + "AND ro.isDeleted = false") + List findOwnedResourceIds(@Param("user") User user, + @Param("resourceIds") List resourceIds, + @Param("tenant") Tenant tenant); +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/ResourceOwnerService.java b/src/main/java/com/agenticcp/core/domain/cloud/service/ResourceOwnerService.java new file mode 100644 index 000000000..f8380bb16 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/service/ResourceOwnerService.java @@ -0,0 +1,201 @@ +package com.agenticcp.core.domain.cloud.service; + +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.common.exception.ResourceNotFoundException; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.entity.ResourceOwner; +import com.agenticcp.core.domain.cloud.repository.ResourceOwnerRepository; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.user.entity.User; +import com.agenticcp.core.common.util.LogMaskingUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +/** + * 리소스 소유권 관리 서비스 + * + *

리소스와 사용자 간의 소유권 관계를 관리합니다. + * DEDICATED 격리 모드에서 리소스 접근 권한을 제어하는데 사용됩니다.

+ * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ResourceOwnerService { + + private final ResourceOwnerRepository resourceOwnerRepository; + + /** + * 리소스 소유권 생성 + * + * @param resource 리소스 + * @param owner 소유자 (사용자) + */ + @Transactional + public void createResourceOwnership(CloudResource resource, User owner) { + log.info("[ResourceOwnerService] createResourceOwnership - resourceId={}, userId={}", + LogMaskingUtils.mask(String.valueOf(resource.getId()), 2, 2), + LogMaskingUtils.mask(String.valueOf(owner.getId()), 2, 2)); + + // 중복 확인 + if (resourceOwnerRepository.existsByResourceAndUserAndIsDeletedFalse(resource, owner)) { + log.warn("[ResourceOwnerService] createResourceOwnership - already exists resourceId={}, userId={}", + LogMaskingUtils.mask(String.valueOf(resource.getId()), 2, 2), + LogMaskingUtils.mask(String.valueOf(owner.getId()), 2, 2)); + return; + } + + // 소유권 생성 + ResourceOwner resourceOwner = ResourceOwner.builder() + .resource(resource) + .user(owner) + .tenant(resource.getTenant()) + .accessType(ResourceOwner.AccessType.OWNER) + .build(); + + resourceOwnerRepository.save(resourceOwner); + log.info("[ResourceOwnerService] createResourceOwnership - success resourceId={}, userId={}", + LogMaskingUtils.mask(String.valueOf(resource.getId()), 2, 2), + LogMaskingUtils.mask(String.valueOf(owner.getId()), 2, 2)); + } + + /** + * 사용자가 소유한 리소스 목록 조회 + * + * @param user 사용자 + * @param tenant 테넌트 + * @return 소유한 리소스 목록 + */ + public List getOwnedResources(User user, Tenant tenant) { + log.info("[ResourceOwnerService] getOwnedResources - userId={}, tenantKey={}", + LogMaskingUtils.mask(String.valueOf(user.getId()), 2, 2), + LogMaskingUtils.maskTenantKey(tenant.getTenantKey())); + + List resources = resourceOwnerRepository.findResourcesByOwner(user, tenant); + + log.info("[ResourceOwnerService] getOwnedResources - success count={}, userId={}", + resources.size(), + LogMaskingUtils.mask(String.valueOf(user.getId()), 2, 2)); + return resources; + } + + /** + * 리소스 소유권 확인 + * + * @param user 사용자 + * @param resource 리소스 + * @return 소유 여부 + */ + public boolean isResourceOwner(User user, CloudResource resource) { + log.debug("[ResourceOwnerService] isResourceOwner - userId={}, resourceId={}", + LogMaskingUtils.mask(String.valueOf(user.getId()), 2, 2), + LogMaskingUtils.mask(String.valueOf(resource.getId()), 2, 2)); + + boolean isOwner = resourceOwnerRepository.existsByResourceAndUserAndIsDeletedFalse(resource, user); + + log.debug("[ResourceOwnerService] isResourceOwner - result={}, userId={}, resourceId={}", + isOwner, + LogMaskingUtils.mask(String.valueOf(user.getId()), 2, 2), + LogMaskingUtils.mask(String.valueOf(resource.getId()), 2, 2)); + return isOwner; + } + + /** + * 리소스 소유권 조회 + * + * @param resource 리소스 + * @param user 사용자 + * @return 리소스 소유권 정보 + */ + public Optional getResourceOwnership(CloudResource resource, User user) { + log.debug("[ResourceOwnerService] getResourceOwnership - resourceId={}, userId={}", + LogMaskingUtils.mask(String.valueOf(resource.getId()), 2, 2), + LogMaskingUtils.mask(String.valueOf(user.getId()), 2, 2)); + + return resourceOwnerRepository.findByResourceAndUserAndIsDeletedFalse(resource, user); + } + + /** + * 리소스 소유권 삭제 (Soft Delete) + * + * @param resource 리소스 + * @param user 사용자 + */ + @Transactional + public void deleteResourceOwnership(CloudResource resource, User user) { + log.info("[ResourceOwnerService] deleteResourceOwnership - resourceId={}, userId={}", + LogMaskingUtils.mask(String.valueOf(resource.getId()), 2, 2), + LogMaskingUtils.mask(String.valueOf(user.getId()), 2, 2)); + + Optional resourceOwner = resourceOwnerRepository + .findByResourceAndUserAndIsDeletedFalse(resource, user); + + if (resourceOwner.isPresent()) { + resourceOwner.get().setIsDeleted(true); + resourceOwnerRepository.save(resourceOwner.get()); + log.info("[ResourceOwnerService] deleteResourceOwnership - success resourceId={}, userId={}", + LogMaskingUtils.mask(String.valueOf(resource.getId()), 2, 2), + LogMaskingUtils.mask(String.valueOf(user.getId()), 2, 2)); + } else { + log.warn("[ResourceOwnerService] deleteResourceOwnership - not found resourceId={}, userId={}", + LogMaskingUtils.mask(String.valueOf(resource.getId()), 2, 2), + LogMaskingUtils.mask(String.valueOf(user.getId()), 2, 2)); + } + } + + /** + * 사용자가 소유한 리소스 ID 목록 조회 (배치 최적화) + * + * @param user 사용자 + * @param tenant 테넌트 + * @return 소유한 리소스 ID 목록 + */ + public List getOwnedResourceIds(User user, Tenant tenant) { + log.debug("[ResourceOwnerService] getOwnedResourceIds - userId={}, tenantKey={}", + LogMaskingUtils.mask(String.valueOf(user.getId()), 2, 2), + LogMaskingUtils.maskTenantKey(tenant.getTenantKey())); + + List resourceIds = resourceOwnerRepository.findResourceIdsByOwner(user, tenant); + + log.debug("[ResourceOwnerService] getOwnedResourceIds - success count={}, userId={}", + resourceIds.size(), + LogMaskingUtils.mask(String.valueOf(user.getId()), 2, 2)); + return resourceIds; + } + + /** + * 여러 리소스에 대한 사용자 소유권 일괄 확인 (배치 최적화) + * + * @param user 사용자 + * @param resourceIds 리소스 ID 목록 + * @param tenant 테넌트 + * @return 소유한 리소스 ID 목록 + */ + public List getOwnedResourceIdsBatch(User user, List resourceIds, Tenant tenant) { + log.debug("[ResourceOwnerService] getOwnedResourceIdsBatch - userId={}, resourceCount={}, tenantKey={}", + LogMaskingUtils.mask(String.valueOf(user.getId()), 2, 2), + resourceIds.size(), + LogMaskingUtils.maskTenantKey(tenant.getTenantKey())); + + if (resourceIds.isEmpty()) { + return List.of(); + } + + List ownedResourceIds = resourceOwnerRepository.findOwnedResourceIds(user, resourceIds, tenant); + + log.debug("[ResourceOwnerService] getOwnedResourceIdsBatch - success ownedCount={}, totalCount={}, userId={}", + ownedResourceIds.size(), + resourceIds.size(), + LogMaskingUtils.mask(String.valueOf(user.getId()), 2, 2)); + return ownedResourceIds; + } +} + From f472da1254875d4aec3ee4d8c9fbef4672a4de11 Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Mon, 24 Nov 2025 19:34:41 +0900 Subject: [PATCH 27/32] =?UTF-8?q?feat:=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=20=EC=A0=9C=EC=96=B4=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=B0=8F=20CloudResourceService=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/util/SecurityContextUtils.java | 41 +++++ .../cloud/service/CloudResourceService.java | 172 +++++++++++++++++- .../service/ResourceAccessControlService.java | 164 +++++++++++++++++ 3 files changed, 369 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/agenticcp/core/common/util/SecurityContextUtils.java create mode 100644 src/main/java/com/agenticcp/core/domain/cloud/service/ResourceAccessControlService.java diff --git a/src/main/java/com/agenticcp/core/common/util/SecurityContextUtils.java b/src/main/java/com/agenticcp/core/common/util/SecurityContextUtils.java new file mode 100644 index 000000000..26740fd4a --- /dev/null +++ b/src/main/java/com/agenticcp/core/common/util/SecurityContextUtils.java @@ -0,0 +1,41 @@ +package com.agenticcp.core.common.util; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * SecurityContext 유틸리티 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +public class SecurityContextUtils { + + /** + * 현재 인증된 사용자명 조회 + * + * @return 사용자명, 없으면 null + */ + public static String getCurrentUsername() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.isAuthenticated()) { + return authentication.getName(); + } + return null; + } + + /** + * 현재 인증된 사용자명 조회 (null 체크 포함) + * + * @return 사용자명 + * @throws IllegalStateException 인증 정보가 없는 경우 + */ + public static String getCurrentUsernameOrThrow() { + String username = getCurrentUsername(); + if (username == null) { + throw new IllegalStateException("현재 인증된 사용자 정보가 없습니다"); + } + return username; + } +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/CloudResourceService.java b/src/main/java/com/agenticcp/core/domain/cloud/service/CloudResourceService.java index 52dbb6241..5c8d3f80b 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/service/CloudResourceService.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/service/CloudResourceService.java @@ -1,8 +1,18 @@ package com.agenticcp.core.domain.cloud.service; +import com.agenticcp.core.common.exception.AuthorizationException; +import com.agenticcp.core.common.exception.ResourceNotFoundException; +import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; +import com.agenticcp.core.common.util.LogMaskingUtils; +import com.agenticcp.core.common.util.SecurityContextUtils; +import com.agenticcp.core.common.context.TenantContextHolder; import com.agenticcp.core.domain.cloud.entity.CloudResource; import com.agenticcp.core.domain.cloud.repository.CloudResourceRepository; -import com.agenticcp.core.common.util.LogMaskingUtils; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.tenant.entity.TenantIsolation; +import com.agenticcp.core.domain.tenant.service.TenantIsolationService; +import com.agenticcp.core.domain.user.entity.User; +import com.agenticcp.core.domain.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -13,6 +23,8 @@ /** * 클라우드 리소스 관리 서비스 * + *

리소스 생성/조회/수정/삭제 시 접근 제어를 적용합니다.

+ * * @author AgenticCP Team * @version 1.0.0 * @since 2025-10-06 @@ -24,27 +36,161 @@ public class CloudResourceService { private final CloudResourceRepository cloudResourceRepository; + private final ResourceOwnerService resourceOwnerService; + private final ResourceAccessControlService accessControlService; + private final TenantIsolationService tenantIsolationService; + private final UserService userService; /** - * 테넌트 키로 클라우드 리소스 목록 조회 + * 리소스 생성 + * + * @param resource 리소스 엔티티 + * @return 생성된 리소스 + */ + @Transactional + public CloudResource createResource(CloudResource resource) { + log.info("[CloudResourceService] createResource - resourceName={}", resource.getResourceName()); + + // 현재 사용자 및 테넌트 정보 가져오기 + User currentUser = getCurrentUser(); + Tenant currentTenant = TenantContextHolder.getCurrentTenantOrThrow(); + + // 리소스에 테넌트 설정 + resource.setTenant(currentTenant); + + // 리소스 저장 + CloudResource saved = cloudResourceRepository.save(resource); + + // 소유권 등록 (중간 테이블에 레코드 추가) + resourceOwnerService.createResourceOwnership(saved, currentUser); + + log.info("[CloudResourceService] createResource - success resourceId={}, userId={}", + saved.getId(), currentUser.getId()); + return saved; + } + + /** + * 사용자가 접근 가능한 리소스 목록 조회 + * + * @return 접근 가능한 리소스 목록 + */ + public List getAccessibleResources() { + log.info("[CloudResourceService] getAccessibleResources"); + + User currentUser = getCurrentUser(); + Tenant currentTenant = TenantContextHolder.getCurrentTenantOrThrow(); + + // 격리 수준 조회 + TenantIsolation.IsolationLevel level = tenantIsolationService.getIsolationLevel(currentTenant); + + List resources; + if (level == TenantIsolation.IsolationLevel.SHARED) { + // SHARED: 테넌트의 모든 리소스 + resources = cloudResourceRepository.findByTenantId(currentTenant.getTenantKey()); + log.info("[CloudResourceService] getAccessibleResources - SHARED mode, count={}", resources.size()); + } else { + // DEDICATED: ResourceOwner 테이블을 통해 소유한 리소스만 + resources = resourceOwnerService.getOwnedResources(currentUser, currentTenant); + log.info("[CloudResourceService] getAccessibleResources - DEDICATED mode, count={}", resources.size()); + } + + log.info("[CloudResourceService] getAccessibleResources - success count={}", resources.size()); + return resources; + } + + /** + * 리소스 조회 (접근 권한 검증) * - * @param tenantKey 테넌트 키 (tenantKey) + * @param resourceId 리소스 ID + * @return 리소스 + * @throws ResourceNotFoundException 리소스를 찾을 수 없는 경우 + * @throws com.agenticcp.core.common.exception.AccessDeniedException 접근 권한이 없는 경우 + */ + public CloudResource getResource(Long resourceId) { + log.info("[CloudResourceService] getResource - resourceId={}", resourceId); + + CloudResource resource = cloudResourceRepository.findById(resourceId) + .orElseThrow(() -> new ResourceNotFoundException(CloudErrorCode.CLOUD_RESOURCE_NOT_FOUND)); + + // 접근 권한 검증 + User currentUser = getCurrentUser(); + if (!accessControlService.canAccessResource(currentUser, resource)) { + throw new AuthorizationException(currentUser.getId(), "CloudResource", "조회"); + } + + log.info("[CloudResourceService] getResource - success resourceId={}", resourceId); + return resource; + } + + /** + * 리소스 수정 (접근 권한 검증) + * + * @param resourceId 리소스 ID + * @param resource 수정할 리소스 정보 + * @return 수정된 리소스 + */ + @Transactional + public CloudResource updateResource(Long resourceId, CloudResource resource) { + log.info("[CloudResourceService] updateResource - resourceId={}", resourceId); + + // 기존 리소스 조회 및 접근 권한 검증 + CloudResource existing = getResource(resourceId); + + // 리소스 정보 업데이트 + existing.setResourceName(resource.getResourceName()); + existing.setDisplayName(resource.getDisplayName()); + existing.setStatus(resource.getStatus()); + existing.setLifecycleState(resource.getLifecycleState()); + // 필요한 필드 추가 업데이트 + + CloudResource saved = cloudResourceRepository.save(existing); + log.info("[CloudResourceService] updateResource - success resourceId={}", resourceId); + return saved; + } + + /** + * 리소스 삭제 (접근 권한 검증) + * + * @param resourceId 리소스 ID + */ + @Transactional + public void deleteResource(Long resourceId) { + log.info("[CloudResourceService] deleteResource - resourceId={}", resourceId); + + // 기존 리소스 조회 및 접근 권한 검증 + CloudResource resource = getResource(resourceId); + + // Soft Delete + resource.setIsDeleted(true); + cloudResourceRepository.save(resource); + + // ResourceOwner도 Soft Delete + User currentUser = getCurrentUser(); + resourceOwnerService.deleteResourceOwnership(resource, currentUser); + + log.info("[CloudResourceService] deleteResource - success resourceId={}", resourceId); + } + + /** + * 테넌트 키로 클라우드 리소스 목록 조회 (관리자용) + * + * @param tenantKey 테넌트 키 * @return 클라우드 리소스 목록 */ public List getResourcesByTenant(String tenantKey) { log.info("[CloudResourceService] getResourcesByTenant - tenantKey={}", - LogMaskingUtils.mask(tenantKey, 2, 2)); + LogMaskingUtils.maskTenantKey(tenantKey)); List resources = cloudResourceRepository.findByTenantId(tenantKey); - log.info("[CloudResourceService] getResourcesByTenant - success count={} tenantId={}", - resources.size(), LogMaskingUtils.mask(tenantKey, 2, 2)); + log.info("[CloudResourceService] getResourcesByTenant - success count={} tenantKey={}", + resources.size(), LogMaskingUtils.maskTenantKey(tenantKey)); return resources; } - + /** - * 리소스 ID로 조회 + * 리소스 ID로 조회 (관리자용, 접근 권한 검증 없음) * * @param resourceId 리소스 ID * @return 클라우드 리소스 @@ -60,5 +206,15 @@ public CloudResource getResourceById(String resourceId) { return resource; } + + /** + * 현재 사용자 조회 + * + * @return 현재 사용자 + */ + private User getCurrentUser() { + String username = SecurityContextUtils.getCurrentUsernameOrThrow(); + return userService.getUserByUsernameOrThrow(username); + } } diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/ResourceAccessControlService.java b/src/main/java/com/agenticcp/core/domain/cloud/service/ResourceAccessControlService.java new file mode 100644 index 000000000..c6796f3c2 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/service/ResourceAccessControlService.java @@ -0,0 +1,164 @@ +package com.agenticcp.core.domain.cloud.service; + +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.tenant.entity.TenantIsolation; +import com.agenticcp.core.domain.tenant.service.TenantIsolationService; +import com.agenticcp.core.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 리소스 접근 제어 서비스 + * + *

테넌트 격리 수준에 따라 리소스 접근 권한을 검증합니다.

+ * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ResourceAccessControlService { + + private final TenantIsolationService tenantIsolationService; + private final ResourceOwnerService resourceOwnerService; + + /** + * 리소스 접근 권한 검증 + * + * @param user 사용자 + * @param resource 리소스 + * @return 접근 가능 여부 + */ + public boolean canAccessResource(User user, CloudResource resource) { + log.debug("[ResourceAccessControlService] canAccessResource - userId={}, resourceId={}", + user.getId(), resource.getId()); + + // 1. 테넌트 일치 확인 + if (!isSameTenant(user.getTenant(), resource.getTenant())) { + log.warn("[ResourceAccessControlService] canAccessResource - denied: different tenant - userId={}, resourceId={}", + user.getId(), resource.getId()); + return false; + } + + // 2. 격리 수준 조회 + TenantIsolation.IsolationLevel level = tenantIsolationService.getIsolationLevel(resource.getTenant()); + + // 3. 격리 수준이 없으면 기본적으로 거부 (안전한 기본값) + if (level == null) { + log.warn("[ResourceAccessControlService] canAccessResource - denied: isolation level not set - tenantId={}", + resource.getTenant().getId()); + return false; + } + + // 4. SHARED 모드: 테넌트 내 모든 사용자 접근 가능 + if (level == TenantIsolation.IsolationLevel.SHARED) { + log.debug("[ResourceAccessControlService] canAccessResource - granted: SHARED mode - userId={}, resourceId={}", + user.getId(), resource.getId()); + return true; + } + + // 5. DEDICATED 모드: ResourceOwner 테이블에서 소유권 확인 + if (level == TenantIsolation.IsolationLevel.DEDICATED) { + boolean isOwner = resourceOwnerService.isResourceOwner(user, resource); + if (isOwner) { + log.debug("[ResourceAccessControlService] canAccessResource - granted: DEDICATED mode (owner) - userId={}, resourceId={}", + user.getId(), resource.getId()); + } else { + log.warn("[ResourceAccessControlService] canAccessResource - denied: DEDICATED mode (not owner) - userId={}, resourceId={}", + user.getId(), resource.getId()); + } + return isOwner; + } + + // 6. 알 수 없는 격리 수준 + log.error("[ResourceAccessControlService] canAccessResource - denied: unknown isolation level - level={}, tenantId={}", + level, resource.getTenant().getId()); + return false; + } + + /** + * 접근 가능한 리소스만 필터링 + * + * @param user 사용자 + * @param resources 리소스 목록 + * @return 접근 가능한 리소스 목록 + */ + public List filterAccessibleResources(User user, List resources) { + log.debug("[ResourceAccessControlService] filterAccessibleResources - userId={}, resourceCount={}", + user.getId(), resources.size()); + + if (resources.isEmpty()) { + return List.of(); + } + + // 1. 테넌트 일치 확인 + Tenant userTenant = user.getTenant(); + List sameTenantResources = resources.stream() + .filter(resource -> isSameTenant(userTenant, resource.getTenant())) + .collect(Collectors.toList()); + + if (sameTenantResources.isEmpty()) { + log.debug("[ResourceAccessControlService] filterAccessibleResources - no same tenant resources"); + return List.of(); + } + + // 2. 격리 수준 조회 (한 번만) + TenantIsolation.IsolationLevel level = tenantIsolationService.getIsolationLevel(userTenant); + + // 3. SHARED 모드: 테넌트 일치한 모든 리소스 반환 + if (level == TenantIsolation.IsolationLevel.SHARED) { + log.debug("[ResourceAccessControlService] filterAccessibleResources - SHARED mode, returning all same tenant resources"); + return sameTenantResources; + } + + // 4. DEDICATED 모드: 배치로 소유권 확인 (N+1 문제 해결) + if (level == TenantIsolation.IsolationLevel.DEDICATED) { + List resourceIds = sameTenantResources.stream() + .map(CloudResource::getId) + .collect(Collectors.toList()); + + // 배치로 소유한 리소스 ID 조회 (한 번의 쿼리) + List ownedResourceIds = resourceOwnerService.getOwnedResourceIdsBatch( + user, resourceIds, userTenant); + + // 소유한 리소스만 필터링 + Set ownedResourceIdSet = new HashSet<>(ownedResourceIds); + List accessibleResources = sameTenantResources.stream() + .filter(resource -> ownedResourceIdSet.contains(resource.getId())) + .collect(Collectors.toList()); + + log.debug("[ResourceAccessControlService] filterAccessibleResources - DEDICATED mode, accessibleCount={}, totalCount={}", + accessibleResources.size(), resources.size()); + return accessibleResources; + } + + // 5. 알 수 없는 격리 수준: 접근 거부 + log.warn("[ResourceAccessControlService] filterAccessibleResources - unknown isolation level: {}", level); + return List.of(); + } + + /** + * 테넌트 일치 확인 + * + * @param userTenant 사용자의 테넌트 + * @param resourceTenant 리소스의 테넌트 + * @return 테넌트 일치 여부 + */ + private boolean isSameTenant(Tenant userTenant, Tenant resourceTenant) { + if (userTenant == null || resourceTenant == null) { + return false; + } + return userTenant.getId().equals(resourceTenant.getId()); + } +} + From 3c6a1e723cc19ac1ffca10bfcf15e00d7a8dac2f Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Mon, 24 Nov 2025 19:34:59 +0900 Subject: [PATCH 28/32] =?UTF-8?q?test:=20=ED=85=8C=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=A9=EB=A6=AC=20=EC=88=98=EC=A4=80=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CloudResourceServiceTest.java | 355 +++++++++++++++++ .../ResourceAccessControlServiceTest.java | 376 ++++++++++++++++++ .../service/ResourceOwnerServiceTest.java | 329 +++++++++++++++ .../service/TenantIsolationServiceTest.java | 228 +++++++++++ 4 files changed, 1288 insertions(+) create mode 100644 src/test/java/com/agenticcp/core/domain/cloud/service/CloudResourceServiceTest.java create mode 100644 src/test/java/com/agenticcp/core/domain/cloud/service/ResourceAccessControlServiceTest.java create mode 100644 src/test/java/com/agenticcp/core/domain/cloud/service/ResourceOwnerServiceTest.java create mode 100644 src/test/java/com/agenticcp/core/domain/tenant/service/TenantIsolationServiceTest.java diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/CloudResourceServiceTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/CloudResourceServiceTest.java new file mode 100644 index 000000000..b750b022a --- /dev/null +++ b/src/test/java/com/agenticcp/core/domain/cloud/service/CloudResourceServiceTest.java @@ -0,0 +1,355 @@ +package com.agenticcp.core.domain.cloud.service; + +import com.agenticcp.core.common.context.TenantContextHolder; +import com.agenticcp.core.common.exception.AuthorizationException; +import com.agenticcp.core.common.exception.ResourceNotFoundException; +import com.agenticcp.core.common.util.SecurityContextUtils; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.repository.CloudResourceRepository; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.tenant.entity.TenantIsolation; +import com.agenticcp.core.domain.tenant.service.TenantIsolationService; +import com.agenticcp.core.domain.user.entity.User; +import com.agenticcp.core.domain.user.service.UserService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * CloudResourceService 단위 테스트 + * + *

클라우드 리소스 관리 서비스의 핵심 기능과 접근 제어를 검증합니다.

+ * + * @author AgenticCP Team + * @version 1.0.0 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("CloudResourceService 단위 테스트") +class CloudResourceServiceTest { + + @Mock + private CloudResourceRepository cloudResourceRepository; + + @Mock + private ResourceOwnerService resourceOwnerService; + + @Mock + private ResourceAccessControlService accessControlService; + + @Mock + private TenantIsolationService tenantIsolationService; + + @Mock + private UserService userService; + + @InjectMocks + private CloudResourceService cloudResourceService; + + private Tenant testTenant; + private User testUser; + private CloudResource testResource1; + private CloudResource testResource2; + + @BeforeEach + void setUp() { + testTenant = Tenant.builder() + .tenantKey("test-tenant-001") + .tenantName("Test Tenant") + .build(); + testTenant.setId(1L); + + testUser = User.builder() + .username("testuser") + .email("test@example.com") + .name("Test User") + .tenant(testTenant) + .build(); + testUser.setId(1L); + + testResource1 = CloudResource.builder() + .resourceId("resource-001") + .resourceName("Resource 1") + .tenant(testTenant) + .build(); + testResource1.setId(1L); + + testResource2 = CloudResource.builder() + .resourceId("resource-002") + .resourceName("Resource 2") + .tenant(testTenant) + .build(); + testResource2.setId(2L); + } + + @AfterEach + void tearDown() { + TenantContextHolder.clear(); + } + + @Nested + @DisplayName("createResource 테스트") + class CreateResourceTest { + + @Test + @DisplayName("리소스 생성 → 테넌트 설정 및 소유권 등록") + void createResource_리소스생성_테넌트설정및소유권등록() { + // Given + CloudResource newResource = CloudResource.builder() + .resourceId("new-resource-001") + .resourceName("New Resource") + .build(); + + try (MockedStatic tenantContextHolder = mockStatic(TenantContextHolder.class); + MockedStatic securityContextUtils = mockStatic(SecurityContextUtils.class)) { + + tenantContextHolder.when(TenantContextHolder::getCurrentTenantOrThrow).thenReturn(testTenant); + securityContextUtils.when(SecurityContextUtils::getCurrentUsernameOrThrow).thenReturn("testuser"); + when(userService.getUserByUsernameOrThrow("testuser")).thenReturn(testUser); + when(cloudResourceRepository.save(any(CloudResource.class))).thenReturn(testResource1); + + // When + CloudResource result = cloudResourceService.createResource(newResource); + + // Then + assertThat(result).isEqualTo(testResource1); + assertThat(newResource.getTenant()).isEqualTo(testTenant); + verify(cloudResourceRepository).save(newResource); + verify(resourceOwnerService).createResourceOwnership(testResource1, testUser); + } + } + } + + @Nested + @DisplayName("getAccessibleResources 테스트") + class GetAccessibleResourcesTest { + + @Test + @DisplayName("SHARED 모드에서 접근 가능한 리소스 조회 → 테넌트의 모든 리소스 반환") + void getAccessibleResources_SHARED모드_접근가능한리소스조회_테넌트의모든리소스반환() { + // Given + List allResources = Arrays.asList(testResource1, testResource2); + + try (MockedStatic tenantContextHolder = mockStatic(TenantContextHolder.class); + MockedStatic securityContextUtils = mockStatic(SecurityContextUtils.class)) { + + tenantContextHolder.when(TenantContextHolder::getCurrentTenantOrThrow).thenReturn(testTenant); + securityContextUtils.when(SecurityContextUtils::getCurrentUsernameOrThrow).thenReturn("testuser"); + when(userService.getUserByUsernameOrThrow("testuser")).thenReturn(testUser); + when(tenantIsolationService.getIsolationLevel(testTenant)) + .thenReturn(TenantIsolation.IsolationLevel.SHARED); + when(cloudResourceRepository.findByTenantId(testTenant.getTenantKey())) + .thenReturn(allResources); + + // When + List result = cloudResourceService.getAccessibleResources(); + + // Then + assertThat(result).hasSize(2); + assertThat(result).containsExactly(testResource1, testResource2); + verify(tenantIsolationService).getIsolationLevel(testTenant); + verify(cloudResourceRepository).findByTenantId(testTenant.getTenantKey()); + verify(resourceOwnerService, never()).getOwnedResources(any(), any()); + } + } + + @Test + @DisplayName("DEDICATED 모드에서 접근 가능한 리소스 조회 → 소유한 리소스만 반환") + void getAccessibleResources_DEDICATED모드_접근가능한리소스조회_소유한리소스만반환() { + // Given + List ownedResources = Arrays.asList(testResource1); + + try (MockedStatic tenantContextHolder = mockStatic(TenantContextHolder.class); + MockedStatic securityContextUtils = mockStatic(SecurityContextUtils.class)) { + + tenantContextHolder.when(TenantContextHolder::getCurrentTenantOrThrow).thenReturn(testTenant); + securityContextUtils.when(SecurityContextUtils::getCurrentUsernameOrThrow).thenReturn("testuser"); + when(userService.getUserByUsernameOrThrow("testuser")).thenReturn(testUser); + when(tenantIsolationService.getIsolationLevel(testTenant)) + .thenReturn(TenantIsolation.IsolationLevel.DEDICATED); + when(resourceOwnerService.getOwnedResources(testUser, testTenant)) + .thenReturn(ownedResources); + + // When + List result = cloudResourceService.getAccessibleResources(); + + // Then + assertThat(result).hasSize(1); + assertThat(result).containsExactly(testResource1); + verify(tenantIsolationService).getIsolationLevel(testTenant); + verify(resourceOwnerService).getOwnedResources(testUser, testTenant); + verify(cloudResourceRepository, never()).findByTenantId(any()); + } + } + } + + @Nested + @DisplayName("getResource 테스트") + class GetResourceTest { + + @Test + @DisplayName("접근 권한이 있는 리소스 조회 → 리소스 반환") + void getResource_접근권한있는리소스조회_리소스반환() { + // Given + try (MockedStatic securityContextUtils = mockStatic(SecurityContextUtils.class)) { + securityContextUtils.when(SecurityContextUtils::getCurrentUsernameOrThrow).thenReturn("testuser"); + when(userService.getUserByUsernameOrThrow("testuser")).thenReturn(testUser); + when(cloudResourceRepository.findById(1L)).thenReturn(Optional.of(testResource1)); + when(accessControlService.canAccessResource(testUser, testResource1)).thenReturn(true); + + // When + CloudResource result = cloudResourceService.getResource(1L); + + // Then + assertThat(result).isEqualTo(testResource1); + verify(cloudResourceRepository).findById(1L); + verify(accessControlService).canAccessResource(testUser, testResource1); + } + } + + @Test + @DisplayName("리소스를 찾을 수 없는 경우 → ResourceNotFoundException 발생") + void getResource_리소스찾을수없음_ResourceNotFoundException발생() { + // Given + try (MockedStatic securityContextUtils = mockStatic(SecurityContextUtils.class)) { + when(cloudResourceRepository.findById(999L)).thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> cloudResourceService.getResource(999L)) + .isInstanceOf(ResourceNotFoundException.class); + verify(cloudResourceRepository).findById(999L); + verify(accessControlService, never()).canAccessResource(any(), any()); + } + } + + @Test + @DisplayName("접근 권한이 없는 경우 → AuthorizationException 발생") + void getResource_접근권한없음_AuthorizationException발생() { + // Given + try (MockedStatic securityContextUtils = mockStatic(SecurityContextUtils.class)) { + securityContextUtils.when(SecurityContextUtils::getCurrentUsernameOrThrow).thenReturn("testuser"); + when(userService.getUserByUsernameOrThrow("testuser")).thenReturn(testUser); + when(cloudResourceRepository.findById(1L)).thenReturn(Optional.of(testResource1)); + when(accessControlService.canAccessResource(testUser, testResource1)).thenReturn(false); + + // When & Then + assertThatThrownBy(() -> cloudResourceService.getResource(1L)) + .isInstanceOf(AuthorizationException.class); + verify(cloudResourceRepository).findById(1L); + verify(accessControlService).canAccessResource(testUser, testResource1); + } + } + } + + @Nested + @DisplayName("updateResource 테스트") + class UpdateResourceTest { + + @Test + @DisplayName("접근 권한이 있는 리소스 수정 → 리소스 수정 성공") + void updateResource_접근권한있는리소스수정_리소스수정성공() { + // Given + CloudResource updateData = CloudResource.builder() + .resourceName("Updated Resource") + .displayName("Updated Display Name") + .build(); + + try (MockedStatic securityContextUtils = mockStatic(SecurityContextUtils.class)) { + securityContextUtils.when(SecurityContextUtils::getCurrentUsernameOrThrow).thenReturn("testuser"); + when(userService.getUserByUsernameOrThrow("testuser")).thenReturn(testUser); + when(cloudResourceRepository.findById(1L)).thenReturn(Optional.of(testResource1)); + when(accessControlService.canAccessResource(testUser, testResource1)).thenReturn(true); + when(cloudResourceRepository.save(testResource1)).thenReturn(testResource1); + + // When + CloudResource result = cloudResourceService.updateResource(1L, updateData); + + // Then + assertThat(result).isEqualTo(testResource1); + assertThat(testResource1.getResourceName()).isEqualTo("Updated Resource"); + assertThat(testResource1.getDisplayName()).isEqualTo("Updated Display Name"); + verify(cloudResourceRepository).save(testResource1); + } + } + + @Test + @DisplayName("접근 권한이 없는 리소스 수정 → AuthorizationException 발생") + void updateResource_접근권한없는리소스수정_AuthorizationException발생() { + // Given + CloudResource updateData = CloudResource.builder() + .resourceName("Updated Resource") + .build(); + + try (MockedStatic securityContextUtils = mockStatic(SecurityContextUtils.class)) { + securityContextUtils.when(SecurityContextUtils::getCurrentUsernameOrThrow).thenReturn("testuser"); + when(userService.getUserByUsernameOrThrow("testuser")).thenReturn(testUser); + when(cloudResourceRepository.findById(1L)).thenReturn(Optional.of(testResource1)); + when(accessControlService.canAccessResource(testUser, testResource1)).thenReturn(false); + + // When & Then + assertThatThrownBy(() -> cloudResourceService.updateResource(1L, updateData)) + .isInstanceOf(AuthorizationException.class); + verify(cloudResourceRepository, never()).save(any()); + } + } + } + + @Nested + @DisplayName("deleteResource 테스트") + class DeleteResourceTest { + + @Test + @DisplayName("접근 권한이 있는 리소스 삭제 → Soft Delete 처리 및 소유권 삭제") + void deleteResource_접근권한있는리소스삭제_SoftDelete처리및소유권삭제() { + // Given + try (MockedStatic securityContextUtils = mockStatic(SecurityContextUtils.class)) { + securityContextUtils.when(SecurityContextUtils::getCurrentUsernameOrThrow).thenReturn("testuser"); + when(userService.getUserByUsernameOrThrow("testuser")).thenReturn(testUser); + when(cloudResourceRepository.findById(1L)).thenReturn(Optional.of(testResource1)); + when(accessControlService.canAccessResource(testUser, testResource1)).thenReturn(true); + when(cloudResourceRepository.save(testResource1)).thenReturn(testResource1); + + // When + cloudResourceService.deleteResource(1L); + + // Then + assertThat(testResource1.getIsDeleted()).isTrue(); + verify(cloudResourceRepository).save(testResource1); + verify(resourceOwnerService).deleteResourceOwnership(testResource1, testUser); + } + } + + @Test + @DisplayName("접근 권한이 없는 리소스 삭제 → AuthorizationException 발생") + void deleteResource_접근권한없는리소스삭제_AuthorizationException발생() { + // Given + try (MockedStatic securityContextUtils = mockStatic(SecurityContextUtils.class)) { + securityContextUtils.when(SecurityContextUtils::getCurrentUsernameOrThrow).thenReturn("testuser"); + when(userService.getUserByUsernameOrThrow("testuser")).thenReturn(testUser); + when(cloudResourceRepository.findById(1L)).thenReturn(Optional.of(testResource1)); + when(accessControlService.canAccessResource(testUser, testResource1)).thenReturn(false); + + // When & Then + assertThatThrownBy(() -> cloudResourceService.deleteResource(1L)) + .isInstanceOf(AuthorizationException.class); + verify(cloudResourceRepository, never()).save(any()); + verify(resourceOwnerService, never()).deleteResourceOwnership(any(), any()); + } + } + } +} + diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/ResourceAccessControlServiceTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/ResourceAccessControlServiceTest.java new file mode 100644 index 000000000..8751276be --- /dev/null +++ b/src/test/java/com/agenticcp/core/domain/cloud/service/ResourceAccessControlServiceTest.java @@ -0,0 +1,376 @@ +package com.agenticcp.core.domain.cloud.service; + +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.tenant.entity.TenantIsolation; +import com.agenticcp.core.domain.tenant.service.TenantIsolationService; +import com.agenticcp.core.domain.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * ResourceAccessControlService 단위 테스트 + * + *

리소스 접근 제어 서비스의 핵심 기능을 검증합니다.

+ * + * @author AgenticCP Team + * @version 1.0.0 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("ResourceAccessControlService 단위 테스트") +class ResourceAccessControlServiceTest { + + @Mock + private TenantIsolationService tenantIsolationService; + + @Mock + private ResourceOwnerService resourceOwnerService; + + @InjectMocks + private ResourceAccessControlService resourceAccessControlService; + + private Tenant testTenant1; + private Tenant testTenant2; + private User testUser1; + private User testUser2; + private CloudResource testResource1; + private CloudResource testResource2; + private CloudResource testResource3; + + @BeforeEach + void setUp() { + testTenant1 = Tenant.builder() + .tenantKey("test-tenant-001") + .tenantName("Test Tenant 1") + .build(); + testTenant1.setId(1L); + + testTenant2 = Tenant.builder() + .tenantKey("test-tenant-002") + .tenantName("Test Tenant 2") + .build(); + testTenant2.setId(2L); + + testUser1 = User.builder() + .username("user1") + .email("user1@example.com") + .name("User 1") + .tenant(testTenant1) + .build(); + testUser1.setId(1L); + + testUser2 = User.builder() + .username("user2") + .email("user2@example.com") + .name("User 2") + .tenant(testTenant1) + .build(); + testUser2.setId(2L); + + testResource1 = CloudResource.builder() + .resourceId("resource-001") + .resourceName("Resource 1") + .tenant(testTenant1) + .build(); + testResource1.setId(1L); + + testResource2 = CloudResource.builder() + .resourceId("resource-002") + .resourceName("Resource 2") + .tenant(testTenant1) + .build(); + testResource2.setId(2L); + + testResource3 = CloudResource.builder() + .resourceId("resource-003") + .resourceName("Resource 3") + .tenant(testTenant2) + .build(); + testResource3.setId(3L); + } + + @Nested + @DisplayName("canAccessResource 테스트 - SHARED 모드") + class CanAccessResourceSharedModeTest { + + @Test + @DisplayName("SHARED 모드에서 동일 테넌트 리소스 접근 → 접근 허용") + void canAccessResource_SHARED모드_동일테넌트리소스접근_접근허용() { + // Given + when(tenantIsolationService.getIsolationLevel(testTenant1)) + .thenReturn(TenantIsolation.IsolationLevel.SHARED); + + // When + boolean result = resourceAccessControlService.canAccessResource(testUser1, testResource1); + + // Then + assertThat(result).isTrue(); + verify(tenantIsolationService).getIsolationLevel(testTenant1); + verify(resourceOwnerService, never()).isResourceOwner(any(), any()); + } + + @Test + @DisplayName("SHARED 모드에서 다른 사용자의 리소스 접근 → 접근 허용") + void canAccessResource_SHARED모드_다른사용자리소스접근_접근허용() { + // Given + when(tenantIsolationService.getIsolationLevel(testTenant1)) + .thenReturn(TenantIsolation.IsolationLevel.SHARED); + + // When + boolean result = resourceAccessControlService.canAccessResource(testUser2, testResource1); + + // Then + assertThat(result).isTrue(); + verify(tenantIsolationService).getIsolationLevel(testTenant1); + verify(resourceOwnerService, never()).isResourceOwner(any(), any()); + } + + @Test + @DisplayName("SHARED 모드에서 다른 테넌트 리소스 접근 → 접근 거부") + void canAccessResource_SHARED모드_다른테넌트리소스접근_접근거부() { + // Given + // 다른 테넌트의 리소스이므로 격리 수준 조회 전에 거부됨 + + // When + boolean result = resourceAccessControlService.canAccessResource(testUser1, testResource3); + + // Then + assertThat(result).isFalse(); + verify(tenantIsolationService, never()).getIsolationLevel(any()); + verify(resourceOwnerService, never()).isResourceOwner(any(), any()); + } + } + + @Nested + @DisplayName("canAccessResource 테스트 - DEDICATED 모드") + class CanAccessResourceDedicatedModeTest { + + @Test + @DisplayName("DEDICATED 모드에서 소유한 리소스 접근 → 접근 허용") + void canAccessResource_DEDICATED모드_소유한리소스접근_접근허용() { + // Given + when(tenantIsolationService.getIsolationLevel(testTenant1)) + .thenReturn(TenantIsolation.IsolationLevel.DEDICATED); + when(resourceOwnerService.isResourceOwner(testUser1, testResource1)) + .thenReturn(true); + + // When + boolean result = resourceAccessControlService.canAccessResource(testUser1, testResource1); + + // Then + assertThat(result).isTrue(); + verify(tenantIsolationService).getIsolationLevel(testTenant1); + verify(resourceOwnerService).isResourceOwner(testUser1, testResource1); + } + + @Test + @DisplayName("DEDICATED 모드에서 소유하지 않은 리소스 접근 → 접근 거부") + void canAccessResource_DEDICATED모드_소유하지않은리소스접근_접근거부() { + // Given + when(tenantIsolationService.getIsolationLevel(testTenant1)) + .thenReturn(TenantIsolation.IsolationLevel.DEDICATED); + when(resourceOwnerService.isResourceOwner(testUser1, testResource2)) + .thenReturn(false); + + // When + boolean result = resourceAccessControlService.canAccessResource(testUser1, testResource2); + + // Then + assertThat(result).isFalse(); + verify(tenantIsolationService).getIsolationLevel(testTenant1); + verify(resourceOwnerService).isResourceOwner(testUser1, testResource2); + } + + @Test + @DisplayName("DEDICATED 모드에서 다른 테넌트 리소스 접근 → 접근 거부") + void canAccessResource_DEDICATED모드_다른테넌트리소스접근_접근거부() { + // Given + // 다른 테넌트의 리소스이므로 격리 수준 조회 전에 거부됨 + + // When + boolean result = resourceAccessControlService.canAccessResource(testUser1, testResource3); + + // Then + assertThat(result).isFalse(); + verify(tenantIsolationService, never()).getIsolationLevel(any()); + verify(resourceOwnerService, never()).isResourceOwner(any(), any()); + } + } + + @Nested + @DisplayName("canAccessResource 테스트 - 격리 수준 없음") + class CanAccessResourceNoIsolationLevelTest { + + @Test + @DisplayName("격리 수준이 설정되지 않은 경우 → 접근 거부") + void canAccessResource_격리수준설정안됨_접근거부() { + // Given + when(tenantIsolationService.getIsolationLevel(testTenant1)) + .thenReturn(null); + + // When + boolean result = resourceAccessControlService.canAccessResource(testUser1, testResource1); + + // Then + assertThat(result).isFalse(); + verify(tenantIsolationService).getIsolationLevel(testTenant1); + } + } + + @Nested + @DisplayName("filterAccessibleResources 테스트 - SHARED 모드") + class FilterAccessibleResourcesSharedModeTest { + + @Test + @DisplayName("SHARED 모드에서 동일 테넌트 리소스 필터링 → 모든 리소스 반환") + void filterAccessibleResources_SHARED모드_동일테넌트리소스필터링_모든리소스반환() { + // Given + List resources = Arrays.asList(testResource1, testResource2); + when(tenantIsolationService.getIsolationLevel(testTenant1)) + .thenReturn(TenantIsolation.IsolationLevel.SHARED); + + // When + List result = resourceAccessControlService.filterAccessibleResources(testUser1, resources); + + // Then + assertThat(result).hasSize(2); + assertThat(result).containsExactly(testResource1, testResource2); + verify(tenantIsolationService).getIsolationLevel(testTenant1); + verify(resourceOwnerService, never()).getOwnedResourceIdsBatch(any(), any(), any()); + } + + @Test + @DisplayName("SHARED 모드에서 다른 테넌트 리소스 포함 → 동일 테넌트 리소스만 반환") + void filterAccessibleResources_SHARED모드_다른테넌트리소스포함_동일테넌트리소스만반환() { + // Given + List resources = Arrays.asList(testResource1, testResource2, testResource3); + when(tenantIsolationService.getIsolationLevel(testTenant1)) + .thenReturn(TenantIsolation.IsolationLevel.SHARED); + + // When + List result = resourceAccessControlService.filterAccessibleResources(testUser1, resources); + + // Then + assertThat(result).hasSize(2); + assertThat(result).containsExactly(testResource1, testResource2); + assertThat(result).doesNotContain(testResource3); + } + + @Test + @DisplayName("SHARED 모드에서 빈 리소스 목록 → 빈 목록 반환") + void filterAccessibleResources_SHARED모드_빈리소스목록_빈목록반환() { + // Given + List resources = List.of(); + + // When + List result = resourceAccessControlService.filterAccessibleResources(testUser1, resources); + + // Then + assertThat(result).isEmpty(); + verify(tenantIsolationService, never()).getIsolationLevel(any()); + } + } + + @Nested + @DisplayName("filterAccessibleResources 테스트 - DEDICATED 모드") + class FilterAccessibleResourcesDedicatedModeTest { + + @Test + @DisplayName("DEDICATED 모드에서 소유한 리소스만 필터링 → 소유한 리소스만 반환") + void filterAccessibleResources_DEDICATED모드_소유한리소스만필터링_소유한리소스만반환() { + // Given + List resources = Arrays.asList(testResource1, testResource2); + List resourceIds = Arrays.asList(1L, 2L); + List ownedResourceIds = Arrays.asList(1L); // testResource1만 소유 + + when(tenantIsolationService.getIsolationLevel(testTenant1)) + .thenReturn(TenantIsolation.IsolationLevel.DEDICATED); + when(resourceOwnerService.getOwnedResourceIdsBatch(testUser1, resourceIds, testTenant1)) + .thenReturn(ownedResourceIds); + + // When + List result = resourceAccessControlService.filterAccessibleResources(testUser1, resources); + + // Then + assertThat(result).hasSize(1); + assertThat(result).containsExactly(testResource1); + verify(tenantIsolationService).getIsolationLevel(testTenant1); + verify(resourceOwnerService).getOwnedResourceIdsBatch(testUser1, resourceIds, testTenant1); + } + + @Test + @DisplayName("DEDICATED 모드에서 소유한 리소스가 없는 경우 → 빈 목록 반환") + void filterAccessibleResources_DEDICATED모드_소유한리소스없음_빈목록반환() { + // Given + List resources = Arrays.asList(testResource1, testResource2); + List resourceIds = Arrays.asList(1L, 2L); + + when(tenantIsolationService.getIsolationLevel(testTenant1)) + .thenReturn(TenantIsolation.IsolationLevel.DEDICATED); + when(resourceOwnerService.getOwnedResourceIdsBatch(testUser1, resourceIds, testTenant1)) + .thenReturn(List.of()); + + // When + List result = resourceAccessControlService.filterAccessibleResources(testUser1, resources); + + // Then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("DEDICATED 모드에서 다른 테넌트 리소스 포함 → 동일 테넌트 소유 리소스만 반환") + void filterAccessibleResources_DEDICATED모드_다른테넌트리소스포함_동일테넌트소유리소스만반환() { + // Given + List resources = Arrays.asList(testResource1, testResource2, testResource3); + List resourceIds = Arrays.asList(1L, 2L); // testResource3는 다른 테넌트이므로 제외 + List ownedResourceIds = Arrays.asList(1L); + + when(tenantIsolationService.getIsolationLevel(testTenant1)) + .thenReturn(TenantIsolation.IsolationLevel.DEDICATED); + when(resourceOwnerService.getOwnedResourceIdsBatch(testUser1, resourceIds, testTenant1)) + .thenReturn(ownedResourceIds); + + // When + List result = resourceAccessControlService.filterAccessibleResources(testUser1, resources); + + // Then + assertThat(result).hasSize(1); + assertThat(result).containsExactly(testResource1); + assertThat(result).doesNotContain(testResource2, testResource3); + } + } + + @Nested + @DisplayName("filterAccessibleResources 테스트 - 격리 수준 없음") + class FilterAccessibleResourcesNoIsolationLevelTest { + + @Test + @DisplayName("격리 수준이 설정되지 않은 경우 → 빈 목록 반환") + void filterAccessibleResources_격리수준설정안됨_빈목록반환() { + // Given + List resources = Arrays.asList(testResource1, testResource2); + when(tenantIsolationService.getIsolationLevel(testTenant1)) + .thenReturn(null); + + // When + List result = resourceAccessControlService.filterAccessibleResources(testUser1, resources); + + // Then + assertThat(result).isEmpty(); + } + } +} + diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/ResourceOwnerServiceTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/ResourceOwnerServiceTest.java new file mode 100644 index 000000000..886c9ac27 --- /dev/null +++ b/src/test/java/com/agenticcp/core/domain/cloud/service/ResourceOwnerServiceTest.java @@ -0,0 +1,329 @@ +package com.agenticcp.core.domain.cloud.service; + +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.entity.ResourceOwner; +import com.agenticcp.core.domain.cloud.repository.ResourceOwnerRepository; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * ResourceOwnerService 단위 테스트 + * + *

리소스 소유권 관리 서비스의 핵심 기능을 검증합니다.

+ * + * @author AgenticCP Team + * @version 1.0.0 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("ResourceOwnerService 단위 테스트") +class ResourceOwnerServiceTest { + + @Mock + private ResourceOwnerRepository resourceOwnerRepository; + + @InjectMocks + private ResourceOwnerService resourceOwnerService; + + private Tenant testTenant; + private User testUser; + private CloudResource testResource; + private ResourceOwner testResourceOwner; + + @BeforeEach + void setUp() { + testTenant = Tenant.builder() + .tenantKey("test-tenant-001") + .tenantName("Test Tenant") + .build(); + testTenant.setId(1L); + + testUser = User.builder() + .username("testuser") + .email("test@example.com") + .name("Test User") + .tenant(testTenant) + .build(); + testUser.setId(1L); + + testResource = CloudResource.builder() + .resourceId("resource-001") + .resourceName("Test Resource") + .tenant(testTenant) + .build(); + testResource.setId(1L); + + testResourceOwner = ResourceOwner.builder() + .resource(testResource) + .user(testUser) + .tenant(testTenant) + .accessType(ResourceOwner.AccessType.OWNER) + .build(); + testResourceOwner.setId(1L); + } + + @Nested + @DisplayName("createResourceOwnership 테스트") + class CreateResourceOwnershipTest { + + @Test + @DisplayName("새로운 소유권 생성 → ResourceOwner 저장") + void createResourceOwnership_새로운소유권생성_ResourceOwner저장() { + // Given + when(resourceOwnerRepository.existsByResourceAndUserAndIsDeletedFalse(testResource, testUser)) + .thenReturn(false); + when(resourceOwnerRepository.save(any(ResourceOwner.class))) + .thenReturn(testResourceOwner); + + // When + resourceOwnerService.createResourceOwnership(testResource, testUser); + + // Then + ArgumentCaptor captor = ArgumentCaptor.forClass(ResourceOwner.class); + verify(resourceOwnerRepository).save(captor.capture()); + + ResourceOwner saved = captor.getValue(); + assertThat(saved.getResource()).isEqualTo(testResource); + assertThat(saved.getUser()).isEqualTo(testUser); + assertThat(saved.getTenant()).isEqualTo(testTenant); + assertThat(saved.getAccessType()).isEqualTo(ResourceOwner.AccessType.OWNER); + } + + @Test + @DisplayName("이미 소유권이 존재하는 경우 → 저장하지 않고 반환") + void createResourceOwnership_이미소유권존재_저장하지않고반환() { + // Given + when(resourceOwnerRepository.existsByResourceAndUserAndIsDeletedFalse(testResource, testUser)) + .thenReturn(true); + + // When + resourceOwnerService.createResourceOwnership(testResource, testUser); + + // Then + verify(resourceOwnerRepository, never()).save(any(ResourceOwner.class)); + } + } + + @Nested + @DisplayName("getOwnedResources 테스트") + class GetOwnedResourcesTest { + + @Test + @DisplayName("사용자가 소유한 리소스 조회 → 소유한 리소스 목록 반환") + void getOwnedResources_사용자가소유한리소스조회_소유한리소스목록반환() { + // Given + CloudResource resource1 = CloudResource.builder() + .resourceId("resource-001") + .resourceName("Resource 1") + .tenant(testTenant) + .build(); + resource1.setId(1L); + + CloudResource resource2 = CloudResource.builder() + .resourceId("resource-002") + .resourceName("Resource 2") + .tenant(testTenant) + .build(); + resource2.setId(2L); + + List ownedResources = Arrays.asList(resource1, resource2); + when(resourceOwnerRepository.findResourcesByOwner(testUser, testTenant)) + .thenReturn(ownedResources); + + // When + List result = resourceOwnerService.getOwnedResources(testUser, testTenant); + + // Then + assertThat(result).hasSize(2); + assertThat(result).containsExactly(resource1, resource2); + verify(resourceOwnerRepository).findResourcesByOwner(testUser, testTenant); + } + + @Test + @DisplayName("소유한 리소스가 없는 경우 → 빈 목록 반환") + void getOwnedResources_소유한리소스없음_빈목록반환() { + // Given + when(resourceOwnerRepository.findResourcesByOwner(testUser, testTenant)) + .thenReturn(List.of()); + + // When + List result = resourceOwnerService.getOwnedResources(testUser, testTenant); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("isResourceOwner 테스트") + class IsResourceOwnerTest { + + @Test + @DisplayName("사용자가 리소스 소유자인 경우 → true 반환") + void isResourceOwner_사용자가리소스소유자_true반환() { + // Given + when(resourceOwnerRepository.existsByResourceAndUserAndIsDeletedFalse(testResource, testUser)) + .thenReturn(true); + + // When + boolean result = resourceOwnerService.isResourceOwner(testUser, testResource); + + // Then + assertThat(result).isTrue(); + verify(resourceOwnerRepository).existsByResourceAndUserAndIsDeletedFalse(testResource, testUser); + } + + @Test + @DisplayName("사용자가 리소스 소유자가 아닌 경우 → false 반환") + void isResourceOwner_사용자가리소스소유자아님_false반환() { + // Given + when(resourceOwnerRepository.existsByResourceAndUserAndIsDeletedFalse(testResource, testUser)) + .thenReturn(false); + + // When + boolean result = resourceOwnerService.isResourceOwner(testUser, testResource); + + // Then + assertThat(result).isFalse(); + } + } + + @Nested + @DisplayName("getResourceOwnership 테스트") + class GetResourceOwnershipTest { + + @Test + @DisplayName("소유권 정보 조회 → ResourceOwner 반환") + void getResourceOwnership_소유권정보조회_ResourceOwner반환() { + // Given + when(resourceOwnerRepository.findByResourceAndUserAndIsDeletedFalse(testResource, testUser)) + .thenReturn(Optional.of(testResourceOwner)); + + // When + Optional result = resourceOwnerService.getResourceOwnership(testResource, testUser); + + // Then + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(testResourceOwner); + } + + @Test + @DisplayName("소유권 정보가 없는 경우 → Optional.empty() 반환") + void getResourceOwnership_소유권정보없음_OptionalEmpty반환() { + // Given + when(resourceOwnerRepository.findByResourceAndUserAndIsDeletedFalse(testResource, testUser)) + .thenReturn(Optional.empty()); + + // When + Optional result = resourceOwnerService.getResourceOwnership(testResource, testUser); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("deleteResourceOwnership 테스트") + class DeleteResourceOwnershipTest { + + @Test + @DisplayName("소유권 삭제 → Soft Delete 처리") + void deleteResourceOwnership_소유권삭제_SoftDelete처리() { + // Given + when(resourceOwnerRepository.findByResourceAndUserAndIsDeletedFalse(testResource, testUser)) + .thenReturn(Optional.of(testResourceOwner)); + when(resourceOwnerRepository.save(testResourceOwner)) + .thenReturn(testResourceOwner); + + // When + resourceOwnerService.deleteResourceOwnership(testResource, testUser); + + // Then + assertThat(testResourceOwner.getIsDeleted()).isTrue(); + verify(resourceOwnerRepository).findByResourceAndUserAndIsDeletedFalse(testResource, testUser); + verify(resourceOwnerRepository).save(testResourceOwner); + } + + @Test + @DisplayName("소유권이 없는 경우 → 삭제하지 않음") + void deleteResourceOwnership_소유권없음_삭제하지않음() { + // Given + when(resourceOwnerRepository.findByResourceAndUserAndIsDeletedFalse(testResource, testUser)) + .thenReturn(Optional.empty()); + + // When + resourceOwnerService.deleteResourceOwnership(testResource, testUser); + + // Then + verify(resourceOwnerRepository, never()).save(any(ResourceOwner.class)); + } + } + + @Nested + @DisplayName("getOwnedResourceIdsBatch 테스트") + class GetOwnedResourceIdsBatchTest { + + @Test + @DisplayName("배치로 소유한 리소스 ID 조회 → 소유한 리소스 ID 목록 반환") + void getOwnedResourceIdsBatch_배치로소유한리소스ID조회_소유한리소스ID목록반환() { + // Given + List resourceIds = Arrays.asList(1L, 2L, 3L, 4L); + List ownedResourceIds = Arrays.asList(1L, 3L); // 1L, 3L만 소유 + + when(resourceOwnerRepository.findOwnedResourceIds(testUser, resourceIds, testTenant)) + .thenReturn(ownedResourceIds); + + // When + List result = resourceOwnerService.getOwnedResourceIdsBatch(testUser, resourceIds, testTenant); + + // Then + assertThat(result).hasSize(2); + assertThat(result).containsExactly(1L, 3L); + verify(resourceOwnerRepository).findOwnedResourceIds(testUser, resourceIds, testTenant); + } + + @Test + @DisplayName("빈 리소스 ID 목록 → 빈 목록 반환") + void getOwnedResourceIdsBatch_빈리소스ID목록_빈목록반환() { + // When + List result = resourceOwnerService.getOwnedResourceIdsBatch(testUser, List.of(), testTenant); + + // Then + assertThat(result).isEmpty(); + verify(resourceOwnerRepository, never()).findOwnedResourceIds(any(), any(), any()); + } + + @Test + @DisplayName("소유한 리소스가 없는 경우 → 빈 목록 반환") + void getOwnedResourceIdsBatch_소유한리소스없음_빈목록반환() { + // Given + List resourceIds = Arrays.asList(1L, 2L, 3L); + when(resourceOwnerRepository.findOwnedResourceIds(testUser, resourceIds, testTenant)) + .thenReturn(List.of()); + + // When + List result = resourceOwnerService.getOwnedResourceIdsBatch(testUser, resourceIds, testTenant); + + // Then + assertThat(result).isEmpty(); + } + } +} + diff --git a/src/test/java/com/agenticcp/core/domain/tenant/service/TenantIsolationServiceTest.java b/src/test/java/com/agenticcp/core/domain/tenant/service/TenantIsolationServiceTest.java new file mode 100644 index 000000000..35b7f6d97 --- /dev/null +++ b/src/test/java/com/agenticcp/core/domain/tenant/service/TenantIsolationServiceTest.java @@ -0,0 +1,228 @@ +package com.agenticcp.core.domain.tenant.service; + +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.tenant.entity.TenantIsolation; +import com.agenticcp.core.domain.tenant.repository.TenantIsolationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * TenantIsolationService 단위 테스트 + * + *

테넌트 격리 수준 관리 서비스의 핵심 기능을 검증합니다.

+ * + * @author AgenticCP Team + * @version 1.0.0 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("TenantIsolationService 단위 테스트") +class TenantIsolationServiceTest { + + @Mock + private TenantIsolationRepository tenantIsolationRepository; + + @Mock + private TenantService tenantService; + + @InjectMocks + private TenantIsolationService tenantIsolationService; + + private Tenant testTenant; + private TenantIsolation testIsolation; + + @BeforeEach + void setUp() { + testTenant = Tenant.builder() + .tenantKey("test-tenant-001") + .tenantName("Test Tenant") + .build(); + testTenant.setId(1L); + + testIsolation = TenantIsolation.builder() + .tenant(testTenant) + .isolationLevel(TenantIsolation.IsolationLevel.SHARED) + .build(); + testIsolation.setId(1L); + } + + @Nested + @DisplayName("getIsolationLevel 테스트") + class GetIsolationLevelTest { + + @Test + @DisplayName("격리 수준이 설정된 경우 → 해당 격리 수준 반환") + void getIsolationLevel_격리수준설정됨_해당격리수준반환() { + // Given + when(tenantIsolationRepository.findByTenantAndIsDeletedFalse(testTenant)) + .thenReturn(Optional.of(testIsolation)); + + // When + TenantIsolation.IsolationLevel level = tenantIsolationService.getIsolationLevel(testTenant); + + // Then + assertThat(level).isEqualTo(TenantIsolation.IsolationLevel.SHARED); + verify(tenantIsolationRepository).findByTenantAndIsDeletedFalse(testTenant); + } + + @Test + @DisplayName("격리 수준이 설정되지 않은 경우 → null 반환") + void getIsolationLevel_격리수준설정안됨_null반환() { + // Given + when(tenantIsolationRepository.findByTenantAndIsDeletedFalse(testTenant)) + .thenReturn(Optional.empty()); + + // When + TenantIsolation.IsolationLevel level = tenantIsolationService.getIsolationLevel(testTenant); + + // Then + assertThat(level).isNull(); + verify(tenantIsolationRepository).findByTenantAndIsDeletedFalse(testTenant); + } + + @Test + @DisplayName("DEDICATED 격리 수준 조회 → DEDICATED 반환") + void getIsolationLevel_DEDICATED격리수준_DEDICATED반환() { + // Given + testIsolation.setIsolationLevel(TenantIsolation.IsolationLevel.DEDICATED); + when(tenantIsolationRepository.findByTenantAndIsDeletedFalse(testTenant)) + .thenReturn(Optional.of(testIsolation)); + + // When + TenantIsolation.IsolationLevel level = tenantIsolationService.getIsolationLevel(testTenant); + + // Then + assertThat(level).isEqualTo(TenantIsolation.IsolationLevel.DEDICATED); + } + } + + @Nested + @DisplayName("getTenantIsolation 테스트") + class GetTenantIsolationTest { + + @Test + @DisplayName("격리 정보가 존재하는 경우 → Optional에 격리 정보 포함") + void getTenantIsolation_격리정보존재_Optional에격리정보포함() { + // Given + when(tenantIsolationRepository.findByTenantAndIsDeletedFalse(testTenant)) + .thenReturn(Optional.of(testIsolation)); + + // When + Optional result = tenantIsolationService.getTenantIsolation(testTenant); + + // Then + assertThat(result).isPresent(); + assertThat(result.get().getIsolationLevel()).isEqualTo(TenantIsolation.IsolationLevel.SHARED); + verify(tenantIsolationRepository).findByTenantAndIsDeletedFalse(testTenant); + } + + @Test + @DisplayName("격리 정보가 없는 경우 → Optional.empty() 반환") + void getTenantIsolation_격리정보없음_OptionalEmpty반환() { + // Given + when(tenantIsolationRepository.findByTenantAndIsDeletedFalse(testTenant)) + .thenReturn(Optional.empty()); + + // When + Optional result = tenantIsolationService.getTenantIsolation(testTenant); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("setIsolationLevel 테스트") + class SetIsolationLevelTest { + + @Test + @DisplayName("새로운 격리 수준 설정 → 격리 정보 생성") + void setIsolationLevel_새로운격리수준설정_격리정보생성() { + // Given + when(tenantIsolationRepository.findByTenantAndIsDeletedFalse(testTenant)) + .thenReturn(Optional.empty()); + when(tenantIsolationRepository.save(any(TenantIsolation.class))) + .thenReturn(testIsolation); + + // When + TenantIsolation result = tenantIsolationService.setIsolationLevel( + testTenant, TenantIsolation.IsolationLevel.SHARED); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getIsolationLevel()).isEqualTo(TenantIsolation.IsolationLevel.SHARED); + verify(tenantIsolationRepository).findByTenantAndIsDeletedFalse(testTenant); + verify(tenantIsolationRepository).save(any(TenantIsolation.class)); + } + + @Test + @DisplayName("기존 격리 수준 업데이트 → 격리 수준 변경") + void setIsolationLevel_기존격리수준업데이트_격리수준변경() { + // Given + testIsolation.setIsolationLevel(TenantIsolation.IsolationLevel.SHARED); + when(tenantIsolationRepository.findByTenantAndIsDeletedFalse(testTenant)) + .thenReturn(Optional.of(testIsolation)); + when(tenantIsolationRepository.save(testIsolation)) + .thenReturn(testIsolation); + + // When + TenantIsolation result = tenantIsolationService.setIsolationLevel( + testTenant, TenantIsolation.IsolationLevel.DEDICATED); + + // Then + assertThat(result.getIsolationLevel()).isEqualTo(TenantIsolation.IsolationLevel.DEDICATED); + verify(tenantIsolationRepository).findByTenantAndIsDeletedFalse(testTenant); + verify(tenantIsolationRepository).save(testIsolation); + } + + @Test + @DisplayName("SHARED에서 DEDICATED로 변경 → 격리 수준 변경 성공") + void setIsolationLevel_SHARED에서DEDICATED로변경_격리수준변경성공() { + // Given + testIsolation.setIsolationLevel(TenantIsolation.IsolationLevel.SHARED); + when(tenantIsolationRepository.findByTenantAndIsDeletedFalse(testTenant)) + .thenReturn(Optional.of(testIsolation)); + when(tenantIsolationRepository.save(testIsolation)) + .thenReturn(testIsolation); + + // When + TenantIsolation result = tenantIsolationService.setIsolationLevel( + testTenant, TenantIsolation.IsolationLevel.DEDICATED); + + // Then + assertThat(result.getIsolationLevel()).isEqualTo(TenantIsolation.IsolationLevel.DEDICATED); + } + } + + @Nested + @DisplayName("saveTenantIsolation 테스트") + class SaveTenantIsolationTest { + + @Test + @DisplayName("격리 정보 저장 → 저장된 격리 정보 반환") + void saveTenantIsolation_격리정보저장_저장된격리정보반환() { + // Given + when(tenantIsolationRepository.save(testIsolation)) + .thenReturn(testIsolation); + + // When + TenantIsolation result = tenantIsolationService.saveTenantIsolation(testIsolation); + + // Then + assertThat(result).isEqualTo(testIsolation); + verify(tenantIsolationRepository).save(testIsolation); + } + } +} + From 104e621ece8a1a7588a24f583ac7f3be70af0eba Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Mon, 24 Nov 2025 19:35:56 +0900 Subject: [PATCH 29/32] =?UTF-8?q?fix:=20AwsTenantIsolationAdapter=20?= =?UTF-8?q?=EB=B0=8F=20AwsCloudResourceCreator=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/AwsTenantIsolationAdapter.java | 6 +++--- .../resource/AwsCloudResourceCreator.java | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/agenticcp/core/domain/tenant/adapter/AwsTenantIsolationAdapter.java b/src/main/java/com/agenticcp/core/domain/tenant/adapter/AwsTenantIsolationAdapter.java index 4d0962736..6f0eba104 100644 --- a/src/main/java/com/agenticcp/core/domain/tenant/adapter/AwsTenantIsolationAdapter.java +++ b/src/main/java/com/agenticcp/core/domain/tenant/adapter/AwsTenantIsolationAdapter.java @@ -3,9 +3,9 @@ import com.agenticcp.core.domain.tenant.adapter.dto.IsolationResult; import com.agenticcp.core.domain.tenant.adapter.dto.IsolationStatus; import com.agenticcp.core.domain.tenant.cloud.CloudProviderType; -import com.agenticcp.core.domain.tenant.cloud.service.AwsCloudResourceCreator; -import com.agenticcp.core.domain.tenant.cloud.service.IsolationStrategy; -import com.agenticcp.core.domain.tenant.cloud.service.IsolationStrategyFactory; +import com.agenticcp.core.domain.tenant.cloud.service.resource.AwsCloudResourceCreator; +import com.agenticcp.core.domain.tenant.cloud.service.isolation.IsolationStrategy; +import com.agenticcp.core.domain.tenant.cloud.service.isolation.IsolationStrategyFactory; import com.agenticcp.core.domain.tenant.entity.TenantIsolation; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/resource/AwsCloudResourceCreator.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/resource/AwsCloudResourceCreator.java index ea27ab85f..236710809 100644 --- a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/resource/AwsCloudResourceCreator.java +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/resource/AwsCloudResourceCreator.java @@ -12,6 +12,11 @@ @Component public class AwsCloudResourceCreator implements CloudResourceCreator { + @Override + public com.agenticcp.core.domain.tenant.cloud.CloudProviderType getCloudProviderType() { + return com.agenticcp.core.domain.tenant.cloud.CloudProviderType.AWS; + } + @Override public CloudResourceResult createVpc(String tenantKey, ResourceCreateRequest request, TenantIsolation.IsolationLevel level) { // Strategy 의 추상적 요청을 AWS 형식으로 변환 @@ -21,6 +26,18 @@ public CloudResourceResult createVpc(String tenantKey, ResourceCreateRequest req return null; } + @Override + public CloudResourceResult createSubnets(String tenantKey, ResourceCreateRequest request, TenantIsolation.IsolationLevel level) { + // TODO: 실제 AWS API 호출 구현 + return null; + } + + @Override + public CloudResourceResult createSecurityGroups(String tenantKey, ResourceCreateRequest request, TenantIsolation.IsolationLevel level) { + // TODO: 실제 AWS API 호출 구현 + return null; + } + private Map getAwsMetadata(ResourceCreateRequest request) { return new HashMap<>(request.metadata()); } From 7be9b4dc29eb327f9c378f2b3b60235695bf3452 Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Mon, 24 Nov 2025 19:38:09 +0900 Subject: [PATCH 30/32] =?UTF-8?q?docs:=20=ED=85=8C=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=A9=EB=A6=AC=20=EC=88=98=EC=A4=80=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TENANT_ISOLATION_POLICY_IMPLEMENTATION.md | 1243 +++++++++++++++++ 1 file changed, 1243 insertions(+) create mode 100644 docs/TENANT_ISOLATION_POLICY_IMPLEMENTATION.md diff --git a/docs/TENANT_ISOLATION_POLICY_IMPLEMENTATION.md b/docs/TENANT_ISOLATION_POLICY_IMPLEMENTATION.md new file mode 100644 index 000000000..3c11a70d8 --- /dev/null +++ b/docs/TENANT_ISOLATION_POLICY_IMPLEMENTATION.md @@ -0,0 +1,1243 @@ +# 테넌트 격리 수준 정책 구현 문서 + +## 목차 +1. [개요](#개요) +2. [구현 요구사항](#구현-요구사항) +3. [엔티티 관계 구조](#엔티티-관계-구조) +4. [데이터 모델](#데이터-모델) +5. [서비스 레이어](#서비스-레이어) +6. [접근 제어 로직](#접근-제어-로직) +7. [시퀀스 다이어그램](#시퀀스-다이어그램) +8. [추가 고려사항](#추가-고려사항) +9. [개선사항](#개선사항) + +--- + +## 개요 + +### 목적 +멀티 테넌트 환경에서 조직(테넌트) 단위로 리소스 접근 권한을 제어하기 위한 격리 수준 정책을 구현합니다. + +### 핵심 개념 +- **조직 = 테넌트**: 조직과 테넌트는 동일한 개념으로 사용 +- **Worker**: 조직에 속한 사용자 (User) +- **리소스**: 클라우드 리소스 (CloudResource) - AWS, GCP 등 여러 클라우드 계정에서 생성 +- **격리 수준**: SHARED, DEDICATED 두 가지 모드 + +### 격리 수준 정의 + +#### SHARED (공유 모드) +- 조직 내 모든 Worker가 모든 리소스에 접근 가능 +- 리소스 공유 및 협업에 적합 +- ResourceOwner 테이블 조회 불필요 + +#### DEDICATED (전용 모드) +- Worker는 본인이 생성한 리소스만 접근/관리 가능 +- 리소스 소유권 기반 접근 제어 +- ResourceOwner 테이블에서 소유권 확인 필수 + +--- + +## 구현 요구사항 + +### 기능 요구사항 + +#### FR-1: 리소스 생성 시 소유권 관리 +- 리소스 생성 시 자동으로 ResourceOwner 레코드 생성 +- 생성자(User)를 소유자로 등록 +- 트랜잭션 일관성 보장 (리소스 생성 실패 시 소유권도 롤백) + +#### FR-2: 격리 수준별 리소스 조회 +- **SHARED 모드**: 테넌트의 모든 리소스 조회 +- **DEDICATED 모드**: 사용자가 소유한 리소스만 조회 +- 격리 수준 변경 시 기존 리소스 접근 영향 최소화 + +#### FR-3: 리소스 접근 권한 검증 +- 리소스 조회/수정/삭제 시 접근 권한 검증 +- 테넌트 일치 확인 필수 +- 격리 수준에 따른 접근 제어 적용 + +#### FR-4: 격리 수준 관리 +- 테넌트별 격리 수준 조회 +- 격리 수준 설정/변경 기능 +- 격리 수준 미설정 시 안전한 기본값 (접근 거부) + +### 비기능 요구사항 + +#### NFR-1: 성능 +- 격리 수준 조회는 캐싱 권장 (변경 빈도 낮음) +- ResourceOwner 조회 시 인덱스 활용 필수 +- 목록 조회 시 JOIN 쿼리 최적화 + +#### NFR-2: 보안 +- 접근 거부 시 상세 로그 기록 (보안 감사) +- 테넌트 간 데이터 접근 완전 차단 +- 소유권 확인 실패 시 명확한 에러 메시지 + +#### NFR-3: 확장성 +- 향후 공동 소유, 권한 레벨 추가 용이 +- 리소스 소유권 이전 기능 확장 가능 +- 격리 수준 추가 확장 가능 + +--- + +## 엔티티 관계 구조 + +### 전체 관계도 + +```mermaid +erDiagram + Organization ||--|| Tenant : "1:1" + Tenant ||--|| TenantIsolation : "1:1" + Tenant ||--o{ User : "1:N" + Tenant ||--o{ CloudResource : "1:N" + CloudResource ||--o{ ResourceOwner : "1:N" + User ||--o{ ResourceOwner : "1:N" + Tenant ||--o{ ResourceOwner : "1:N" + + Organization { + bigint id PK + string org_key UK + string org_name + } + + Tenant { + bigint id PK + string tenant_key UK + string tenant_name + bigint organization_id FK,UK + } + + TenantIsolation { + bigint id PK + bigint tenant_id FK,UK + enum isolation_level + } + + User { + bigint id PK + string username UK + bigint tenant_id FK + } + + CloudResource { + bigint id PK + string resource_id UK + string resource_name + bigint tenant_id FK + } + + ResourceOwner { + bigint id PK + bigint resource_id FK + bigint user_id FK + bigint tenant_id FK + enum access_type + } +``` + +### 관계 상세 + +#### 1. Organization → Tenant (1:1) +- 하나의 조직은 하나의 테넌트만 가질 수 있음 +- 테넌트는 반드시 하나의 조직에 속함 +- **조직 = 테넌트**: 조직과 테넌트는 동일한 개념 + +#### 2. Tenant → TenantIsolation (1:1) +- 테넌트당 하나의 격리 정책 +- 격리 수준: SHARED, DEDICATED + +#### 3. Tenant → User (1:N) +- 하나의 테넌트는 여러 사용자를 가질 수 있음 +- 사용자는 반드시 하나의 테넌트에 속함 + +#### 4. Tenant → CloudResource (1:N) +- 하나의 테넌트는 여러 리소스를 가질 수 있음 +- 리소스는 반드시 하나의 테넌트에 속함 + +#### 5. CloudResource ↔ User (M:N via ResourceOwner) +- 중간 테이블(ResourceOwner)을 통한 다대다 관계 +- DEDICATED 모드에서 소유권 관리에 사용 + +--- + +## 데이터 모델 + +### 1. TenantIsolation (테넌트 격리) + +```java +@Entity +@Table(name = "tenant_isolation") +public class TenantIsolation extends BaseEntity { + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tenant_id", nullable = false) + private Tenant tenant; + + @Enumerated(EnumType.STRING) + @Column(name = "isolation_level") + private IsolationLevel isolationLevel; // SHARED, DEDICATED + + // 기타 격리 관련 필드들... +} +``` + +**주요 필드:** +- `tenant`: 테넌트 (1:1 관계) +- `isolationLevel`: 격리 수준 (SHARED, DEDICATED) + +**인덱스:** +- `idx_tenant_isolation_tenant`: tenant_id (Unique) + +### 2. ResourceOwner (리소스 소유자) + +```java +@Entity +@Table(name = "resource_owners", + uniqueConstraints = @UniqueConstraint( + name = "uk_resource_user_deleted", + columnNames = {"resource_id", "user_id", "is_deleted"} + )) +public class ResourceOwner extends BaseEntity { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "resource_id", nullable = false) + private CloudResource resource; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tenant_id", nullable = false) + private Tenant tenant; + + @Enumerated(EnumType.STRING) + @Column(name = "access_type", nullable = false) + private AccessType accessType = AccessType.OWNER; +} +``` + +**주요 필드:** +- `resource`: 리소스 +- `user`: 소유자 (Worker) +- `tenant`: 테넌트 (조직) +- `accessType`: 접근 타입 (OWNER, SHARED, READ_ONLY) + +**제약조건:** +- `(resource_id, user_id, is_deleted)` 조합은 유일 + +**인덱스:** +- `idx_resource_owner_user_tenant`: (user_id, tenant_id, is_deleted) +- `idx_resource_owner_resource_tenant`: (resource_id, tenant_id, is_deleted) + +### 3. CloudResource (클라우드 리소스) + +```java +@Entity +@Table(name = "cloud_resources") +public class CloudResource extends BaseEntity { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tenant_id") + private Tenant tenant; + + // 기타 리소스 필드들... +} +``` + +**주요 필드:** +- `tenant`: 테넌트 (조직) + +### 4. User (사용자) + +```java +@Entity +@Table(name = "users") +public class User extends BaseEntity { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tenant_id") + private Tenant tenant; + + // 기타 사용자 필드들... +} +``` + +**주요 필드:** +- `tenant`: 테넌트 (조직) + +--- + +## 서비스 레이어 + +### 1. TenantIsolationService + +**역할**: 테넌트 격리 수준 관리 + +**주요 메서드:** + +```java +/** + * 테넌트의 격리 수준 조회 + */ +public IsolationLevel getIsolationLevel(Tenant tenant) + +/** + * 테넌트의 격리 수준 설정 + */ +@Transactional +public TenantIsolation setIsolationLevel(Tenant tenant, IsolationLevel level) + +/** + * 테넌트의 격리 정보 조회 + */ +public Optional getTenantIsolation(Tenant tenant) +``` + +**의존성:** +- `TenantIsolationRepository` + +### 2. ResourceOwnerService + +**역할**: 리소스 소유권 관리 + +**주요 메서드:** + +```java +/** + * 리소스 소유권 생성 + */ +@Transactional +public void createResourceOwnership(CloudResource resource, User owner) + +/** + * 사용자가 소유한 리소스 목록 조회 + */ +public List getOwnedResources(User user, Tenant tenant) + +/** + * 리소스 소유권 확인 + */ +public boolean isResourceOwner(User user, CloudResource resource) + +/** + * 리소스 소유권 삭제 (Soft Delete) + */ +@Transactional +public void deleteResourceOwnership(CloudResource resource, User user) +``` + +**의존성:** +- `ResourceOwnerRepository` + +### 3. ResourceAccessControlService + +**역할**: 리소스 접근 권한 검증 + +**주요 메서드:** + +```java +/** + * 리소스 접근 권한 검증 + */ +public boolean canAccessResource(User user, CloudResource resource) + +/** + * 접근 가능한 리소스만 필터링 + */ +public List filterAccessibleResources(User user, List resources) +``` + +**접근 제어 로직:** +1. 테넌트 일치 확인 +2. 격리 수준 조회 +3. SHARED → 접근 허용 +4. DEDICATED → ResourceOwner 테이블에서 소유권 확인 + +**의존성:** +- `TenantIsolationService` +- `ResourceOwnerService` + +### 4. CloudResourceService + +**역할**: 리소스 CRUD 및 접근 제어 통합 + +**주요 메서드:** + +```java +/** + * 리소스 생성 (소유권 자동 등록) + */ +@Transactional +public CloudResource createResource(CloudResource resource) + +/** + * 사용자가 접근 가능한 리소스 목록 조회 + */ +public List getAccessibleResources() + +/** + * 리소스 조회 (접근 권한 검증) + */ +public CloudResource getResource(Long resourceId) + +/** + * 리소스 수정 (접근 권한 검증) + */ +@Transactional +public CloudResource updateResource(Long resourceId, CloudResource resource) + +/** + * 리소스 삭제 (접근 권한 검증) + */ +@Transactional +public void deleteResource(Long resourceId) +``` + +**의존성:** +- `CloudResourceRepository` +- `ResourceOwnerService` +- `ResourceAccessControlService` +- `TenantIsolationService` +- `UserService` + +--- + +## 접근 제어 로직 + +### 전체 흐름 + +```mermaid +flowchart TD + Start([리소스 접근 요청]) --> GetUser[현재 사용자 조회
SecurityContext] + GetUser --> GetTenant[현재 테넌트 조회
TenantContextHolder] + GetTenant --> CheckTenant{같은 테넌트?} + + CheckTenant -->|No| Denied1[접근 거부
403 Forbidden] + CheckTenant -->|Yes| GetLevel[격리 수준 조회] + + GetLevel --> CheckLevel{격리 수준?} + CheckLevel -->|SHARED| Granted1[접근 허용] + CheckLevel -->|DEDICATED| CheckOwner[ResourceOwner 확인] + CheckLevel -->|NULL| Denied2[접근 거부
격리 수준 미설정] + + CheckOwner --> IsOwner{소유자인가?} + IsOwner -->|Yes| Granted2[접근 허용] + IsOwner -->|No| Denied3[접근 거부
소유하지 않은 리소스] + + Granted1 --> Execute[작업 실행] + Granted2 --> Execute + Execute --> End([성공]) + + Denied1 --> End + Denied2 --> End + Denied3 --> End + + style Start fill:#e1f5ff + style End fill:#c8e6c9 + style Granted1 fill:#c8e6c9 + style Granted2 fill:#c8e6c9 + style Denied1 fill:#ffcdd2 + style Denied2 fill:#ffcdd2 + style Denied3 fill:#ffcdd2 +``` + +### 상세 로직 + +#### 1단계: 테넌트 일치 확인 +```java +if (!isSameTenant(user.getTenant(), resource.getTenant())) { + return false; // 접근 거부 +} +``` + +#### 2단계: 격리 수준 조회 +```java +IsolationLevel level = tenantIsolationService.getIsolationLevel(resource.getTenant()); +``` + +#### 3단계: 격리 수준별 접근 제어 + +**SHARED 모드:** +```java +if (level == IsolationLevel.SHARED) { + return true; // 테넌트 내 모든 사용자 접근 가능 +} +``` + +**DEDICATED 모드:** +```java +if (level == IsolationLevel.DEDICATED) { + return resourceOwnerService.isResourceOwner(user, resource); +} +``` + +### 접근 제어 적용 시점 + +| 작업 | 접근 제어 적용 | 비고 | +|------|--------------|------| +| 리소스 생성 | ❌ | 생성자는 자동으로 소유자 등록 | +| 리소스 조회 | ✅ | 단건/목록 모두 적용 | +| 리소스 수정 | ✅ | 접근 권한 검증 후 수정 | +| 리소스 삭제 | ✅ | 접근 권한 검증 후 삭제 | + +--- + +## 시퀀스 다이어그램 + +### 리소스 생성 시퀀스 + +```mermaid +sequenceDiagram + participant Client as 클라이언트 + participant Service as 리소스 서비스 + participant Owner as 소유권 서비스 + participant DB as 데이터베이스 + + Client->>Service: 리소스 생성 요청 + Service->>Service: 현재 사용자/테넌트 조회 + Service->>DB: 리소스 저장 + Service->>Owner: 소유권 생성 + Owner->>DB: ResourceOwner 저장 + Service-->>Client: 생성 완료 +``` + +### 리소스 조회 시퀀스 (SHARED vs DEDICATED) + +```mermaid +sequenceDiagram + participant Client as 클라이언트 + participant Service as 리소스 서비스 + participant AccessControl as 접근 제어 + participant Isolation as 격리 수준 + participant Owner as 소유권 관리 + participant DB as 데이터베이스 + + Client->>Service: 리소스 조회 요청 + Service->>DB: 리소스 조회 + Service->>AccessControl: 접근 권한 확인 + AccessControl->>Isolation: 격리 수준 조회 + Isolation->>DB: 조회 + DB-->>Isolation: SHARED/DEDICATED + + alt SHARED + AccessControl-->>Service: 허용 + else DEDICATED + AccessControl->>Owner: 소유자 확인 + Owner->>DB: ResourceOwner 조회 + DB-->>Owner: 예/아니오 + Owner-->>AccessControl: 허용/거부 + end + + alt 허용 + Service->>DB: 작업 실행 + Service-->>Client: 리소스 반환 + else 거부 + Service-->>Client: 403 Forbidden + end +``` + +--- + +## 추가 고려사항 + +### 1. 기존 리소스 마이그레이션 + +**문제:** +- `owner`가 null인 기존 리소스 처리 방안 필요 + +**해결 방안:** + +**옵션 1: SHARED 모드로만 접근 허용** +```java +// 격리 수준이 DEDICATED인데 ResourceOwner가 없는 경우 +if (level == DEDICATED && !hasOwner) { + // SHARED 모드로 간주하여 접근 허용 + return true; +} +``` + +**옵션 2: 마이그레이션 스크립트** +```java +@Transactional +public void migrateExistingResources() { + List resources = cloudResourceRepository.findAll(); + for (CloudResource resource : resources) { + if (resource.getCreatedBy() != null) { + User owner = userRepository.findByUsername(resource.getCreatedBy()) + .orElse(null); + if (owner != null && !resourceOwnerService.isResourceOwner(owner, resource)) { + resourceOwnerService.createResourceOwnership(resource, owner); + } + } + } +} +``` + +**권장:** 옵션 2 (마이그레이션 스크립트) - 데이터 일관성 보장 + +### 2. 격리 수준 변경 시 영향 + +**시나리오 1: SHARED → DEDICATED** +- 기존 리소스 접근 제한 발생 +- 모든 사용자가 접근하던 리소스가 소유자만 접근 가능 +- **대응 방안**: 변경 전 사용자에게 공지, 마이그레이션 스크립트 실행 + +**시나리오 2: DEDICATED → SHARED** +- 모든 리소스 접근 허용 +- ResourceOwner 테이블은 유지 (이력 보존) +- **대응 방안**: 즉시 적용 가능 + +**권장:** +- 격리 수준 변경 시 이벤트 발행 +- 변경 전 검증 로직 추가 +- 변경 이력 기록 + +### 3. 관리자 권한 + +**요구사항:** +- 관리자(Admin)는 모든 리소스 접근 가능 여부 + +**구현 방안:** + +```java +public boolean canAccessResource(User user, CloudResource resource) { + // 관리자 권한 확인 + if (user.hasRole("ADMIN") || user.hasPermission("RESOURCE_ADMIN_ACCESS")) { + return true; + } + + // 일반 접근 제어 로직... +} +``` + +**고려사항:** +- 관리자 권한은 테넌트 단위로 제한할지, 전체 시스템 단위로 할지 결정 필요 +- 감사 로그에 관리자 접근 기록 필수 + +### 4. 리소스 소유권 이전 + +**요구사항:** +- 리소스 소유권을 다른 사용자에게 이전 + +**구현 방안:** + +```java +@Transactional +public void transferResourceOwnership(CloudResource resource, User fromUser, User toUser) { + // 1. 현재 소유권 확인 + if (!isResourceOwner(fromUser, resource)) { + throw new AuthorizationException("소유권 이전 권한이 없습니다"); + } + + // 2. 기존 소유권 삭제 (Soft Delete) + deleteResourceOwnership(resource, fromUser); + + // 3. 새로운 소유권 생성 + createResourceOwnership(resource, toUser); + + // 4. 이력 기록 (선택) + recordOwnershipTransfer(resource, fromUser, toUser); +} +``` + +### 5. 공동 소유 (향후 확장) + +**요구사항:** +- 여러 사용자가 하나의 리소스를 공동 소유 + +**구현 방안:** +- ResourceOwner 테이블에 여러 레코드 생성 +- `accessType`을 통해 소유자/공유자 구분 +- 접근 권한 검증 시 `accessType` 확인 + +```java +// 공동 소유자도 접근 가능 +boolean canAccess = resourceOwnerRepository.existsByResourceAndUserAndAccessTypeIn( + resource, user, List.of(AccessType.OWNER, AccessType.SHARED)); +``` + +### 6. 성능 최적화 + +#### 캐싱 전략 + +**격리 수준 캐싱:** +```java +@Cacheable(value = "tenantIsolation", key = "#tenant.id") +public IsolationLevel getIsolationLevel(Tenant tenant) { + // ... +} +``` + +**캐시 무효화:** +```java +@CacheEvict(value = "tenantIsolation", key = "#tenant.id") +@Transactional +public TenantIsolation setIsolationLevel(Tenant tenant, IsolationLevel level) { + // ... +} +``` + +#### 쿼리 최적화 + +**인덱스 활용:** +- `(user_id, tenant_id, is_deleted)` 복합 인덱스 +- `(resource_id, tenant_id, is_deleted)` 복합 인덱스 + +**JOIN 최적화 (Fetch Join):** +```java +@Query("SELECT DISTINCT ro.resource FROM ResourceOwner ro " + + "LEFT JOIN FETCH ro.resource.provider " + + "LEFT JOIN FETCH ro.resource.region " + + "LEFT JOIN FETCH ro.resource.service " + + "LEFT JOIN FETCH ro.resource.tenant " + + "WHERE ro.user = :user AND ro.tenant = :tenant AND ro.isDeleted = false " + + "AND ro.resource.isDeleted = false") +List findResourcesByOwner(@Param("user") User user, + @Param("tenant") Tenant tenant); +``` + +**배치 조회 최적화 (N+1 문제 해결):** + +**문제점:** +- 기존 구현: 리소스 목록 필터링 시 각 리소스마다 개별 쿼리 실행 (N+1 문제) +- 100개 리소스 조회 시 101번의 쿼리 실행 (1번 목록 조회 + 100번 소유권 확인) + +**해결 방안:** +```java +// 1. 배치 소유권 확인 메서드 추가 +@Query("SELECT ro.resource.id FROM ResourceOwner ro " + + "WHERE ro.user = :user " + + "AND ro.resource.id IN :resourceIds " + + "AND ro.tenant = :tenant " + + "AND ro.isDeleted = false") +List findOwnedResourceIds(@Param("user") User user, + @Param("resourceIds") List resourceIds, + @Param("tenant") Tenant tenant); + +// 2. 필터링 시 배치 조회 사용 +public List filterAccessibleResources(User user, List resources) { + // 리소스 ID 목록 추출 + List resourceIds = resources.stream() + .map(CloudResource::getId) + .collect(Collectors.toList()); + + // 배치로 소유한 리소스 ID 조회 (한 번의 쿼리) + List ownedResourceIds = resourceOwnerService.getOwnedResourceIdsBatch( + user, resourceIds, tenant); + + // 메모리에서 필터링 + Set ownedResourceIdSet = new HashSet<>(ownedResourceIds); + return resources.stream() + .filter(resource -> ownedResourceIdSet.contains(resource.getId())) + .collect(Collectors.toList()); +} +``` + +**성능 개선 효과:** +- **이전**: 100개 리소스 조회 시 101번 쿼리 실행 +- **개선 후**: 100개 리소스 조회 시 2번 쿼리 실행 (1번 목록 조회 + 1번 배치 소유권 확인) +- **쿼리 수 감소**: 약 99% 감소 (101 → 2) + +### 7. 트랜잭션 관리 + +**리소스 생성 시:** +```java +@Transactional +public CloudResource createResource(CloudResource resource) { + // 1. 리소스 저장 + CloudResource saved = cloudResourceRepository.save(resource); + + // 2. 소유권 등록 (같은 트랜잭션) + resourceOwnerService.createResourceOwnership(saved, currentUser); + + return saved; +} +``` + +**롤백 시나리오:** +- 리소스 저장 실패 → 소유권도 롤백 +- 소유권 저장 실패 → 리소스도 롤백 + +### 8. 에러 처리 + +**기존 프로젝트의 에러 처리 로직 활용:** + +프로젝트는 이미 표준화된 예외 처리 구조를 가지고 있습니다: +- `BusinessException`: 모든 비즈니스 예외의 최상위 클래스 +- `ResourceNotFoundException`: 리소스를 찾을 수 없을 때 (404) +- `AuthorizationException`: 권한이 없을 때 (403) +- `GlobalExceptionHandler`: 전역 예외 처리 및 ApiResponse 변환 + +**접근 거부 시:** +```java +// ✅ 기존 AuthorizationException 활용 +if (!accessControlService.canAccessResource(user, resource)) { + throw new AuthorizationException( + user.getId(), + "CloudResource", + "조회" + ); +} +``` + +**리소스 조회 실패 시:** +```java +// ✅ 기존 ResourceNotFoundException + CloudErrorCode 활용 +CloudResource resource = cloudResourceRepository.findById(resourceId) + .orElseThrow(() -> new ResourceNotFoundException(CloudErrorCode.CLOUD_RESOURCE_NOT_FOUND)); +``` + +**에러 코드 활용:** +```java +// CloudErrorCode (4000-4999 범위) +CLOUD_RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, 4009, "클라우드 리소스를 찾을 수 없습니다.") + +// CommonErrorCode (공통 에러) +FORBIDDEN(HttpStatus.FORBIDDEN, 403, "접근 권한이 없습니다.") +NOT_FOUND(HttpStatus.NOT_FOUND, 404, "리소스를 찾을 수 없습니다.") +``` + +**에러 메시지:** +- 구체적인 이유 제공 (테넌트 불일치, 소유권 없음 등) +- 보안을 위해 과도한 정보 노출 방지 +- `GlobalExceptionHandler`가 자동으로 `ApiResponse` 형식으로 변환 + +### 9. 로깅 및 감사 + +**기존 프로젝트의 감사 로깅 어노테이션 활용:** + +프로젝트는 이미 표준화된 감사 로깅 어노테이션을 제공합니다: +- `@AuditController`: 클래스 레벨 감사 로깅 +- `@AuditRequired`: 메서드 레벨 감사 로깅 +- 자동으로 JSON 형식의 감사 로그 생성 + +**컨트롤러에 감사 로깅 적용 예시:** +```java +@RestController +@RequestMapping("/api/v1/cloud-resources") +@Slf4j +@AuditController( + resourceType = AuditResourceType.CLOUD_RESOURCE, + defaultSeverity = AuditSeverity.MEDIUM, + defaultIncludeRequestData = true, + targetHttpMethods = {"POST", "PUT", "PATCH", "DELETE"} +) +public class CloudResourceController { + + @PostMapping + public ResponseEntity> createResource( + @Valid @RequestBody CreateCloudResourceRequest request) { + // 자동으로 감사 로깅 적용됨 + // - action: "createResource" + // - resourceType: CLOUD_RESOURCE + // - severity: MEDIUM + // - requestData 포함 + } + + @PutMapping("/{id}") + @AuditRequired( + action = "updateCloudResource", + resourceType = AuditResourceType.CLOUD_RESOURCE, + severity = AuditSeverity.HIGH, + includeRequestData = true, + includeResponseData = true, + description = "클라우드 리소스 수정" + ) + public ResponseEntity> updateResource( + @PathVariable Long id, + @Valid @RequestBody UpdateCloudResourceRequest request) { + // 메서드 레벨 설정이 클래스 레벨보다 우선 + } +} +``` + +**서비스 레이어의 애플리케이션 로깅:** +```java +// ✅ 기존 @Slf4j 활용 +@Slf4j +@Service +public class ResourceAccessControlService { + + public boolean canAccessResource(User user, CloudResource resource) { + // 접근 거부 로그 (WARN 레벨) + log.warn("[ResourceAccessControlService] canAccessResource - denied: DEDICATED mode (not owner) - userId={}, resourceId={}", + user.getId(), resource.getId()); + + // 접근 허용 로그 (DEBUG 레벨) + log.debug("[ResourceAccessControlService] canAccessResource - granted: SHARED mode - userId={}, resourceId={}", + user.getId(), resource.getId()); + } +} +``` + +**감사 로그 자동 생성:** +- 리소스 생성/수정/삭제 시 자동으로 감사 로그 생성 +- 격리 수준 변경 시 감사 로그 생성 (컨트롤러에 `@AuditRequired` 적용) +- 소유권 이전 시 감사 로그 생성 (컨트롤러에 `@AuditRequired` 적용) + +**감사 로그 저장 위치:** +- 별도의 감사 로그 파일 (`audit.log`) +- 구조화된 JSON 형식 +- `AuditLog` 엔티티를 통한 DB 저장 (선택적) + +--- + +## 개선사항 + +### 1. 단기 개선사항 + +#### 1.1 격리 수준 기본값 설정 +**현재:** 격리 수준 미설정 시 접근 거부 (안전한 기본값) +**개선:** 테넌트 생성 시 기본 격리 수준 자동 설정 + +```java +@Transactional +public Tenant createTenant(Tenant tenant) { + Tenant saved = tenantRepository.save(tenant); + + // 기본 격리 수준 설정 (SHARED) + tenantIsolationService.setIsolationLevel(saved, IsolationLevel.SHARED); + + return saved; +} +``` + +#### 1.2 격리 수준 변경 검증 +**개선:** 격리 수준 변경 전 영향도 분석 + +```java +@Transactional +public TenantIsolation setIsolationLevel(Tenant tenant, IsolationLevel newLevel) { + IsolationLevel currentLevel = getIsolationLevel(tenant); + + if (currentLevel != null && currentLevel != newLevel) { + // 영향도 분석 + IsolationLevelChangeImpact impact = analyzeImpact(tenant, currentLevel, newLevel); + + // 경고 로그 + log.warn("격리 수준 변경 - tenantId={}, {} -> {}, 영향받는 리소스: {}", + tenant.getId(), currentLevel, newLevel, impact.getAffectedResourceCount()); + } + + // 격리 수준 변경 + // ... +} +``` + +#### 1.3 배치 작업 최적화 +**개선:** 목록 조회 시 N+1 문제 해결 + +```java +// 현재: 각 리소스마다 소유권 확인 +// 개선: 한 번의 쿼리로 모든 소유권 조회 + +@Query("SELECT ro FROM ResourceOwner ro " + + "WHERE ro.user = :user AND ro.tenant = :tenant AND ro.isDeleted = false") +List findByUserAndTenant(@Param("user") User user, + @Param("tenant") Tenant tenant); + +// 리소스 ID 목록으로 일괄 조회 +Set ownedResourceIds = resourceOwners.stream() + .map(ro -> ro.getResource().getId()) + .collect(Collectors.toSet()); +``` + +### 2. 중기 개선사항 + +#### 2.1 리소스 공유 기능 +**요구사항:** DEDICATED 모드에서도 특정 리소스를 다른 사용자와 공유 + +**구현:** +```java +@Transactional +public void shareResource(CloudResource resource, User owner, User sharedUser) { + // 소유권 확인 + if (!isResourceOwner(owner, resource)) { + throw new AuthorizationException("리소스 공유 권한이 없습니다"); + } + + // 공유 소유권 생성 + ResourceOwner sharedOwnership = ResourceOwner.builder() + .resource(resource) + .user(sharedUser) + .tenant(resource.getTenant()) + .accessType(AccessType.SHARED) // 공유 접근 + .build(); + + resourceOwnerRepository.save(sharedOwnership); +} +``` + +#### 2.2 접근 권한 레벨 세분화 +**요구사항:** 읽기 전용, 읽기/쓰기 등 세분화된 권한 + +**구현:** +```java +public enum AccessType { + OWNER, // 소유자 (모든 권한) + READ_WRITE, // 읽기/쓰기 + READ_ONLY, // 읽기 전용 + SHARED // 공유 접근 +} + +// 접근 권한 검증 시 작업 타입 확인 +public boolean canAccessResource(User user, CloudResource resource, OperationType operation) { + if (operation == OperationType.READ) { + return hasAccessType(user, resource, + AccessType.OWNER, AccessType.READ_WRITE, AccessType.READ_ONLY, AccessType.SHARED); + } else if (operation == OperationType.WRITE || operation == OperationType.DELETE) { + return hasAccessType(user, resource, AccessType.OWNER, AccessType.READ_WRITE); + } + return false; +} +``` + +#### 2.3 리소스 그룹 관리 +**요구사항:** 여러 리소스를 그룹으로 묶어서 관리 + +**구현:** +```java +@Entity +@Table(name = "resource_groups") +public class ResourceGroup extends BaseEntity { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tenant_id", nullable = false) + private Tenant tenant; + + @ManyToMany + @JoinTable(name = "resource_group_members", + joinColumns = @JoinColumn(name = "group_id"), + inverseJoinColumns = @JoinColumn(name = "resource_id")) + private List resources; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner_id", nullable = false) + private User owner; +} +``` + +### 3. 장기 개선사항 + +#### 3.1 동적 격리 수준 +**요구사항:** 리소스 타입별로 다른 격리 수준 적용 + +**구현:** +```java +@Entity +@Table(name = "resource_type_isolation") +public class ResourceTypeIsolation extends BaseEntity { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tenant_id", nullable = false) + private Tenant tenant; + + @Enumerated(EnumType.STRING) + @Column(name = "resource_type", nullable = false) + private CloudResource.ResourceType resourceType; + + @Enumerated(EnumType.STRING) + @Column(name = "isolation_level", nullable = false) + private IsolationLevel isolationLevel; +} +``` + +#### 3.2 시간 기반 접근 제어 +**요구사항:** 특정 시간대에만 리소스 접근 허용 + +**구현:** +```java +@Entity +@Table(name = "resource_access_schedules") +public class ResourceAccessSchedule extends BaseEntity { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "resource_id", nullable = false) + private CloudResource resource; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "allowed_time_ranges", columnDefinition = "TEXT") + private String allowedTimeRanges; // JSON: [{"start": "09:00", "end": "18:00"}] +} +``` + +#### 3.3 리소스 접근 통계 +**요구사항:** 리소스별 접근 통계 및 분석 + +**구현:** +```java +@Entity +@Table(name = "resource_access_logs") +public class ResourceAccessLog extends BaseEntity { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "resource_id", nullable = false) + private CloudResource resource; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "operation_type", nullable = false) + private OperationType operationType; // READ, WRITE, DELETE + + @Column(name = "access_time", nullable = false) + private LocalDateTime accessTime; + + @Column(name = "ip_address") + private String ipAddress; +} +``` + +#### 3.4 자동 리소스 정리 +**요구사항:** 미사용 리소스 자동 정리 + +**구현:** +```java +@Scheduled(cron = "0 0 2 * * ?") // 매일 새벽 2시 +public void cleanupUnusedResources() { + LocalDateTime threshold = LocalDateTime.now().minusMonths(6); + + List unusedResources = cloudResourceRepository + .findUnusedResources(threshold); + + for (CloudResource resource : unusedResources) { + // 소유자에게 알림 + notificationService.notifyResourceOwner(resource, + "6개월 이상 사용되지 않은 리소스입니다. 삭제 예정일: " + + LocalDateTime.now().plusDays(30)); + } +} +``` + +### 4. 보안 강화 + +#### 4.1 접근 패턴 분석 +**요구사항:** 비정상적인 접근 패턴 탐지 + +**구현:** +```java +public boolean detectAnomalousAccess(User user, CloudResource resource) { + // 1. 시간대 분석 (비정상적인 시간대 접근) + if (isUnusualTimeAccess(user, resource)) { + return true; + } + + // 2. 접근 빈도 분석 (과도한 접근 시도) + if (isHighFrequencyAccess(user, resource)) { + return true; + } + + // 3. IP 주소 분석 (새로운 IP에서 접근) + if (isNewIpAccess(user, resource)) { + return true; + } + + return false; +} +``` + +#### 4.2 2단계 인증 강화 +**요구사항:** 민감한 리소스 접근 시 2FA 필수 + +**구현:** +```java +public boolean canAccessResource(User user, CloudResource resource) { + // 일반 접근 제어 로직... + + // 민감한 리소스인 경우 2FA 확인 + if (isSensitiveResource(resource) && !user.isTwoFactorVerified()) { + throw new TwoFactorRequiredException("민감한 리소스 접근을 위해 2FA 인증이 필요합니다"); + } + + return true; +} +``` + +### 5. 모니터링 및 알림 + +#### 5.1 접근 실패 모니터링 +**구현:** +```java +@EventListener +public void handleAccessDenied(AccessDeniedEvent event) { + // 접근 실패 횟수 증가 + accessFailureCounter.increment(event.getUserId(), event.getResourceId()); + + // 임계값 초과 시 알림 + if (accessFailureCounter.getCount(event.getUserId()) > 10) { + alertService.sendAlert("사용자 " + event.getUserId() + + "의 접근 실패 횟수가 임계값을 초과했습니다"); + } +} +``` + +#### 5.2 리소스 사용량 모니터링 +**구현:** +```java +@Entity +@Table(name = "resource_usage_metrics") +public class ResourceUsageMetric extends BaseEntity { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "resource_id", nullable = false) + private CloudResource resource; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "access_count") + private Long accessCount = 0L; + + @Column(name = "last_accessed_at") + private LocalDateTime lastAccessedAt; +} +``` + +--- + +## 구현 체크리스트 + +### Phase 1: 기본 구현 ✅ +- [x] ResourceOwner 엔티티 생성 +- [x] ResourceOwnerRepository 생성 +- [x] TenantIsolationRepository 생성 +- [x] ResourceOwnerService 구현 +- [x] ResourceAccessControlService 구현 +- [x] TenantIsolationService 재작성 +- [x] CloudResourceService 수정 +- [x] Organization과 Tenant 관계를 1:1로 변경 +- [x] OrganizationService 및 Repository 수정 +- [x] 기존 감사 로깅 어노테이션 활용 (`@AuditController`, `@AuditRequired`) +- [x] 기존 에러 처리 로직 활용 (`BusinessException`, `ResourceNotFoundException`, `AuthorizationException`) + +### Phase 2: 테스트 및 검증 +- [ ] 단위 테스트 작성 +- [ ] 통합 테스트 작성 +- [ ] 성능 테스트 +- [ ] 보안 테스트 + +### Phase 3: 마이그레이션 +- [ ] 기존 리소스 마이그레이션 스크립트 작성 +- [ ] 마이그레이션 테스트 +- [ ] 프로덕션 배포 계획 + +### Phase 4: 모니터링 및 문서화 +- [ ] 모니터링 대시보드 구성 +- [ ] 운영 문서 작성 +- [ ] 사용자 가이드 작성 + +--- + +## 참고 자료 + +- [접근 제어 흐름 설계 문서](./TENANT_ISOLATION_ACCESS_CONTROL_DESIGN.md) +- [도메인 아키텍처 문서](./DOMAIN_ARCHITECTURE.md) +- [테스트 가이드라인](./TESTING_GUIDELINES.md) + +--- + +## 변경 이력 + +| 버전 | 날짜 | 변경 내용 | 작성자 | +|------|------|----------|--------| +| 1.0.0 | 2025-01-XX | 초기 문서 작성 | AgenticCP Team | + From 04d755e97beb866bb0b58a7f39232101f5f6b066 Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Mon, 24 Nov 2025 19:48:30 +0900 Subject: [PATCH 31/32] =?UTF-8?q?fix:=20FeatureFlagAuditServiceTest=20requ?= =?UTF-8?q?estPath=20=EB=88=84=EB=9D=BD=20=EC=98=A4=EB=A5=98=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20-=20AuditEventBuilder=EC=97=90=20requestPath/httpMe?= =?UTF-8?q?thod=20=EC=84=A4=EC=A0=95=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/common/audit/AuditEventBuilder.java | 27 +++++++++++++++++++ .../domain/monitoring/entity/MetricTag.java | 4 +-- .../service/FeatureFlagAuditService.java | 8 ++++++ .../service/FeatureFlagAuditServiceTest.java | 2 ++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/agenticcp/core/common/audit/AuditEventBuilder.java b/src/main/java/com/agenticcp/core/common/audit/AuditEventBuilder.java index dd1259a31..298f0afd3 100644 --- a/src/main/java/com/agenticcp/core/common/audit/AuditEventBuilder.java +++ b/src/main/java/com/agenticcp/core/common/audit/AuditEventBuilder.java @@ -67,6 +67,11 @@ private AuditEventBuilder(AuditContextDto context) { this.userId = context.userId(); this.clientIp = context.clientIp(); } + + // requestPath가 null이거나 blank인 경우 기본값 설정 (나중에 덮어쓸 수 있음) + if (this.requestPath == null || this.requestPath.isBlank()) { + this.requestPath = "/api/unknown"; + } } /** @@ -157,6 +162,28 @@ public AuditEventBuilder targetResourceId(String targetResourceId) { return this; } + /** + * 요청 경로를 설정합니다. + * + * @param requestPath 요청 경로 + * @return 빌더 + */ + public AuditEventBuilder requestPath(String requestPath) { + this.requestPath = requestPath; + return this; + } + + /** + * HTTP 메서드를 설정합니다. + * + * @param httpMethod HTTP 메서드 + * @return 빌더 + */ + public AuditEventBuilder httpMethod(String httpMethod) { + this.httpMethod = httpMethod; + return this; + } + /** * 감사 이벤트 DTO를 생성합니다. * 필수 필드 누락 시 {@link BusinessException}을 발생시킵니다. diff --git a/src/main/java/com/agenticcp/core/domain/monitoring/entity/MetricTag.java b/src/main/java/com/agenticcp/core/domain/monitoring/entity/MetricTag.java index 4db4b71bc..e158cfa0c 100644 --- a/src/main/java/com/agenticcp/core/domain/monitoring/entity/MetricTag.java +++ b/src/main/java/com/agenticcp/core/domain/monitoring/entity/MetricTag.java @@ -24,7 +24,7 @@ @Table(name = "metric_tags", indexes = { @Index(name = "idx_metric_tags_metric_id", columnList = "metric_id"), @Index(name = "idx_metric_tags_name", columnList = "name"), - @Index(name = "idx_metric_tags_name_value", columnList = "name,value"), + @Index(name = "idx_metric_tags_name_value", columnList = "name,\"value\""), @Index(name = "idx_metric_tags_category", columnList = "category") }) @Getter @@ -54,7 +54,7 @@ public class MetricTag extends BaseEntity { */ @NotBlank(message = "태그 값은 필수입니다") @Size(max = 255, message = "태그 값은 255자를 초과할 수 없습니다") - @Column(name = "value", nullable = false, length = 255) + @Column(name = "\"value\"", nullable = false, length = 255) private String value; /** diff --git a/src/main/java/com/agenticcp/core/domain/platform/service/FeatureFlagAuditService.java b/src/main/java/com/agenticcp/core/domain/platform/service/FeatureFlagAuditService.java index deec8d67f..68fc2b892 100644 --- a/src/main/java/com/agenticcp/core/domain/platform/service/FeatureFlagAuditService.java +++ b/src/main/java/com/agenticcp/core/domain/platform/service/FeatureFlagAuditService.java @@ -93,6 +93,8 @@ public void logFlagChange(FeatureFlag oldFlag, FeatureFlag newFlag, String actio .userId(userId != null ? userId : mdcContext.userId()) .action(normalizedAction) .resourceType(AuditResourceType.FEATURE_FLAG) + .requestPath(mdcContext.requestPath() != null ? mdcContext.requestPath() : "/api/platform/feature-flags") + .httpMethod(mdcContext.httpMethod() != null ? mdcContext.httpMethod() : "POST") .operationSummary("Feature Flag " + normalizedAction) .controllerName("FeatureFlagController") .methodName("") @@ -103,6 +105,12 @@ public void logFlagChange(FeatureFlag oldFlag, FeatureFlag newFlag, String actio // 감사 이벤트 생성 AuditEventDto event = AuditEventBuilder.builder(context) + .requestPath(context.requestPath() != null && !context.requestPath().isBlank() + ? context.requestPath() + : "/api/platform/feature-flags") + .httpMethod(context.httpMethod() != null && !context.httpMethod().isBlank() + ? context.httpMethod() + : "POST") .requestData(changeDetails) .responseData(null) .oldValue(oldValue) diff --git a/src/test/java/com/agenticcp/core/domain/platform/service/FeatureFlagAuditServiceTest.java b/src/test/java/com/agenticcp/core/domain/platform/service/FeatureFlagAuditServiceTest.java index 5ebce6ceb..dd7f0dac9 100644 --- a/src/test/java/com/agenticcp/core/domain/platform/service/FeatureFlagAuditServiceTest.java +++ b/src/test/java/com/agenticcp/core/domain/platform/service/FeatureFlagAuditServiceTest.java @@ -63,6 +63,8 @@ void setUp() { .tenantId("test-tenant-id") .clientIp("127.0.0.1") .userId("test-user-id") + .requestPath("/api/platform/feature-flags") + .httpMethod("POST") .build(); lenient().when(auditContextProvider.getCurrentContext()).thenReturn(mockContext); From 8f647fdf29cbe621bdbb85c2ff1b92d0824b19a3 Mon Sep 17 00:00:00 2001 From: yeonsoo Date: Mon, 24 Nov 2025 20:29:57 +0900 Subject: [PATCH 32/32] =?UTF-8?q?Revert=20"fix:=20FeatureFlagAuditServiceT?= =?UTF-8?q?est=20requestPath=20=EB=88=84=EB=9D=BD=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20-=20AuditEventBuilder=EC=97=90=20requestPa?= =?UTF-8?q?th/httpMethod=20=EC=84=A4=EC=A0=95=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 04d755e97beb866bb0b58a7f39232101f5f6b066. --- .../core/common/audit/AuditEventBuilder.java | 27 ------------------- .../domain/monitoring/entity/MetricTag.java | 4 +-- .../service/FeatureFlagAuditService.java | 8 ------ .../service/FeatureFlagAuditServiceTest.java | 2 -- 4 files changed, 2 insertions(+), 39 deletions(-) diff --git a/src/main/java/com/agenticcp/core/common/audit/AuditEventBuilder.java b/src/main/java/com/agenticcp/core/common/audit/AuditEventBuilder.java index 298f0afd3..dd1259a31 100644 --- a/src/main/java/com/agenticcp/core/common/audit/AuditEventBuilder.java +++ b/src/main/java/com/agenticcp/core/common/audit/AuditEventBuilder.java @@ -67,11 +67,6 @@ private AuditEventBuilder(AuditContextDto context) { this.userId = context.userId(); this.clientIp = context.clientIp(); } - - // requestPath가 null이거나 blank인 경우 기본값 설정 (나중에 덮어쓸 수 있음) - if (this.requestPath == null || this.requestPath.isBlank()) { - this.requestPath = "/api/unknown"; - } } /** @@ -162,28 +157,6 @@ public AuditEventBuilder targetResourceId(String targetResourceId) { return this; } - /** - * 요청 경로를 설정합니다. - * - * @param requestPath 요청 경로 - * @return 빌더 - */ - public AuditEventBuilder requestPath(String requestPath) { - this.requestPath = requestPath; - return this; - } - - /** - * HTTP 메서드를 설정합니다. - * - * @param httpMethod HTTP 메서드 - * @return 빌더 - */ - public AuditEventBuilder httpMethod(String httpMethod) { - this.httpMethod = httpMethod; - return this; - } - /** * 감사 이벤트 DTO를 생성합니다. * 필수 필드 누락 시 {@link BusinessException}을 발생시킵니다. diff --git a/src/main/java/com/agenticcp/core/domain/monitoring/entity/MetricTag.java b/src/main/java/com/agenticcp/core/domain/monitoring/entity/MetricTag.java index e158cfa0c..4db4b71bc 100644 --- a/src/main/java/com/agenticcp/core/domain/monitoring/entity/MetricTag.java +++ b/src/main/java/com/agenticcp/core/domain/monitoring/entity/MetricTag.java @@ -24,7 +24,7 @@ @Table(name = "metric_tags", indexes = { @Index(name = "idx_metric_tags_metric_id", columnList = "metric_id"), @Index(name = "idx_metric_tags_name", columnList = "name"), - @Index(name = "idx_metric_tags_name_value", columnList = "name,\"value\""), + @Index(name = "idx_metric_tags_name_value", columnList = "name,value"), @Index(name = "idx_metric_tags_category", columnList = "category") }) @Getter @@ -54,7 +54,7 @@ public class MetricTag extends BaseEntity { */ @NotBlank(message = "태그 값은 필수입니다") @Size(max = 255, message = "태그 값은 255자를 초과할 수 없습니다") - @Column(name = "\"value\"", nullable = false, length = 255) + @Column(name = "value", nullable = false, length = 255) private String value; /** diff --git a/src/main/java/com/agenticcp/core/domain/platform/service/FeatureFlagAuditService.java b/src/main/java/com/agenticcp/core/domain/platform/service/FeatureFlagAuditService.java index 68fc2b892..deec8d67f 100644 --- a/src/main/java/com/agenticcp/core/domain/platform/service/FeatureFlagAuditService.java +++ b/src/main/java/com/agenticcp/core/domain/platform/service/FeatureFlagAuditService.java @@ -93,8 +93,6 @@ public void logFlagChange(FeatureFlag oldFlag, FeatureFlag newFlag, String actio .userId(userId != null ? userId : mdcContext.userId()) .action(normalizedAction) .resourceType(AuditResourceType.FEATURE_FLAG) - .requestPath(mdcContext.requestPath() != null ? mdcContext.requestPath() : "/api/platform/feature-flags") - .httpMethod(mdcContext.httpMethod() != null ? mdcContext.httpMethod() : "POST") .operationSummary("Feature Flag " + normalizedAction) .controllerName("FeatureFlagController") .methodName("") @@ -105,12 +103,6 @@ public void logFlagChange(FeatureFlag oldFlag, FeatureFlag newFlag, String actio // 감사 이벤트 생성 AuditEventDto event = AuditEventBuilder.builder(context) - .requestPath(context.requestPath() != null && !context.requestPath().isBlank() - ? context.requestPath() - : "/api/platform/feature-flags") - .httpMethod(context.httpMethod() != null && !context.httpMethod().isBlank() - ? context.httpMethod() - : "POST") .requestData(changeDetails) .responseData(null) .oldValue(oldValue) diff --git a/src/test/java/com/agenticcp/core/domain/platform/service/FeatureFlagAuditServiceTest.java b/src/test/java/com/agenticcp/core/domain/platform/service/FeatureFlagAuditServiceTest.java index dd7f0dac9..5ebce6ceb 100644 --- a/src/test/java/com/agenticcp/core/domain/platform/service/FeatureFlagAuditServiceTest.java +++ b/src/test/java/com/agenticcp/core/domain/platform/service/FeatureFlagAuditServiceTest.java @@ -63,8 +63,6 @@ void setUp() { .tenantId("test-tenant-id") .clientIp("127.0.0.1") .userId("test-user-id") - .requestPath("/api/platform/feature-flags") - .httpMethod("POST") .build(); lenient().when(auditContextProvider.getCurrentContext()).thenReturn(mockContext);