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 | + diff --git a/pom.xml b/pom.xml index e76b9fc89..7d754776b 100644 --- a/pom.xml +++ b/pom.xml @@ -197,6 +197,14 @@ test + + + org.springframework.cloud + spring-cloud-starter-openfeign + 4.1.3 + + + org.awaitility awaitility diff --git a/src/main/java/com/agenticcp/core/AgenticCpCoreApplication.java b/src/main/java/com/agenticcp/core/AgenticCpCoreApplication.java index 5259464f7..a423dc06d 100644 --- a/src/main/java/com/agenticcp/core/AgenticCpCoreApplication.java +++ b/src/main/java/com/agenticcp/core/AgenticCpCoreApplication.java @@ -4,12 +4,10 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.scheduling.annotation.EnableAsync; -import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaRepositories @EnableAsync -@EnableScheduling public class AgenticCpCoreApplication { public static void main(String[] args) { 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/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/CloudResourceService.java b/src/main/java/com/agenticcp/core/domain/cloud/service/CloudResourceService.java index aaa77642a..296eaa15d 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.findByTenantKey(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()); + } +} + 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; + } +} + 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 99fbb5230..ba5c7b0a6 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 @@ -352,7 +352,7 @@ public ResponseEntity> removeUserFromOrganization( ) @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> getOrganizationTenant( @Parameter(description = "조직 ID", required = true, example = "1") 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 8bd5d2095..7adc740e4 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 @@ -85,4 +85,4 @@ public interface OrganizationRepository extends JpaRepository 0 THEN true ELSE false END FROM Tenant t WHERE t.organization.id = :organizationId") boolean existsTenantByOrganizationId(@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 67dd00166..7bc85190d 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; /** @@ -632,6 +634,29 @@ public Tenant getOrganizationTenant(Long organizationId) { return tenant; } + /** + * 조직에 속한 테넌트 조회 (1:1 관계, 없으면 예외) + * + * @param organizationId 조직 ID + * @return 조직에 속한 테넌트 + * @throws ResourceNotFoundException 조직 또는 테넌트를 찾을 수 없는 경우 + */ + public Tenant getOrganizationTenantOrThrow(Long organizationId) { + log.info("[OrganizationService] getOrganizationTenantOrThrow - organizationId={}", organizationId); + + // 조직 존재 확인 + organizationRepository.findById(organizationId) + .orElseThrow(() -> new ResourceNotFoundException("Organization", "id", organizationId.toString())); + + // 조직의 테넌트 조회 (1:1 관계) + Tenant tenant = organizationRepository.findTenantByOrganizationId(organizationId) + .orElseThrow(() -> new ResourceNotFoundException("Tenant", "organizationId", organizationId.toString())); + + log.info("[OrganizationService] getOrganizationTenantOrThrow - success organizationId={}, tenantId={}", + organizationId, tenant.getId()); + return tenant; + } + /** * 조직에 테넌트가 존재하는지 확인 * 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..6f0eba104 --- /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.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; + +@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; + } +} 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..84d45a979 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/adapter/TenantIsolationAdapter.java @@ -0,0 +1,52 @@ +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; + +/** + * 테넌트 격리 정책을 적용하는 핵심 인터페이스 + * 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 클라우드 프로바이더 타입 + */ + 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 new file mode 100644 index 000000000..47242a3dc --- /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.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 -> 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 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 diff --git a/src/main/java/com/agenticcp/core/domain/tenant/cloud/CloudProviderType.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/CloudProviderType.java new file mode 100644 index 000000000..82c9b3e29 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/CloudProviderType.java @@ -0,0 +1,11 @@ +package com.agenticcp.core.domain.tenant.cloud; + +import lombok.Getter; + +@Getter +public enum CloudProviderType { + AWS, + AZURE, + GCP, + OTHER +} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/cloud/dto/CloudResourceResult.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/dto/CloudResourceResult.java new file mode 100644 index 000000000..82f7f0221 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/dto/CloudResourceResult.java @@ -0,0 +1,12 @@ +package com.agenticcp.core.domain.tenant.cloud.dto; + +import lombok.Builder; + +import java.util.Map; + +@Builder +public record CloudResourceResult( + String resourceId, + Map metadata +) { +} 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/isolation/IsolationStrategy.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/isolation/IsolationStrategy.java new file mode 100644 index 000000000..7c3f0e084 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/isolation/IsolationStrategy.java @@ -0,0 +1,13 @@ +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 { + + 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/isolation/IsolationStrategyFactory.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/isolation/IsolationStrategyFactory.java new file mode 100644 index 000000000..f96e1427c --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/isolation/IsolationStrategyFactory.java @@ -0,0 +1,38 @@ +package com.agenticcp.core.domain.tenant.cloud.service.isolation; + +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.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( + Map.Entry::getKey, + Map.Entry::getValue + )); + } + + // 격리 수준 조회 + public IsolationStrategy getStrategy(TenantIsolation.IsolationLevel isolationLevel) { + return strategies.get(isolationLevel); + } + + private Stream> findSupportedLevel(IsolationStrategy st) { + return Arrays.stream(TenantIsolation.IsolationLevel.values()) + .filter(st::supports) + .map(level -> Map.entry(level, st)); + } + +} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/isolation/SharedIsolationStrategy.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/isolation/SharedIsolationStrategy.java new file mode 100644 index 000000000..f5f03b00a --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/isolation/SharedIsolationStrategy.java @@ -0,0 +1,41 @@ +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; + +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); + + } +} 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 new file mode 100644 index 000000000..236710809 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/resource/AwsCloudResourceCreator.java @@ -0,0 +1,44 @@ +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; +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 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 형식으로 변환 + Map awsMetadata = getAwsMetadata(request); + + // TODO: 실제 AWS API 호출 구현 + 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()); + } +} diff --git a/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/resource/CloudResourceCreator.java b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/resource/CloudResourceCreator.java new file mode 100644 index 000000000..f44847b96 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/cloud/service/resource/CloudResourceCreator.java @@ -0,0 +1,23 @@ +package com.agenticcp.core.domain.tenant.cloud.service.resource; + + +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); + + + + + +} 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..72611ec78 --- /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().getIsolationLevel().toString(); + + log.info("tenant isolation applied - tenantKey={}, isolationLevel={}", tenantKey, isolationLevel); + + } + +} 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 new file mode 100644 index 000000000..f76f4c2ef --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantIsolationService.java @@ -0,0 +1,127 @@ +package com.agenticcp.core.domain.tenant.service; + +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.entity.Tenant; +import com.agenticcp.core.domain.tenant.entity.TenantIsolation; +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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +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; + + /** + * 테넌트의 격리 수준 조회 + * + * @param tenant 테넌트 + * @return 격리 수준 (없으면 null) + */ + 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 tenant 테넌트 + * @return 격리 정보 + */ + 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 tenant 테넌트 + * @param isolationLevel 격리 수준 + * @return 저장된 격리 정보 + */ + @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); + } + + TenantIsolation saved = tenantIsolationRepository.save(isolation); + log.info("[TenantIsolationService] setIsolationLevel - success tenantKey={}, level={}", + LogMaskingUtils.maskTenantKey(tenant.getTenantKey()), isolationLevel); + return saved; + } + + /** + * 테넌트의 격리 정보 저장 + * + * @param tenantIsolation 격리 정보 + * @return 저장된 격리 정보 + */ + @Transactional + 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; + } +} 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/organization/service/OrganizationTenantServiceTest.java b/src/test/java/com/agenticcp/core/domain/organization/service/OrganizationTenantServiceTest.java deleted file mode 100644 index df9668f16..000000000 --- a/src/test/java/com/agenticcp/core/domain/organization/service/OrganizationTenantServiceTest.java +++ /dev/null @@ -1,239 +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.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -@DisplayName("Organization-Tenant 관계 테스트 (1:1)") -class OrganizationTenantServiceTest { - - @Mock - private OrganizationRepository organizationRepository; - - @Mock - private TenantRepository tenantRepository; - - @InjectMocks - private OrganizationService organizationService; - - private Organization testOrganization; - private Tenant testTenant; - - @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:1 관계) - testTenant = Tenant.builder() - .tenantKey("TENANT_A") - .tenantName("테넌트 A") - .description("테스트 테넌트 A") - .status(Status.ACTIVE) - .maxUsers(50) - .organization(testOrganization) - .build(); - testTenant.setId(1L); - - // 조직에 테넌트 설정 (1:1) - testOrganization.setTenant(testTenant); - } - - @Test - @DisplayName("조직에 연결된 테넌트 조회 성공 (1:1)") - void 조직에_연결된_테넌트_조회_성공() { - // Given - Long organizationId = 1L; - - when(organizationRepository.findTenantByOrganizationId(organizationId)) - .thenReturn(Optional.of(testTenant)); - - // When - Optional result = organizationRepository.findTenantByOrganizationId(organizationId); - - // Then - assertThat(result).isPresent(); - assertThat(result.get().getTenantKey()).isEqualTo("TENANT_A"); - - verify(organizationRepository).findTenantByOrganizationId(organizationId); - } - - @Test - @DisplayName("조직에 테넌트가 존재하는지 확인") - void 조직에_테넌트_존재_여부_확인() { - // Given - Long organizationId = 1L; - - when(organizationRepository.existsTenantByOrganizationId(organizationId)) - .thenReturn(true); - - // When - boolean exists = organizationRepository.existsTenantByOrganizationId(organizationId); - - // Then - assertThat(exists).isTrue(); - verify(organizationRepository).existsTenantByOrganizationId(organizationId); - } - - @Test - @DisplayName("조직에 테넌트가 없는 경우") - void 조직에_테넌트가_없는_경우() { - // Given - Long organizationId = 999L; - - when(organizationRepository.findTenantByOrganizationId(organizationId)) - .thenReturn(Optional.empty()); - - // When - Optional result = organizationRepository.findTenantByOrganizationId(organizationId); - - // Then - assertThat(result).isEmpty(); - verify(organizationRepository).findTenantByOrganizationId(organizationId); - } - - @Test - @DisplayName("조직-테넌트 1:1 관계 검증") - void 조직_테넌트_1대1_관계_검증() { - // Given - Organization org = testOrganization; - Tenant tenant = testTenant; - - // When & Then - // 조직이 테넌트를 포함하는지 확인 (1:1) - assertThat(org.getTenant()).isNotNull(); - assertThat(org.getTenant()).isEqualTo(tenant); - - // 테넌트가 조직을 참조하는지 확인 - 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 = testTenant; - - // 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("조직별 테넌트 정보 조회 (1:1)") - void 조직별_테넌트_정보_조회() { - // Given - Long organizationId = 1L; - - when(organizationRepository.findTenantByOrganizationId(organizationId)) - .thenReturn(Optional.of(testTenant)); - - // When - Optional result = organizationRepository.findTenantByOrganizationId(organizationId); - - // Then - assertThat(result).isPresent(); - - Tenant tenant = result.get(); - assertThat(tenant.getTenantKey()).isEqualTo("TENANT_A"); - assertThat(tenant.getTenantName()).isEqualTo("테넌트 A"); - assertThat(tenant.getStatus()).isEqualTo(Status.ACTIVE); - assertThat(tenant.getMaxUsers()).isEqualTo(50); - } - - @Test - @DisplayName("테넌트 활성 상태 확인") - void 테넌트_활성_상태_확인() { - // Given - Long organizationId = 1L; - - when(organizationRepository.findTenantByOrganizationId(organizationId)) - .thenReturn(Optional.of(testTenant)); - - // When - Optional result = organizationRepository.findTenantByOrganizationId(organizationId); - - // Then - assertThat(result).isPresent(); - assertThat(result.get().getStatus()).isEqualTo(Status.ACTIVE); - } - - @Test - @DisplayName("테넌트 비활성 상태 확인") - void 테넌트_비활성_상태_확인() { - // Given - Long organizationId = 1L; - - Tenant inactiveTenant = Tenant.builder() - .tenantKey("INACTIVE_TENANT") - .tenantName("비활성 테넌트") - .status(Status.INACTIVE) - .maxUsers(30) - .organization(testOrganization) - .build(); - inactiveTenant.setId(2L); - - when(organizationRepository.findTenantByOrganizationId(organizationId)) - .thenReturn(Optional.of(inactiveTenant)); - - // When - Optional result = organizationRepository.findTenantByOrganizationId(organizationId); - - // Then - assertThat(result).isPresent(); - assertThat(result.get().getStatus()).isEqualTo(Status.INACTIVE); - } -} 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); + } + } +} +