diff --git a/docs/RBAC_SIMPLIFIED_SEQUENCE.md b/docs/RBAC_SIMPLIFIED_SEQUENCE.md new file mode 100644 index 00000000..f96de351 --- /dev/null +++ b/docs/RBAC_SIMPLIFIED_SEQUENCE.md @@ -0,0 +1,897 @@ +# RBAC 권한 체크 시퀀스 다이어그램 + +## 시나리오 개요 + +### 시나리오: User ↔ Worker (1:N), Worker → Tenant (N:1) +- **User ↔ Worker (1:N)**: 한 User는 여러 Worker를 가질 수 있음 +- **Worker → Tenant (N:1)**: Worker는 하나의 Tenant에만 속함 +- **User가 여러 Tenant에 속할 수 있음**: 각 Tenant마다 Worker가 다름 +- **컨텍스트 선택 필요**: User가 여러 Tenant 중 하나를 선택해야 함 +- **Worker 결정**: Tenant 선택 시 해당 Tenant의 Worker 자동 조회 + +### 핵심 원칙 +- **User는 조직(테넌트)이 정해지면 Worker도 정해진다** +- **Worker는 Tenant에 종속적** → Worker를 직접 선택할 필요 없음 +- **Tenant 선택** → 해당 Tenant의 Worker 자동 결정 + +### 전제 조건 +- **Tenant ↔ Organization (1:1)**: Tenant와 Organization은 1:1 관계 + +--- + +## 1. 사용자 로그인 및 Tenant 목록 조회 시퀀스 + +```mermaid +sequenceDiagram + participant Client + participant AuthController + participant AuthService + participant UserRepository + participant WorkerRepository + participant TenantRepository + participant SecurityContext + + Client->>AuthController: POST /api/auth/login
(username, password) + AuthController->>AuthService: authenticate(username, password) + AuthService->>UserRepository: findByUsername(username) + UserRepository-->>AuthService: User (id, ...) + + Note over AuthService: User 인증 성공 + AuthService->>WorkerRepository: findByUserId(userId) + WorkerRepository-->>AuthService: List
[Worker A (Tenant 1), Worker B (Tenant 2), ...] + + Note over AuthService: User가 속한 모든 Tenant 조회
(각 Tenant마다 Worker 존재) + AuthService->>TenantRepository: findTenantsByWorkers(workers) + TenantRepository-->>AuthService: List
[Tenant 1, Tenant 2, ...] + + AuthService->>SecurityContext: setAuthentication(userId) + SecurityContext-->>AuthService: OK + AuthService-->>AuthController: AuthResponse (token, userId, availableTenants) + AuthController-->>Client: 200 OK + JWT Token
+ Tenant 목록 +``` + +--- + +## 2. 컨텍스트 선택 및 Tenant/Worker 결정 시퀀스 + +```mermaid +sequenceDiagram + participant Client + participant TenantContextInterceptor + participant TenantContextService + participant TenantRepository + participant WorkerRepository + participant SecurityContext + participant TenantContextHolder + + Client->>TenantContextInterceptor: API Request
(Authorization: Bearer token
X-Tenant-Id: tenantId) + TenantContextInterceptor->>SecurityContext: getAuthentication() + SecurityContext-->>TenantContextInterceptor: Authentication (userId) + + TenantContextInterceptor->>TenantContextInterceptor: X-Tenant-Id 헤더 파싱 + alt Tenant ID가 헤더에 있음 + TenantContextInterceptor->>TenantContextService: validateTenantAccess(userId, tenantId) + TenantContextService->>WorkerRepository: findByUserIdAndTenantId(userId, tenantId) + WorkerRepository-->>TenantContextService: Worker (id, userId, tenantId) + + alt Worker가 있음 (User가 이 Tenant에 속함) + TenantContextService-->>TenantContextInterceptor: Tenant, Worker + TenantContextInterceptor->>TenantContextHolder: setCurrentTenantAndWorker(tenantId, worker) + Note over TenantContextHolder: effectiveTenantId, effectiveWorkerId 저장 + else Worker가 없음 (User가 이 Tenant에 속하지 않음) + TenantContextInterceptor-->>Client: 403 Forbidden
(User is not a member of this tenant) + end + else Tenant ID가 헤더에 없음 + TenantContextInterceptor-->>Client: 400 Bad Request
(Tenant ID required) + end +``` + +--- + +## 3. API 요청 및 권한 체크 시퀀스 + +```mermaid +sequenceDiagram + participant Client + participant TenantContextInterceptor + participant TenantContextHolder + participant Controller + participant Service + participant PermissionService + participant WorkerRoleAssignmentRepository + participant RolePermissionRepository + participant PermissionRepository + + Client->>TenantContextInterceptor: API Request
(X-Tenant-Id: tenantId) + TenantContextInterceptor->>TenantContextHolder: getCurrentTenantAndWorker() + TenantContextHolder-->>TenantContextInterceptor: Tenant (id), Worker (id) + + Note over TenantContextInterceptor: 컨텍스트 선택 완료
effectiveTenantId, effectiveWorkerId 확보 + + TenantContextInterceptor-->>Controller: Request (with TenantContext) + + Controller->>Service: executeAction(action, resourceId) + + Service->>TenantContextHolder: getCurrentTenantAndWorker() + TenantContextHolder-->>Service: Tenant (id), Worker (id) + + Service->>PermissionService: can(workerId, tenantId, action, resource) + + Note over PermissionService: 권한 체크 시작 + + PermissionService->>PermissionService: 1) 리소스의 tenant 확인
if (resource.tenantId != tenantId) return DENY + + PermissionService->>WorkerRoleAssignmentRepository: findByTenantIdAndWorkerId(tenantId, workerId) + WorkerRoleAssignmentRepository-->>PermissionService: List + + alt roles가 비어있음 + PermissionService-->>Service: DENY + Service-->>Controller: 403 Forbidden + Controller-->>Client: 403 Forbidden + else roles가 있음 + PermissionService->>RolePermissionRepository: existsByRoleIdsAndResourceTypeAndAction(
roleIds, resourceType, action) + RolePermissionRepository->>PermissionRepository: findByResourceTypeAndAction(resourceType, action) + PermissionRepository-->>RolePermissionRepository: Permission + RolePermissionRepository-->>PermissionService: true/false + + alt 권한 있음 + PermissionService-->>Service: ALLOW + Service->>Service: 비즈니스 로직 실행 + Service-->>Controller: Success + Controller-->>Client: 200 OK + else 권한 없음 + PermissionService-->>Service: DENY + Service-->>Controller: 403 Forbidden + Controller-->>Client: 403 Forbidden + end + end +``` + +--- + +## 4. 상세 권한 체크 흐름 (can 메서드 내부) + +```mermaid +sequenceDiagram + participant PermissionService + participant ResourceRepository + participant WorkerRoleAssignmentRepository + participant RolePermissionRepository + participant PermissionRepository + + Note over PermissionService: can(workerId, tenantId, action, resource) + + PermissionService->>ResourceRepository: findById(resourceId) + ResourceRepository-->>PermissionService: Resource (tenantId, type, ...) + + alt resource.tenantId != tenantId + PermissionService-->>PermissionService: return DENY
(Tenant 격리 위반) + else tenant 일치 + PermissionService->>WorkerRoleAssignmentRepository: findByTenantIdAndWorkerId(tenantId, workerId) + WorkerRoleAssignmentRepository-->>PermissionService: List
[roleId1, roleId2, ...] + + alt roles가 비어있음 + PermissionService-->>PermissionService: return DENY
(역할 없음) + else roles 있음 + PermissionService->>RolePermissionRepository: findByRoleIdIn(roleIds) + RolePermissionRepository-->>PermissionService: List + + PermissionService->>PermissionRepository: findByResourceTypeAndAction(
resource.type, action) + PermissionRepository-->>PermissionService: Permission + + alt Permission이 RolePermission 목록에 포함됨 + PermissionService-->>PermissionService: return ALLOW + else 포함되지 않음 + PermissionService-->>PermissionService: return DENY
(권한 없음) + end + end + end +``` + +--- + +## 5. 클라우드 리소스 접근 시퀀스 + +```mermaid +sequenceDiagram + participant Client + participant TenantContextInterceptor + participant TenantContextHolder + participant Controller + participant CloudResourceService + participant PermissionService + participant TenantRepository + participant WorkerRepository + participant CloudResourceRepository + participant WorkerRoleAssignmentRepository + participant RolePermissionRepository + + Client->>TenantContextInterceptor: POST /api/cloud-resources/{id}/start
(Authorization: Bearer token
X-Tenant-Id: tenantId) + + Note over TenantContextInterceptor: 컨텍스트 선택 (User가 여러 Tenant 중 선택) + TenantContextInterceptor->>SecurityContext: getAuthentication() + SecurityContext-->>TenantContextInterceptor: Authentication (userId) + + TenantContextInterceptor->>TenantRepository: findById(tenantId) + TenantRepository-->>TenantContextInterceptor: Tenant (id, organizationId) + + TenantContextInterceptor->>WorkerRepository: findByUserIdAndTenantId(userId, tenantId) + WorkerRepository-->>TenantContextInterceptor: Worker (id, userId, tenantId) + + alt Worker가 없음 (User가 이 Tenant에 속하지 않음) + TenantContextInterceptor-->>Client: 403 Forbidden
(User is not a member of this tenant) + else Worker가 있음 + Note over TenantContextInterceptor: Tenant 선택 시 Worker 자동 결정
(User가 이 Tenant에 속하면 Worker 자동 조회) + TenantContextInterceptor->>TenantContextHolder: setCurrentTenantAndWorker(tenantId, worker) + TenantContextHolder-->>TenantContextInterceptor: OK + + TenantContextInterceptor-->>Controller: Request (with TenantContext) + end + + Controller->>CloudResourceService: startResource(resourceId) + + CloudResourceService->>TenantContextHolder: getCurrentTenantAndWorker() + TenantContextHolder-->>CloudResourceService: Tenant (id), Worker (id) + + CloudResourceService->>CloudResourceRepository: findById(resourceId) + CloudResourceRepository-->>CloudResourceService: CloudResource (id, tenantId, type, ...) + + CloudResourceService->>PermissionService: can(workerId, tenantId, "START", resource) + + Note over PermissionService: 1단계: Tenant 격리 확인 + PermissionService->>PermissionService: if (resource.tenantId != tenantId) return DENY + + alt Tenant 불일치 + PermissionService-->>CloudResourceService: DENY (Tenant 격리 위반) + CloudResourceService-->>Controller: 403 Forbidden + Controller-->>Client: 403 Forbidden
(Tenant 격리 위반) + else Tenant 일치 + Note over PermissionService: 2단계: RBAC 권한 체크 + PermissionService->>WorkerRoleAssignmentRepository: findByTenantIdAndWorkerId(tenantId, workerId) + WorkerRoleAssignmentRepository-->>PermissionService: List [roleId1, roleId2] + + alt roles가 비어있음 + PermissionService-->>CloudResourceService: DENY (역할 없음) + CloudResourceService-->>Controller: 403 Forbidden + Controller-->>Client: 403 Forbidden
(역할 없음) + else roles 있음 + PermissionService->>RolePermissionRepository: existsByRoleIdsAndResourceTypeAndAction(
roleIds, "CLOUD_RESOURCE", "START") + RolePermissionRepository-->>PermissionService: true/false + + alt RBAC 권한 없음 + PermissionService-->>CloudResourceService: DENY (RBAC 권한 없음) + CloudResourceService-->>Controller: 403 Forbidden + Controller-->>Client: 403 Forbidden
(권한 없음) + else RBAC 권한 있음 + PermissionService-->>CloudResourceService: ALLOW + CloudResourceService->>CloudResourceService: 리소스 시작 로직 실행 + CloudResourceService-->>Controller: Success + Controller-->>Client: 200 OK + end + end + end +``` + +### 권한 체크 흐름 요약 + +1. **Tenant 격리 확인**: `resource.tenantId == tenantId` +2. **RBAC 권한 체크**: + - Worker의 Role 조회 (`WorkerRoleAssignment`) + - Role의 Permission 확인 (`RolePermission`) + - `resourceType`과 `action`에 대한 권한 확인 + +**권한 체크 함수**: `PermissionService.can(workerId, tenantId, action, resource)` + +--- + +## 6. SecurityContext 및 TenantContext 구조 + +```mermaid +classDiagram + class SecurityContext { + +Authentication getAuthentication() + +void setAuthentication(Authentication) + } + + class Authentication { + +Long userId + +String username + +List~String~ authorities + } + + class TenantContextHolder { + +Tenant getCurrentTenant() + +Worker getCurrentWorker() + +void setCurrentTenantAndWorker(Tenant, Worker) + +void clear() + } + + class Tenant { + +Long id + +String tenantKey + +Long organizationId + } + + class Worker { + +Long id + +Long userId + +Long tenantId + +String workerKey + } + + class User { + +Long id + +String username + +String email + } + + SecurityContext --> Authentication + Authentication --> User : "1:1" + TenantContextHolder --> Tenant : "현재 선택된 Tenant" + TenantContextHolder --> Worker : "현재 선택된 Worker" + User --> Worker : "1:N (각 Tenant마다)" + Worker --> Tenant : "N:1" + Tenant --> Organization : "1:1" +``` + +--- + +## 7. 주요 설계 요소 + +### 필요한 컴포넌트 +- ✅ `TenantContextService` - User가 속한 Tenant 목록 조회 및 Tenant 선택 검증 +- ✅ `TenantContextInterceptor` - HTTP 헤더(`X-Tenant-Id`)에서 Tenant 선택 +- ✅ `TenantContextHolder` - 현재 선택된 Tenant/Worker 저장 (ThreadLocal) + +### 흐름 +1. **로그인 시**: User → 모든 Tenant 조회 (1:N) → Client에 Tenant 목록 반환 +2. **요청 시**: Client가 `X-Tenant-Id` 헤더로 Tenant 선택 → 해당 Tenant의 Worker 자동 조회 → `TenantContextHolder`에 저장 +3. **권한 체크**: `TenantContextHolder`에서 `workerId`, `tenantId` 가져와서 `can(workerId, tenantId, action, resource)` 호출 + +### 권한 체크 로직 +```java +function can(workerId, tenantId, action, resource) { + // 0) 리소스의 tenant 확인 + if (resource.tenantId != tenantId) return DENY + + // 1) 이 테넌트에서 worker에게 할당된 role 목록 + roles = WorkerRoleAssignment + .where(tenantId, workerId) + .map(roleId) + + if roles.empty? return DENY + + // 2) role들이 이 resourceType에 대해 action 허용하는지 + return RolePermission.exists( + roleId in roles, + resourceType = resource.type, + action = action + ) +} +``` + +--- + +## 8. 구현 시 주의사항 + +1. **Worker 엔티티 구조** + - `Worker.userId` (FK) - User와 N:1 관계 (한 User가 여러 Tenant에 속할 수 있음) + - `Worker.tenantId` (FK) - Tenant와 N:1 관계 (하나의 Tenant에만 속함) + - `Worker.workerKey` (unique) - Worker 식별자 + +2. **컨텍스트 선택 레이어** + - 로그인 시: User가 속한 모든 Tenant 목록 반환 + - 요청 시: `X-Tenant-Id` 헤더로 Tenant 선택 (필수) + - `TenantContextInterceptor`에서 Tenant 선택 후 해당 Tenant의 Worker 자동 조회 + - `TenantContextHolder`에 Tenant/Worker 저장 + +3. **권한 체크 서비스** + - `PermissionService.can()` 메서드는 `TenantContextHolder`에서 `workerId`, `tenantId`를 가져옴 + - Tenant 선택이 안 되어 있으면 예외 발생 + +4. **Tenant 선택 검증** + - `TenantContextService.validateTenantAccess(userId, tenantId)`로 + - 해당 Tenant에 User의 Worker가 존재하는지 확인 (보안) + - Worker 존재 여부로 User가 해당 Tenant에 속하는지 검증 + +## 9. 예시 시나리오 + +### User가 여러 Tenant에 속한 경우 + +**상황:** +- User A (김연수)가 두 개의 Tenant에 속함 + - Tenant 1 (LG CNS): Worker 1 - `ORG_ADMIN` 역할 + - Tenant 2 (이화여대): Worker 2 - `VIEWER` 역할 + +**흐름:** +1. 로그인 시: User A가 속한 Tenant 목록 반환 `[Tenant 1, Tenant 2]` +2. Tenant 1 리소스 조회 시: `X-Tenant-Id: 1` 헤더로 Tenant 1 선택 → Worker 1 자동 조회 +3. 권한 체크: Worker 1의 `ORG_ADMIN` 역할로 권한 확인 → ALLOW +4. Tenant 2 리소스 조회 시: `X-Tenant-Id: 2` 헤더로 Tenant 2 선택 → Worker 2 자동 조회 +5. 권한 체크: Worker 2의 `VIEWER` 역할로 권한 확인 → ALLOW (read만 가능) + +--- + +## 10. 데이터베이스 스키마 + +### 10.1 DBML 스키마 (dbdiagram.io) + +```dbml +Table organizations { + id bigint [pk] + name varchar [not null] + created_at datetime + updated_at datetime + is_deleted boolean +} + +Table tenants { + id bigint [pk] + tenant_key varchar [unique, not null] + tenant_name varchar [not null] + organization_id bigint [not null, ref: > organizations.id] + status varchar + created_at datetime + updated_at datetime + is_deleted boolean +} + +Table users { + id bigint [pk] + username varchar [unique, not null] + email varchar [unique, not null] + name varchar [not null] + password_hash varchar + status varchar + created_at datetime + updated_at datetime + is_deleted boolean +} + +Table organization_users { + id bigint [pk] + organization_id bigint [not null, ref: > organizations.id] + user_id bigint [not null, ref: > users.id] + org_role varchar "조직 내 역할 (ORG_ADMIN, ORG_MEMBER 등)" + status varchar + created_at datetime + updated_at datetime + is_deleted boolean +} + +Table workers { + id bigint [pk] + worker_key varchar [unique, not null] + worker_name varchar [not null] + tenant_id bigint [not null, ref: > tenants.id] + organization_id bigint [ref: > organizations.id] + user_id bigint [not null, ref: > users.id] + status varchar + created_at datetime + updated_at datetime + is_deleted boolean +} + +Table roles { + id bigint [pk] + role_key varchar [not null] + role_name varchar [not null] + description varchar + tenant_id bigint [not null, ref: > tenants.id] + status varchar + is_system boolean + is_default boolean + priority int + created_at datetime + updated_at datetime + is_deleted boolean +} + +Table permissions { + id bigint [pk] + permission_key varchar [not null] + permission_name varchar [not null] + description varchar + tenant_id bigint [not null, ref: > tenants.id] + resource_type varchar [not null] + action varchar [not null] + status varchar + is_system boolean + category varchar + priority int + created_at datetime + updated_at datetime + is_deleted boolean +} + +Table role_permissions { + id bigint [pk] + role_id bigint [not null, ref: > roles.id] + permission_id bigint [not null, ref: > permissions.id] + created_at datetime + updated_at datetime + is_deleted boolean +} + +Table worker_role_assignments { + id bigint [pk] + tenant_id bigint [not null, ref: > tenants.id] + worker_id bigint [not null, ref: > workers.id] + role_id bigint [not null, ref: > roles.id] + assigned_by varchar + assigned_at datetime + expires_at datetime + created_at datetime + updated_at datetime + is_deleted boolean +} + +Table cloud_resources { + id bigint [pk] + resource_id varchar [unique, not null] + name varchar [not null] + provider varchar [not null] + region varchar [not null] + type varchar [not null] + tenant_id bigint [not null, ref: > tenants.id] + labels jsonb + properties jsonb "쿠버네티스 Spec (선언된 상태/설정)" + status jsonb "쿠버네티스 Status (관측된 상태/런타임 정보)" + created_at datetime + updated_at datetime + is_deleted boolean +} + +Table cloud_resource_worker_map { + id bigint [pk] + cloud_resource_id bigint [not null, ref: > cloud_resources.id] + worker_id bigint [not null, ref: > workers.id] + access_type varchar "CREATOR, ORGANIZATION, GRANTED" + grant_reason varchar + granted_by varchar + expires_at datetime + created_at datetime + updated_at datetime + is_deleted boolean +} +``` + +### 10.2 SQL DDL 스키마 + +```sql +-- Organizations +CREATE TABLE organizations ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by VARCHAR(255), + updated_by VARCHAR(255), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE +); + +-- Tenants +CREATE TABLE tenants ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_key VARCHAR(100) NOT NULL UNIQUE, + tenant_name VARCHAR(255) NOT NULL, + organization_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by VARCHAR(255), + updated_by VARCHAR(255), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT fk_tenant_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) +); + +CREATE INDEX idx_tenants_organization ON tenants(organization_id); +CREATE INDEX idx_tenants_key ON tenants(tenant_key); + +-- Users +CREATE TABLE users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + password_hash VARCHAR(255), + organization_id BIGINT, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by VARCHAR(255), + updated_by VARCHAR(255), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT fk_user_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) +); + +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_organization ON users(organization_id); + +-- Organization Users (조직-사용자 매핑) +CREATE TABLE organization_users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + organization_id BIGINT NOT NULL COMMENT '조직 ID', + user_id BIGINT NOT NULL COMMENT '사용자 ID', + org_role VARCHAR(50) COMMENT '조직 내 역할 (ORG_ADMIN, ORG_MEMBER 등)', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by VARCHAR(255), + updated_by VARCHAR(255), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT fk_ou_organization FOREIGN KEY (organization_id) REFERENCES organizations(id), + CONSTRAINT fk_ou_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT uk_ou_organization_user UNIQUE (organization_id, user_id, is_deleted) +); + +CREATE INDEX idx_ou_organization ON organization_users(organization_id); +CREATE INDEX idx_ou_user ON organization_users(user_id); +CREATE INDEX idx_ou_status ON organization_users(status); + +-- Workers +CREATE TABLE workers ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + worker_key VARCHAR(100) NOT NULL UNIQUE, + worker_name VARCHAR(255) NOT NULL, + tenant_id BIGINT NOT NULL, + organization_id BIGINT, + user_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by VARCHAR(255), + updated_by VARCHAR(255), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT fk_worker_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), + CONSTRAINT fk_worker_organization FOREIGN KEY (organization_id) REFERENCES organizations(id), + CONSTRAINT fk_worker_user FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE INDEX idx_workers_tenant ON workers(tenant_id); +CREATE INDEX idx_workers_user ON workers(user_id); +CREATE INDEX idx_workers_organization ON workers(organization_id); +CREATE INDEX idx_workers_key ON workers(worker_key); +CREATE INDEX idx_workers_user_tenant ON workers(user_id, tenant_id); + +-- Roles +CREATE TABLE roles ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + role_key VARCHAR(100) NOT NULL, + role_name VARCHAR(255) NOT NULL, + description VARCHAR(500), + tenant_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + is_system BOOLEAN NOT NULL DEFAULT FALSE, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + priority INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by VARCHAR(255), + updated_by VARCHAR(255), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT fk_role_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), + CONSTRAINT uk_role_key_tenant UNIQUE (role_key, tenant_id, is_deleted) +); + +CREATE INDEX idx_roles_tenant ON roles(tenant_id); +CREATE INDEX idx_roles_key ON roles(role_key); +CREATE INDEX idx_roles_status ON roles(status); +CREATE INDEX idx_roles_system ON roles(is_system); + +-- Permissions +CREATE TABLE permissions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + permission_key VARCHAR(100) NOT NULL, + permission_name VARCHAR(255) NOT NULL, + description VARCHAR(500), + tenant_id BIGINT NOT NULL, + resource_type VARCHAR(100) NOT NULL, + action VARCHAR(50) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + is_system BOOLEAN NOT NULL DEFAULT FALSE, + category VARCHAR(50), + priority INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by VARCHAR(255), + updated_by VARCHAR(255), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT fk_permission_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), + CONSTRAINT uk_permission_key_tenant UNIQUE (permission_key, tenant_id, is_deleted), + CONSTRAINT uk_permission_resource_action_tenant UNIQUE (resource_type, action, tenant_id, is_deleted) +); + +CREATE INDEX idx_permissions_tenant ON permissions(tenant_id); +CREATE INDEX idx_permissions_resource_type ON permissions(resource_type); +CREATE INDEX idx_permissions_action ON permissions(action); +CREATE INDEX idx_permissions_resource_action ON permissions(resource_type, action); + +-- Role Permissions +CREATE TABLE role_permissions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + role_id BIGINT NOT NULL, + permission_id BIGINT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by VARCHAR(255), + updated_by VARCHAR(255), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT fk_rp_role FOREIGN KEY (role_id) REFERENCES roles(id), + CONSTRAINT fk_rp_permission FOREIGN KEY (permission_id) REFERENCES permissions(id), + CONSTRAINT uk_rp_role_permission UNIQUE (role_id, permission_id, is_deleted) +); + +CREATE INDEX idx_rp_role ON role_permissions(role_id); +CREATE INDEX idx_rp_permission ON role_permissions(permission_id); + +-- Worker Role Assignments +CREATE TABLE worker_role_assignments ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + worker_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + assigned_by VARCHAR(255), + assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by VARCHAR(255), + updated_by VARCHAR(255), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT fk_wra_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), + CONSTRAINT fk_wra_worker FOREIGN KEY (worker_id) REFERENCES workers(id), + CONSTRAINT fk_wra_role FOREIGN KEY (role_id) REFERENCES roles(id), + CONSTRAINT uk_wra_tenant_worker_role UNIQUE (tenant_id, worker_id, role_id, is_deleted) +); + +CREATE INDEX idx_wra_tenant ON worker_role_assignments(tenant_id); +CREATE INDEX idx_wra_worker ON worker_role_assignments(worker_id); +CREATE INDEX idx_wra_role ON worker_role_assignments(role_id); +CREATE INDEX idx_wra_tenant_worker ON worker_role_assignments(tenant_id, worker_id); +CREATE INDEX idx_wra_expires ON worker_role_assignments(expires_at); + +-- Cloud Resources (쿠버네티스 스타일: Generic Spec/Status JSON) +CREATE TABLE cloud_resources ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + resource_id VARCHAR(255) NOT NULL UNIQUE COMMENT '리소스 ID (CSP에서 부여한 ID)', + name VARCHAR(255) NOT NULL COMMENT '리소스 이름', + provider VARCHAR(50) NOT NULL COMMENT '클라우드 프로바이더 (AWS, GCP, Azure 등)', + region VARCHAR(50) NOT NULL COMMENT '리전', + type VARCHAR(50) NOT NULL COMMENT '리소스 타입 (INSTANCE, CLUSTER, BUCKET 등)', + tenant_id BIGINT NOT NULL COMMENT '테넌트 ID (리소스 소유 테넌트)', + labels JSON COMMENT '태그/라벨 (JSON Map)', + properties JSON COMMENT '쿠버네티스 Spec - 선언된 상태/설정 (리소스 타입별 설정 정보)', + status JSON COMMENT '쿠버네티스 Status - 관측된 상태/런타임 정보 (일반적인 상태 + 상세 정보)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by VARCHAR(255), + updated_by VARCHAR(255), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT fk_resource_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) +); + +CREATE INDEX idx_resource_id ON cloud_resources(resource_id); +CREATE INDEX idx_resource_tenant ON cloud_resources(tenant_id); +CREATE INDEX idx_resource_type ON cloud_resources(type); +CREATE INDEX idx_resource_tenant_type ON cloud_resources(tenant_id, type); + +-- Cloud Resource Worker Map (리소스-워커 접근 권한 매핑) +CREATE TABLE cloud_resource_worker_map ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + cloud_resource_id BIGINT NOT NULL COMMENT '클라우드 리소스 ID', + worker_id BIGINT NOT NULL COMMENT '워커 ID', + access_type VARCHAR(50) NOT NULL COMMENT '접근 타입 (CREATOR, ORGANIZATION, GRANTED)', + grant_reason VARCHAR(255) COMMENT '권한 부여 사유', + granted_by VARCHAR(255) COMMENT '권한 부여자 (Worker ID 또는 User ID)', + expires_at DATETIME COMMENT '권한 만료 시각 (선택)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by VARCHAR(255), + updated_by VARCHAR(255), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT fk_crwm_resource FOREIGN KEY (cloud_resource_id) REFERENCES cloud_resources(id), + CONSTRAINT fk_crwm_worker FOREIGN KEY (worker_id) REFERENCES workers(id), + CONSTRAINT uk_crwm_resource_worker UNIQUE (cloud_resource_id, worker_id, is_deleted) +); + +CREATE INDEX idx_crwm_resource ON cloud_resource_worker_map(cloud_resource_id); +CREATE INDEX idx_crwm_worker ON cloud_resource_worker_map(worker_id); +CREATE INDEX idx_crwm_access_type ON cloud_resource_worker_map(access_type); +CREATE INDEX idx_crwm_expires ON cloud_resource_worker_map(expires_at); +CREATE INDEX idx_crwm_worker_deleted ON cloud_resource_worker_map(worker_id, is_deleted); +``` + +### 10.3 스키마 관계 요약 + +**핵심 관계:** +- `User ↔ Worker (1:N)`: 한 User가 여러 Worker를 가질 수 있음 +- `Worker → Tenant (N:1)`: Worker는 하나의 Tenant에만 속함 +- `Tenant ↔ Organization (1:1)`: Tenant와 Organization은 1:1 관계 +- `Organization ↔ User (M:N)`: `organization_users` 테이블로 매핑 (조직 멤버십) + +**RBAC 관계:** +- `Role → Tenant (N:1)`: Role은 Tenant에 속함 +- `Permission → Tenant (N:1)`: Permission은 Tenant에 속함 +- `Role ↔ Permission (M:N)`: `role_permissions` 테이블로 매핑 +- `Worker ↔ Role (M:N)`: `worker_role_assignments` 테이블로 매핑 (Tenant 스코프 포함) + +**리소스 접근 관계:** +- `CloudResource → Tenant (N:1)`: 리소스는 Tenant에 속함 +- `CloudResource ↔ Worker (M:N)`: `cloud_resource_worker_map` 테이블로 매핑 (쿼리 레벨 필터링용) + +**권한 체크 흐름:** +1. `worker_role_assignments`에서 Worker의 Role 조회 +2. `role_permissions`에서 Role의 Permission 조회 +3. `permissions`에서 `resourceType`과 `action` 확인 + +### 10.4 Cloud Resources 스키마 설명 + +**쿠버네티스 스타일 (Generic Spec/Status JSON) 설계:** + +**properties (JSON) - 쿠버네티스 Spec:** +리소스 타입별 설정 정보를 저장합니다. 예시: +```json +// VM 인스턴스 +{ + "cpuCores": 4, + "memoryGb": 8, + "instanceType": "t3.medium", + "storageGb": 100 +} + +// VPC +{ + "cidrBlock": "10.0.0.0/16", + "enableDnsHostnames": true, + "enableDnsSupport": true +} + +// Storage Bucket +{ + "storageClass": "STANDARD", + "versioning": true, + "encryption": "AES256" +} +``` + +**status (JSON) - 쿠버네티스 Status:** +런타임 상태 및 관측된 정보를 저장합니다. 예시: +```json +// VM 인스턴스 +{ + "state": "running", + "ipAddress": "10.0.0.1", + "publicIpAddress": "1.2.3.4", + "costPerHour": 0.05, + "monthlyCost": 36.0 +} + +// VPC +{ + "state": "available", + "subnetCount": 3, + "routeTableCount": 1 +} +``` + +**장점:** +- ✅ 최대 유연성: 스키마 변경 없이 어떤 리소스 유형이든 저장 가능 +- ✅ 헥사고날 아키텍처 적합: 도메인 객체는 ComputeProperties, StorageProperties와 같은 강력한 타입을 정의하고, 어댑터가 엔티티의 JSON을 도메인 모델로 매핑 +- ✅ 성능: 단일 테이블이며 복잡한 조인이 없음 + +**단점:** +- ⚠️ 쿼리: "CPU가 4개 이상인 모든 리소스 찾기"와 같은 쿼리는 DB가 JSON 인덱싱을 지원하지 않는 한 어려움 +- ⚠️ 타입 안정성: 파싱 로직이 애플리케이션 계층으로 이동 + +### 10.5 Cloud Resource Worker Map 설명 + +**역할:** +- **쿼리 레벨 필터링**: 리소스 조회 시 이 테이블을 JOIN하여 접근 가능한 리소스만 반환 +- **매번 권한 검증 불필요**: 조회된 리소스는 이미 접근 가능한 것으로 보장 +- **접근 타입**: + - `CREATOR`: 리소스를 생성한 Worker (DEDICATED 격리 레벨) + - `ORGANIZATION`: 조직 내 모든 Worker (SHARED 격리 레벨) + - `GRANTED`: 명시적으로 권한 부여 + diff --git a/src/main/java/com/agenticcp/core/common/config/WebMvcConfig.java b/src/main/java/com/agenticcp/core/common/config/WebMvcConfig.java new file mode 100644 index 00000000..ee132abb --- /dev/null +++ b/src/main/java/com/agenticcp/core/common/config/WebMvcConfig.java @@ -0,0 +1,36 @@ +package com.agenticcp.core.common.config; + +import com.agenticcp.core.common.context.TenantContextInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web MVC 설정 + * Interceptor 등록 + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-01-XX + */ +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final TenantContextInterceptor tenantContextInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(tenantContextInterceptor) + .addPathPatterns("/**") + .excludePathPatterns( + "/health", + "/auth/**", + "/swagger-ui/**", + "/v3/api-docs/**", + "/actuator/**" + ); + } +} + diff --git a/src/main/java/com/agenticcp/core/common/context/TenantContextHolder.java b/src/main/java/com/agenticcp/core/common/context/TenantContextHolder.java index ff79785c..f1f8d7e1 100644 --- a/src/main/java/com/agenticcp/core/common/context/TenantContextHolder.java +++ b/src/main/java/com/agenticcp/core/common/context/TenantContextHolder.java @@ -3,14 +3,15 @@ import com.agenticcp.core.common.enums.CommonErrorCode; import com.agenticcp.core.common.exception.BusinessException; import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.user.entity.Worker; import lombok.extern.slf4j.Slf4j; /** * 테넌트 컨텍스트를 ThreadLocal로 관리하는 클래스 - * 현재 요청의 테넌트 정보를 저장하고 조회할 수 있도록 함 + * 현재 요청의 테넌트 및 Worker 정보를 저장하고 조회할 수 있도록 함 * * @author AgenticCP Team - * @version 1.0.0 + * @version 2.0.0 * @since 2025-09-22 */ @Slf4j @@ -18,6 +19,7 @@ public class TenantContextHolder { private static final ThreadLocal TENANT_CONTEXT = new ThreadLocal<>(); private static final ThreadLocal TENANT_KEY_CONTEXT = new ThreadLocal<>(); + private static final ThreadLocal WORKER_CONTEXT = new ThreadLocal<>(); /** * 현재 스레드에 테넌트 정보를 설정 @@ -88,13 +90,79 @@ public static Tenant getCurrentTenantOrThrow() { return tenant; } + /** + * 현재 스레드에 Tenant와 Worker를 함께 설정 + * + * @param tenant 설정할 테넌트 객체 + * @param worker 설정할 Worker 객체 + */ + public static void setCurrentTenantAndWorker(Tenant tenant, Worker worker) { + if (tenant != null) { + TENANT_CONTEXT.set(tenant); + TENANT_KEY_CONTEXT.set(tenant.getTenantKey()); + log.debug("Tenant context set: {}", tenant.getTenantKey()); + } + if (worker != null) { + WORKER_CONTEXT.set(worker); + log.debug("Worker context set: {} (userId: {}, tenantId: {})", + worker.getWorkerKey(), worker.getUser().getId(), worker.getTenant().getId()); + } + } + + /** + * 현재 스레드의 Worker 정보를 조회 + * + * @return 현재 Worker 객체, 없으면 null + */ + public static Worker getCurrentWorker() { + return WORKER_CONTEXT.get(); + } + + /** + * 현재 스레드의 Worker 정보를 조회 (null 체크 포함) + * + * @return 현재 Worker 객체 + * @throws IllegalStateException Worker 컨텍스트가 설정되지 않은 경우 + */ + public static Worker getCurrentWorkerOrThrow() { + Worker worker = getCurrentWorker(); + if (worker == null) { + throw new BusinessException(CommonErrorCode.TENANT_CONTEXT_NOT_SET); + } + return worker; + } + + /** + * 현재 스레드의 Tenant와 Worker를 함께 조회 + * + * @return Tenant와 Worker를 담은 객체 (TenantWorkerContext) + */ + public static TenantWorkerContext getCurrentTenantAndWorker() { + Tenant tenant = getCurrentTenant(); + Worker worker = getCurrentWorker(); + return new TenantWorkerContext(tenant, worker); + } + + /** + * 현재 스레드의 Tenant와 Worker를 함께 조회 (null 체크 포함) + * + * @return Tenant와 Worker를 담은 객체 (TenantWorkerContext) + * @throws IllegalStateException Tenant 또는 Worker 컨텍스트가 설정되지 않은 경우 + */ + public static TenantWorkerContext getCurrentTenantAndWorkerOrThrow() { + Tenant tenant = getCurrentTenantOrThrow(); + Worker worker = getCurrentWorkerOrThrow(); + return new TenantWorkerContext(tenant, worker); + } + /** * 현재 스레드의 테넌트 컨텍스트를 초기화 */ public static void clear() { TENANT_CONTEXT.remove(); TENANT_KEY_CONTEXT.remove(); - log.debug("Tenant context cleared"); + WORKER_CONTEXT.remove(); + log.debug("Tenant and Worker context cleared"); } /** @@ -105,4 +173,33 @@ public static void clear() { public static boolean hasTenantContext() { return getCurrentTenantKey() != null; } + + /** + * Tenant와 Worker를 함께 담는 컨텍스트 객체 + */ + public static class TenantWorkerContext { + private final Tenant tenant; + private final Worker worker; + + public TenantWorkerContext(Tenant tenant, Worker worker) { + this.tenant = tenant; + this.worker = worker; + } + + public Tenant getTenant() { + return tenant; + } + + public Worker getWorker() { + return worker; + } + + public Long getTenantId() { + return tenant != null ? tenant.getId() : null; + } + + public Long getWorkerId() { + return worker != null ? worker.getId() : null; + } + } } diff --git a/src/main/java/com/agenticcp/core/common/context/TenantContextInterceptor.java b/src/main/java/com/agenticcp/core/common/context/TenantContextInterceptor.java new file mode 100644 index 00000000..55e007a2 --- /dev/null +++ b/src/main/java/com/agenticcp/core/common/context/TenantContextInterceptor.java @@ -0,0 +1,124 @@ +package com.agenticcp.core.common.context; + +import com.agenticcp.core.common.enums.CommonErrorCode; +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.user.entity.User; +import com.agenticcp.core.domain.user.entity.Worker; +import com.agenticcp.core.domain.user.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * Tenant Context Interceptor + * + *

HTTP 헤더(`X-Tenant-Id`)에서 Tenant를 선택하고, + * 해당 Tenant의 Worker를 자동 조회하여 TenantContextHolder에 저장합니다.

+ * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-01-XX + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class TenantContextInterceptor implements HandlerInterceptor { + + private final TenantContextService tenantContextService; + private final UserRepository userRepository; + + private static final String TENANT_ID_HEADER = "X-Tenant-Id"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String requestURI = request.getRequestURI(); + + // 인증이 필요한 경로인지 확인 (인증이 필요 없는 경로는 스킵) + if (shouldSkip(requestURI)) { + return true; + } + + // SecurityContext에서 User ID 가져오기 + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + log.debug("No authentication found for request: {}", requestURI); + return true; // 인증이 없으면 스킵 (다른 필터에서 처리) + } + + Long userId = extractUserId(authentication); + if (userId == null) { + log.debug("No user ID found in authentication for request: {}", requestURI); + return true; + } + + // X-Tenant-Id 헤더에서 Tenant ID 추출 + String tenantIdHeader = request.getHeader(TENANT_ID_HEADER); + if (tenantIdHeader == null || tenantIdHeader.trim().isEmpty()) { + log.warn("Tenant ID header ({}) is required but not found in request: {}", TENANT_ID_HEADER, requestURI); + throw new BusinessException(CommonErrorCode.BAD_REQUEST, "Tenant ID is required"); + } + + try { + Long tenantId = Long.parseLong(tenantIdHeader.trim()); + + // Tenant 선택 검증 및 Worker 조회 + Worker worker = tenantContextService.validateTenantAccessOrThrow(userId, tenantId); + Tenant tenant = worker.getTenant(); + + // TenantContextHolder에 Tenant와 Worker 저장 + TenantContextHolder.setCurrentTenantAndWorker(tenant, worker); + + log.debug("Tenant context set for request: {} -> tenantId={}, workerId={}", + requestURI, tenantId, worker.getId()); + + return true; + + } catch (NumberFormatException e) { + log.warn("Invalid tenant ID format in header: {}", tenantIdHeader); + throw new BusinessException(CommonErrorCode.BAD_REQUEST, "Invalid tenant ID format"); + } + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, + Object handler, Exception ex) { + // 요청 처리 완료 후 컨텍스트 정리 + TenantContextHolder.clear(); + } + + /** + * 인증이 필요 없는 경로인지 확인 + * + * @param requestURI 요청 URI + * @return 스킵 여부 + */ + private boolean shouldSkip(String requestURI) { + return requestURI.startsWith("/health") || + requestURI.startsWith("/auth") || + requestURI.startsWith("/swagger") || + requestURI.startsWith("/v3/api-docs") || + requestURI.startsWith("/actuator"); + } + + /** + * Authentication에서 User ID 추출 + * + * @param authentication Authentication 객체 + * @return User ID (없으면 null) + */ + private Long extractUserId(Authentication authentication) { + String username = authentication.getName(); + + // username으로 User 조회 + return userRepository.findByUsername(username) + .map(User::getId) + .orElse(null); + } +} + diff --git a/src/main/java/com/agenticcp/core/common/context/TenantContextService.java b/src/main/java/com/agenticcp/core/common/context/TenantContextService.java new file mode 100644 index 00000000..4b5bd1d7 --- /dev/null +++ b/src/main/java/com/agenticcp/core/common/context/TenantContextService.java @@ -0,0 +1,99 @@ +package com.agenticcp.core.common.context; + +import com.agenticcp.core.common.enums.CommonErrorCode; +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.user.entity.Worker; +import com.agenticcp.core.domain.user.repository.WorkerRepository; +import com.agenticcp.core.domain.tenant.repository.TenantRepository; +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; + +/** + * Tenant Context Service + * + *

User가 속한 Tenant 목록 조회 및 Tenant 선택 검증을 담당합니다.

+ * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-01-XX + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TenantContextService { + + private final WorkerRepository workerRepository; + private final TenantRepository tenantRepository; + + /** + * User가 속한 모든 Tenant 목록 조회 + * + * @param userId User ID + * @return Tenant ID 목록 + */ + @Transactional(readOnly = true) + public List getAvailableTenantIds(Long userId) { + log.debug("Getting available tenant IDs for user: {}", userId); + List tenantIds = workerRepository.findTenantIdsByUserId(userId); + log.debug("Found {} tenants for user: {}", tenantIds.size(), userId); + return tenantIds; + } + + /** + * User가 특정 Tenant에 속하는지 검증하고 Worker를 반환 + * + * @param userId User ID + * @param tenantId Tenant ID + * @return Worker (Optional) + */ + @Transactional(readOnly = true) + public Optional validateTenantAccess(Long userId, Long tenantId) { + log.debug("Validating tenant access: userId={}, tenantId={}", userId, tenantId); + + // Tenant 존재 확인 + Optional tenantOpt = tenantRepository.findById(tenantId); + if (tenantOpt.isEmpty()) { + log.warn("Tenant not found: {}", tenantId); + return Optional.empty(); + } + + Tenant tenant = tenantOpt.get(); + if (tenant.getIsDeleted()) { + log.warn("Tenant is deleted: {}", tenantId); + return Optional.empty(); + } + + // User가 이 Tenant에 속하는지 확인 (Worker 존재 여부로 확인) + Optional workerOpt = workerRepository.findByUserIdAndTenantIdAndIsDeletedFalse(userId, tenantId); + if (workerOpt.isEmpty()) { + log.warn("User {} is not a member of tenant {}", userId, tenantId); + return Optional.empty(); + } + + log.debug("Tenant access validated: userId={}, tenantId={}, workerId={}", + userId, tenantId, workerOpt.get().getId()); + return workerOpt; + } + + /** + * User가 특정 Tenant에 속하는지 검증하고 Worker를 반환 (예외 발생) + * + * @param userId User ID + * @param tenantId Tenant ID + * @return Worker + * @throws BusinessException User가 해당 Tenant에 속하지 않는 경우 + */ + @Transactional(readOnly = true) + public Worker validateTenantAccessOrThrow(Long userId, Long tenantId) { + return validateTenantAccess(userId, tenantId) + .orElseThrow(() -> new BusinessException(CommonErrorCode.FORBIDDEN, + "User is not a member of this tenant")); + } +} + diff --git a/src/main/java/com/agenticcp/core/common/dto/auth/TokenResponse.java b/src/main/java/com/agenticcp/core/common/dto/auth/TokenResponse.java index 2cf8a4d7..a9534d6b 100644 --- a/src/main/java/com/agenticcp/core/common/dto/auth/TokenResponse.java +++ b/src/main/java/com/agenticcp/core/common/dto/auth/TokenResponse.java @@ -5,6 +5,8 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.util.List; + /** * 토큰 응답 DTO * @@ -23,4 +25,9 @@ public class TokenResponse { private String tokenType; private Long expiresIn; private Long refreshExpiresIn; + + /** + * 사용자가 속한 Tenant ID 목록 (User ↔ Worker 1:N 시나리오) + */ + private List availableTenants; } diff --git a/src/main/java/com/agenticcp/core/common/service/AuthenticationService.java b/src/main/java/com/agenticcp/core/common/service/AuthenticationService.java index e123aba4..9c444fe3 100644 --- a/src/main/java/com/agenticcp/core/common/service/AuthenticationService.java +++ b/src/main/java/com/agenticcp/core/common/service/AuthenticationService.java @@ -1,5 +1,6 @@ package com.agenticcp.core.common.service; +import com.agenticcp.core.common.context.TenantContextService; import com.agenticcp.core.common.dto.auth.LoginRequest; import com.agenticcp.core.common.dto.auth.RefreshTokenRequest; import com.agenticcp.core.common.dto.auth.RegisterRequest; @@ -48,6 +49,7 @@ public class AuthenticationService { private final UserService userService; + private final TenantContextService tenantContextService; private final JwtService jwtService; private final PasswordEncoder passwordEncoder; private final TenantService tenantService; @@ -231,7 +233,12 @@ public TokenResponse login(LoginRequest loginRequest, HttpServletRequest httpReq // 토큰 생성 TokenResponse tokenResponse = generateTokens(user.getUsername()); - log.info("[AuthenticationService] login - success username={}", loginRequest.getUsername()); + // User가 속한 Tenant 목록 조회 (User ↔ Worker 1:N 시나리오) + List availableTenants = tenantContextService.getAvailableTenantIds(user.getId()); + tokenResponse.setAvailableTenants(availableTenants); + + log.info("[AuthenticationService] login - success username={}, availableTenants={}", + loginRequest.getUsername(), availableTenants.size()); return tokenResponse; diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/AwsResourceMapper.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/AwsResourceMapper.java index d32516b6..b8337f76 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/AwsResourceMapper.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/AwsResourceMapper.java @@ -9,7 +9,10 @@ public CloudResource toCloudResource(Object awsDescribeInstanceResult) { // Canonical 매핑 스켈레톤: 필수만 매핑, 나머지는 configuration/metadata에 보존 return CloudResource.builder() .resourceId("i-unknown") - .resourceName("unknown") + .name("unknown") + .provider("AWS") + .region("us-east-1") + .type("INSTANCE") .build(); } } diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketDiscoveryAdapter.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketDiscoveryAdapter.java index 25150cdc..dc933c41 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketDiscoveryAdapter.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketDiscoveryAdapter.java @@ -28,6 +28,7 @@ import software.amazon.awssdk.services.resourcegroupstaggingapi.model.TagFilter; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.*; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.*; import java.util.function.Function; @@ -51,6 +52,7 @@ public class AwsS3BucketDiscoveryAdapter implements ObjectStorageDiscoveryPort, private final AccountCredentialManagementPort accountCredentialManagementPort; private final AwsS3Config awsS3Config; private final AwsS3ErrorTranslator errorTranslator; + private final ObjectMapper objectMapper; /** * AWS S3 버킷 목록을 조회합니다. @@ -218,7 +220,8 @@ private void enrichResourceDetails(S3Client client, String bucketName, CloudReso if (regionId == null || regionId.isEmpty()) { regionId = "us-east-1"; } - resource.setRegion(mapper.toCloudRegion(regionId)); + // region은 String으로 저장되므로 직접 설정 + resource.setRegion(regionId); } catch (S3Exception e) { log.warn("Could not retrieve location for bucket {}: {}", bucketName, e.getMessage()); } @@ -234,12 +237,18 @@ private void enrichResourceDetails(S3Client client, String bucketName, CloudReso } else { log.warn("Could not retrieve tags for bucket {}: {}", bucketName, e.getMessage()); } - resource.setTags(null); + // tags는 labels 필드에 JSON으로 저장되므로 null 처리 불필요 } } private void setTags(CloudResource resource, Map tags) { - resource.setTags(tags); + // tags는 labels 필드에 JSON으로 저장 + try { + String labelsJson = objectMapper.writeValueAsString(tags); + resource.setLabels(labelsJson); + } catch (Exception e) { + log.warn("Failed to serialize tags to labels JSON: {}", e.getMessage()); + } } private Page applyMemoryOperations(List resources, @@ -247,7 +256,7 @@ private Page applyMemoryOperations(List resources, List filtered = resources; if (query.getNameContains() != null && !query.getNameContains().isEmpty()) { filtered = filtered.stream() - .filter(resource -> resource.getResourceName().contains(query.getNameContains())) + .filter(resource -> resource.getName() != null && resource.getName().contains(query.getNameContains())) .collect(Collectors.toList()); } @@ -276,10 +285,10 @@ private void applySorting(List buckets, String sortBy, String sor Comparator comparator; if ("name".equalsIgnoreCase(sortBy)) { - comparator = Comparator.comparing(CloudResource::getResourceName); + comparator = Comparator.comparing(CloudResource::getName); } else { log.warn("Unsupported sort key: {}. Defaulting to name sort.", sortBy); - comparator = Comparator.comparing(CloudResource::getResourceName); + comparator = Comparator.comparing(CloudResource::getName); } if ("desc".equalsIgnoreCase(sortDirection)) { diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketMapper.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketMapper.java index c10f4631..68e60bda 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketMapper.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketMapper.java @@ -63,22 +63,56 @@ public CloudResource toCloudResource(Bucket bucket, // 입력 검증 validateBucketInput(bucket, provider); + // 쿠버네티스 스타일: properties (Spec) 구성 + Map properties = new HashMap<>(); + properties.put("instanceType", "S3_BUCKET"); + properties.put("instanceSize", "STANDARD"); + properties.put("storageGb", 0L); // S3 버킷은 스토리지 용량이 동적이므로 0으로 설정 + properties.put("bucketName", bucket.name()); + if (bucket.creationDate() != null) { + properties.put("creationDate", bucket.creationDate().toString()); + } + + // 버전 관리 설정 + if (versioningStatus != null) { + properties.put("versioning", Map.of( + "status", versioningStatus.status() != null ? versioningStatus.status().toString() : "Disabled", + "mfaDelete", versioningStatus.mfaDelete() != null ? versioningStatus.mfaDelete().toString() : "Disabled" + )); + } + + // 라이프사이클 설정 + if (lifecycleConfig != null && lifecycleConfig.rules() != null) { + properties.put("lifecycle", Map.of( + "rulesCount", lifecycleConfig.rules().size(), + "hasRules", !lifecycleConfig.rules().isEmpty() + )); + } + + // 쿠버네티스 스타일: status (Status) 구성 + Map status = new HashMap<>(); + status.put("state", "running"); + if (bucket.creationDate() != null) { + LocalDateTime createdDate = toLocalDateTime(bucket.creationDate()); + status.put("createdInCloud", createdDate != null ? createdDate.toString() : null); + status.put("lastModifiedInCloud", createdDate != null ? createdDate.toString() : null); + } + status.put("lastSync", LocalDateTime.now().toString()); + status.put("serviceType", "S3"); + status.put("storageClass", "STANDARD"); + + // 쿠버네티스 스타일: labels (태그) + Map labelsMap = toTagMap(tags); + return CloudResource.builder() .resourceId(bucket.name()) - .resourceName(bucket.name()) - .displayName(bucket.name()) - .provider(provider) - .resourceType(CloudResource.ResourceType.BUCKET) - .lifecycleState(CloudResource.LifecycleState.RUNNING) - .instanceType("S3_BUCKET") - .instanceSize("STANDARD") - .storageGb(0L) // S3 버킷은 스토리지 용량이 동적이므로 0으로 설정 - .tags(toTagMap(tags)) - .configuration(buildConfigurationJson(bucket, versioningStatus, lifecycleConfig)) - .createdInCloud(toLocalDateTime(bucket.creationDate())) - .lastModifiedInCloud(toLocalDateTime(bucket.creationDate())) - .lastSync(LocalDateTime.now()) - .metadata(buildMetadata(bucket, versioningStatus, lifecycleConfig, tags)) + .name(bucket.name()) + .provider(provider.getProviderKey()) + .region("us-east-1") // S3는 글로벌 서비스이지만 기본 리전 사용 + .type("BUCKET") + .properties(toJson(properties)) + .status(toJson(status)) + .labels(toJson(labelsMap)) .build(); } catch (Exception e) { log.error("Failed to convert AWS S3 bucket to CloudResource: {}", bucket.name(), e); @@ -165,107 +199,22 @@ public List toCloudResources(ListBucketsResponse listBucketsRespo } } - /** - * 버킷 설정 정보를 JSON 문자열로 구성 - * - * @param bucket AWS S3 버킷 - * @param versioningStatus 버전 관리 상태 - * @param lifecycleConfig 라이프사이클 설정 - * @return 설정 JSON 문자열 - */ - private String buildConfigurationJson(Bucket bucket, - GetBucketVersioningResponse versioningStatus, - BucketLifecycleConfiguration lifecycleConfig) { - - try { - Map config = new HashMap<>(); - - // 기본 버킷 설정 - config.put("bucketName", bucket.name()); - config.put("creationDate", bucket.creationDate()); - - // 버전 관리 설정 - if (versioningStatus != null) { - config.put("versioning", Map.of( - "status", versioningStatus.status() != null ? versioningStatus.status().toString() : "Disabled", - "mfaDelete", versioningStatus.mfaDelete() != null ? versioningStatus.mfaDelete().toString() : "Disabled" - )); - } - - // 라이프사이클 설정 - if (lifecycleConfig != null && lifecycleConfig.rules() != null) { - config.put("lifecycle", Map.of( - "rulesCount", lifecycleConfig.rules().size(), - "hasRules", !lifecycleConfig.rules().isEmpty() - )); - } - - return objectMapper.writeValueAsString(config); - } catch (Exception e) { - log.warn("Failed to serialize configuration for bucket: {}", bucket.name(), e); - throw new BusinessException(CloudErrorCode.CLOUD_METADATA_SERIALIZATION_FAILED, - "S3 버킷 설정 직렬화에 실패했습니다: " + bucket.name()); - } - } /** - * 버킷의 메타데이터를 JSON 문자열로 구성 + * Map을 JSON 문자열로 변환합니다. * - * @param bucket AWS S3 버킷 - * @param versioningStatus 버전 관리 상태 - * @param lifecycleConfig 라이프사이클 설정 - * @param tags 태그 정보 - * @return 메타데이터 JSON 문자열 + * @param map 변환할 Map + * @return JSON 문자열 (실패 시 null) */ - private String buildMetadata(Bucket bucket, - GetBucketVersioningResponse versioningStatus, - BucketLifecycleConfiguration lifecycleConfig, - List tags) { - + private String toJson(Map map) { + if (map == null || map.isEmpty()) { + return null; + } try { - Map metadata = new HashMap<>(); - - // 기본 버킷 정보 - metadata.put("bucketName", bucket.name()); - metadata.put("creationDate", bucket.creationDate() != null ? - bucket.creationDate().atZone(ZoneId.systemDefault()).toLocalDateTime() : null); - - // 버전 관리 정보 - if (versioningStatus != null) { - metadata.put("versioningStatus", versioningStatus.status() != null ? - versioningStatus.status().toString() : "Disabled"); - metadata.put("mfaDelete", versioningStatus.mfaDelete() != null ? - versioningStatus.mfaDelete().toString() : "Disabled"); - } - - // 라이프사이클 설정 정보 - if (lifecycleConfig != null && lifecycleConfig.rules() != null) { - metadata.put("lifecycleRules", lifecycleConfig.rules().size()); - metadata.put("hasLifecycleRules", !lifecycleConfig.rules().isEmpty()); - } - - // 태그 정보 - if (tags != null && !tags.isEmpty()) { - Map tagMap = tags.stream() - .collect(Collectors.toMap( - Tag::key, - Tag::value, - (existing, replacement) -> replacement - )); - metadata.put("tags", tagMap); - metadata.put("tagCount", tags.size()); - } - - // S3 특화 정보 - metadata.put("serviceType", "S3"); - metadata.put("storageClass", "STANDARD"); - metadata.put("encryptionEnabled", false); // 기본값, 실제로는 버킷 암호화 설정 확인 필요 - - return objectMapper.writeValueAsString(metadata); + return objectMapper.writeValueAsString(map); } catch (Exception e) { - log.warn("Failed to serialize metadata for bucket: {}", bucket.name(), e); - throw new BusinessException(CloudErrorCode.CLOUD_METADATA_SERIALIZATION_FAILED, - "S3 버킷 메타데이터 직렬화에 실패했습니다: " + bucket.name()); + log.warn("[AwsS3BucketMapper] Failed to serialize to JSON: {}", e.getMessage()); + return null; } } diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/vm/AwsVmMapper.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/vm/AwsVmMapper.java index e1fe08f5..1b2b585a 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/vm/AwsVmMapper.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/vm/AwsVmMapper.java @@ -47,19 +47,30 @@ public CloudResource toCloudResource(Instance awsInstance) { try { log.debug("[AwsVmMapper] Converting AWS Instance to CloudResource: {}", awsInstance.instanceId()); + // 쿠버네티스 스타일: properties (Spec) 구성 + Map properties = new HashMap<>(); + properties.put("instanceType", awsInstance.instanceTypeAsString()); + properties.put("cpuCores", getCpuCores(awsInstance.instanceTypeAsString())); + properties.put("memoryGb", getMemoryGb(awsInstance.instanceTypeAsString())); + properties.put("architecture", awsInstance.architectureAsString()); + properties.put("platform", awsInstance.platformAsString()); + + // 쿠버네티스 스타일: status (Status) 구성 + Map status = new HashMap<>(); + status.put("state", awsInstance.state().nameAsString().toLowerCase()); + status.put("publicIpAddress", awsInstance.publicIpAddress()); + status.put("privateIpAddress", awsInstance.privateIpAddress()); + status.put("launchTime", awsInstance.launchTime() != null ? awsInstance.launchTime().toString() : null); + return CloudResource.builder() .resourceId(awsInstance.instanceId()) - .resourceName(getInstanceName(awsInstance)) - .displayName(getInstanceName(awsInstance)) - .lifecycleState(mapInstanceState(awsInstance.state().nameAsString())) - .instanceType(awsInstance.instanceTypeAsString()) - .cpuCores(getCpuCores(awsInstance.instanceTypeAsString())) - .memoryGb(getMemoryGb(awsInstance.instanceTypeAsString())) - .publicIpAddress(awsInstance.publicIpAddress()) - .privateIpAddress(awsInstance.privateIpAddress()) - .tags(mapTags(awsInstance.tags())) - .configuration(toJson(awsInstance)) - .metadata(buildMetadata(awsInstance)) + .name(getInstanceName(awsInstance)) + .provider("AWS") + .region(awsInstance.placement() != null ? awsInstance.placement().availabilityZone() : null) + .type("INSTANCE") + .properties(toJson(properties)) + .status(toJson(status)) + .labels(toJson(mapTags(awsInstance.tags()))) .build(); } catch (Exception e) { @@ -268,18 +279,13 @@ private String getInstanceName(Instance awsInstance) { } /** - * AWS Instance 상태를 도메인 LifecycleState로 매핑합니다. + * AWS Instance 상태를 JSON status로 매핑합니다. + * 쿠버네티스 스타일: status 필드에 JSON으로 저장됩니다. */ - private CloudResource.LifecycleState mapInstanceState(String awsState) { - return switch (awsState.toLowerCase()) { - case "running" -> CloudResource.LifecycleState.RUNNING; - case "stopped" -> CloudResource.LifecycleState.STOPPED; - case "stopping" -> CloudResource.LifecycleState.STOPPING; - case "pending" -> CloudResource.LifecycleState.PENDING; - case "terminated" -> CloudResource.LifecycleState.TERMINATED; - case "terminating" -> CloudResource.LifecycleState.TERMINATING; - default -> CloudResource.LifecycleState.UNKNOWN; - }; + private String mapInstanceStateToJson(String awsState) { + Map status = new HashMap<>(); + status.put("state", awsState.toLowerCase()); + return toJson(status); } /** diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/vpc/AwsVpcMapper.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/vpc/AwsVpcMapper.java index 01b75b53..55610196 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/vpc/AwsVpcMapper.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/vpc/AwsVpcMapper.java @@ -1,23 +1,16 @@ package com.agenticcp.core.domain.cloud.adapter.outbound.aws.vpc; -import com.agenticcp.core.domain.cloud.entity.CloudProvider; -import com.agenticcp.core.domain.cloud.entity.CloudRegion; import com.agenticcp.core.domain.cloud.entity.CloudResource; -import com.agenticcp.core.domain.cloud.entity.CloudService; import com.agenticcp.core.domain.cloud.port.model.vpc.CreateVpcCommand; import com.agenticcp.core.domain.cloud.port.model.vpc.GetVpcCommand; import com.agenticcp.core.domain.cloud.dto.ListVpcsQueryRequest; import com.agenticcp.core.domain.cloud.port.model.vpc.UpdateVpcCommand; -import com.agenticcp.core.domain.cloud.repository.CloudProviderRepository; -import com.agenticcp.core.domain.cloud.repository.CloudRegionRepository; -import com.agenticcp.core.domain.cloud.repository.CloudServiceRepository; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import software.amazon.awssdk.services.ec2.model.Vpc; -import java.time.LocalDateTime; import java.util.HashMap; import java.util.Map; import java.util.stream.Collectors; @@ -33,19 +26,9 @@ public class AwsVpcMapper { private final ObjectMapper objectMapper; - private final CloudProviderRepository cloudProviderRepository; - private final CloudServiceRepository cloudServiceRepository; - private final CloudRegionRepository cloudRegionRepository; - public AwsVpcMapper( - ObjectMapper objectMapper, - CloudProviderRepository cloudProviderRepository, - CloudServiceRepository cloudServiceRepository, - CloudRegionRepository cloudRegionRepository) { + public AwsVpcMapper(ObjectMapper objectMapper) { this.objectMapper = objectMapper; - this.cloudProviderRepository = cloudProviderRepository; - this.cloudServiceRepository = cloudServiceRepository; - this.cloudRegionRepository = cloudRegionRepository; } /** @@ -93,57 +76,34 @@ private CloudResource buildCloudResourceForCreate(Vpc vpc, CreateVpcCommand comm ? command.vpcName() : vpc.vpcId(); - // 메타데이터 구성 - Map metadata = new HashMap<>(); - metadata.put("vpcId", vpc.vpcId()); - metadata.put("cidrBlock", vpc.cidrBlock()); - metadata.put("state", vpc.stateAsString()); - metadata.put("isDefault", vpc.isDefault()); - metadata.put("dhcpOptionsId", vpc.dhcpOptionsId()); - metadata.put("instanceTenancy", vpc.instanceTenancyAsString()); + // 쿠버네티스 스타일: properties (Spec) 구성 + Map properties = new HashMap<>(); + properties.put("vpcId", vpc.vpcId()); + properties.put("cidrBlock", vpc.cidrBlock()); + properties.put("isDefault", vpc.isDefault()); + properties.put("dhcpOptionsId", vpc.dhcpOptionsId()); + properties.put("instanceTenancy", vpc.instanceTenancyAsString()); - String metadataJson = null; - try { - metadataJson = objectMapper.writeValueAsString(metadata); - } catch (JsonProcessingException e) { - log.warn("[AwsVpcMapper] Failed to serialize metadata: {}", e.getMessage()); - } - - // 엔티티 조회 - CloudProvider provider = cloudProviderRepository.findFirstByProviderType(command.providerType()) - .orElseThrow(() -> new IllegalStateException("CloudProvider not found for type: " + command.providerType())); - - CloudService service = cloudServiceRepository.findByProviderTypeAndServiceKey(command.providerType(), command.serviceKey()) - .orElseThrow(() -> new IllegalStateException("CloudService not found for providerType: " + command.providerType() + ", serviceKey: " + command.serviceKey())); - - // region은 optional이므로 null일 수 있음 - CloudRegion cloudRegion = null; - if (command.region() != null && !command.region().isEmpty()) { - cloudRegion = cloudRegionRepository.findByProviderTypeAndRegionKey(command.providerType(), command.region()) - .orElse(null); - if (cloudRegion == null) { - log.warn("[AwsVpcMapper] CloudRegion not found for providerType: {}, regionKey: {}", command.providerType(), command.region()); - } - } + // 쿠버네티스 스타일: status (Status) 구성 + Map status = new HashMap<>(); + status.put("state", mapStateToLifecycleState(vpc.stateAsString())); + status.put("stateRaw", vpc.stateAsString()); return CloudResource.builder() .resourceId(resourceId) - .resourceName(resourceName) - .displayName(resourceName) - .provider(provider) - .service(service) - .region(cloudRegion) - .resourceType(CloudResource.ResourceType.NETWORK) - .lifecycleState(mapStateToLifecycleState(vpc.stateAsString())) - .tags(command.tags()) - .metadata(metadataJson) - .createdInCloud(LocalDateTime.now()) + .name(resourceName) + .provider(command.providerType().name()) + .region(command.region() != null ? command.region() : "us-east-1") + .type("NETWORK") + .properties(toJson(properties)) + .status(toJson(status)) + .labels(toJson(command.tags() != null ? command.tags() : new HashMap<>())) .build(); } private CloudResource buildCloudResource( Vpc vpc, - CloudProvider.ProviderType providerType, + com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType providerType, String serviceKey, String region, String tenantKey, @@ -163,51 +123,28 @@ private CloudResource buildCloudResource( } } - // 메타데이터 구성 - Map metadata = new HashMap<>(); - metadata.put("vpcId", vpc.vpcId()); - metadata.put("cidrBlock", vpc.cidrBlock()); - metadata.put("state", vpc.stateAsString()); - metadata.put("isDefault", vpc.isDefault()); - metadata.put("dhcpOptionsId", vpc.dhcpOptionsId()); - metadata.put("instanceTenancy", vpc.instanceTenancyAsString()); - - String metadataJson = null; - try { - metadataJson = objectMapper.writeValueAsString(metadata); - } catch (JsonProcessingException e) { - // 로깅은 생략 - } - - // 엔티티 조회 - CloudProvider provider = cloudProviderRepository.findFirstByProviderType(providerType) - .orElseThrow(() -> new IllegalStateException("CloudProvider not found for type: " + providerType)); + // 쿠버네티스 스타일: properties (Spec) 구성 + Map properties = new HashMap<>(); + properties.put("vpcId", vpc.vpcId()); + properties.put("cidrBlock", vpc.cidrBlock()); + properties.put("isDefault", vpc.isDefault()); + properties.put("dhcpOptionsId", vpc.dhcpOptionsId()); + properties.put("instanceTenancy", vpc.instanceTenancyAsString()); - CloudService service = cloudServiceRepository.findByProviderTypeAndServiceKey(providerType, serviceKey) - .orElseThrow(() -> new IllegalStateException("CloudService not found for providerType: " + providerType + ", serviceKey: " + serviceKey)); - - // region은 optional이므로 null일 수 있음 - CloudRegion cloudRegion = null; - if (region != null && !region.isEmpty()) { - cloudRegion = cloudRegionRepository.findByProviderTypeAndRegionKey(providerType, region) - .orElse(null); // region이 없어도 CloudResource는 생성 가능 - if (cloudRegion == null) { - log.warn("[AwsVpcMapper] CloudRegion not found for providerType: {}, regionKey: {}", providerType, region); - } - } + // 쿠버네티스 스타일: status (Status) 구성 + Map status = new HashMap<>(); + status.put("state", mapStateToLifecycleState(vpc.stateAsString())); + status.put("stateRaw", vpc.stateAsString()); return CloudResource.builder() .resourceId(resourceId) - .resourceName(resourceName) - .displayName(resourceName) - .provider(provider) - .service(service) - .region(cloudRegion) - .resourceType(CloudResource.ResourceType.NETWORK) - .lifecycleState(mapStateToLifecycleState(vpc.stateAsString())) - .tags(tags) - .metadata(metadataJson) - .createdInCloud(LocalDateTime.now()) // AWS VPC는 생성 시간 정보를 직접 제공하지 않으므로 현재 시간 사용 + .name(resourceName) + .provider(providerType.name()) + .region(region != null ? region : "us-east-1") + .type("NETWORK") + .properties(toJson(properties)) + .status(toJson(status)) + .labels(toJson(tags != null ? tags : new HashMap<>())) .build(); } @@ -222,14 +159,32 @@ private Map extractTagsFromVpc(Vpc vpc) { )); } - private CloudResource.LifecycleState mapStateToLifecycleState(String state) { + private String mapStateToLifecycleState(String state) { if (state == null) { - return CloudResource.LifecycleState.UNKNOWN; + return "unknown"; } return switch (state.toUpperCase()) { - case "PENDING" -> CloudResource.LifecycleState.PENDING; - case "AVAILABLE" -> CloudResource.LifecycleState.RUNNING; - default -> CloudResource.LifecycleState.UNKNOWN; + case "PENDING" -> "pending"; + case "AVAILABLE" -> "running"; + default -> "unknown"; }; } + + /** + * Map을 JSON 문자열로 변환합니다. + * + * @param map 변환할 Map + * @return JSON 문자열 (실패 시 null) + */ + private String toJson(Map map) { + if (map == null || map.isEmpty()) { + return null; + } + try { + return objectMapper.writeValueAsString(map); + } catch (JsonProcessingException e) { + log.warn("[AwsVpcMapper] Failed to serialize to JSON: {}", e.getMessage()); + return null; + } + } } diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/ResourceRegistrationRequest.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/ResourceRegistrationRequest.java index 5f6e981c..78bab804 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/dto/ResourceRegistrationRequest.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/ResourceRegistrationRequest.java @@ -1,7 +1,5 @@ package com.agenticcp.core.domain.cloud.dto; -import com.agenticcp.core.domain.cloud.entity.CloudResource.LifecycleState; -import com.agenticcp.core.domain.cloud.entity.CloudResource.ResourceType; import lombok.Builder; import lombok.Getter; @@ -15,7 +13,7 @@ * 도메인별 상세 속성(instanceSize, cidrBlock 등)은 attributes에 담아 전달합니다. * * @author AgenticCP Team - * @version 1.0.0 + * @version 2.0.0 (쿠버네티스 스타일) */ @Getter @Builder @@ -33,10 +31,10 @@ public class ResourceRegistrationRequest { private final String resourceName; /** - * 리소스 타입 - * @see ResourceType + * 리소스 타입 (쿠버네티스 스타일: String) + * 예: "INSTANCE", "BUCKET", "NETWORK" 등 */ - private final ResourceType resourceType; + private final String resourceType; /** * 리소스 태그 (CSP의 태그 정보) @@ -54,15 +52,19 @@ public class ResourceRegistrationRequest { *
  • Storage: storageGb
  • *
  • RDS: engineVersion, storageType
  • * + * + *

    이 속성들은 CloudResource의 properties (JSON) 필드에 저장됩니다.

    */ @Builder.Default private final Map attributes = new HashMap<>(); /** - * 초기 생명주기 상태 (선택적) - * null인 경우 resourceType에 따라 기본값이 적용됩니다. + * 초기 상태 정보 (선택적, JSON 형태) + * 쿠버네티스 스타일: status 필드에 JSON으로 저장됩니다. + * 예: {"state": "running", "ipAddress": "10.0.0.1"} */ - private final LifecycleState initialLifecycleState; + @Builder.Default + private final Map initialStatus = new HashMap<>(); // ==================== 편의 메서드 ==================== diff --git a/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java b/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java index 2360bc37..f218d12d 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java @@ -1,375 +1,141 @@ package com.agenticcp.core.domain.cloud.entity; -import com.agenticcp.core.common.config.TagMapConverter; import com.agenticcp.core.common.entity.BaseEntity; -import com.agenticcp.core.common.enums.Status; import com.agenticcp.core.domain.tenant.entity.Tenant; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; -import com.agenticcp.core.domain.cloud.dto.ResourceRegistrationRequest; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.Map; - +/** + * Cloud Resource 엔티티 (쿠버네티스 스타일) + * + *

    리소스를 메타데이터가 있는 일반적인 컨테이너로 취급하고, + * 구체적인 설정은 구조화된 JSON으로 저장합니다.

    + * + *

    기본 필드: resourceId, name, provider, region, type, labels (모든 리소스 공통) + * 확장 필드: properties (Spec), status (Status) - JSON 형태

    + * + * @author AgenticCP Team + * @version 2.0.0 + * @since 2025-01-XX + */ @Entity -@Table(name = "cloud_resources") +@Table(name = "cloud_resources", indexes = { + @Index(name = "idx_resource_id", columnList = "resource_id"), + @Index(name = "idx_resource_tenant", columnList = "tenant_id"), + @Index(name = "idx_resource_type", columnList = "type"), + @Index(name = "idx_resource_tenant_type", columnList = "tenant_id, type") +}) @Data @Builder @NoArgsConstructor @AllArgsConstructor +@EqualsAndHashCode(callSuper = false) public class CloudResource extends BaseEntity { - @Column(name = "resource_id", nullable = false, unique = true) + // ==================== 공통 식별자 ==================== + + /** + * 리소스 ID (CSP에서 부여한 고유 ID) + */ + @Column(name = "resource_id", nullable = false, unique = true, length = 255) private String resourceId; - @Column(name = "resource_name", nullable = false) - private String resourceName; - - @Column(name = "display_name") - private String displayName; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "provider_id", nullable = false) - private CloudProvider provider; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "region_id") - private CloudRegion region; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "service_id", nullable = false) - private CloudService service; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "tenant_id") - private Tenant tenant; - - @Enumerated(EnumType.STRING) - @Column(name = "status") - private Status status = Status.ACTIVE; - - @Enumerated(EnumType.STRING) - @Column(name = "resource_type") - private ResourceType resourceType; - - @Enumerated(EnumType.STRING) - @Column(name = "lifecycle_state") - private LifecycleState lifecycleState = LifecycleState.RUNNING; - - @Column(name = "instance_type") - private String instanceType; - - @Column(name = "instance_size") - private String instanceSize; - - @Column(name = "cpu_cores") - private Integer cpuCores; - - @Column(name = "memory_gb") - private Integer memoryGb; - - @Column(name = "storage_gb") - private Long storageGb; - - @Column(name = "network_bandwidth_mbps") - private Integer networkBandwidthMbps; - - @Column(name = "ip_address") - private String ipAddress; - - @Column(name = "private_ip_address") - private String privateIpAddress; - - @Column(name = "public_ip_address") - private String publicIpAddress; - - @Convert(converter = TagMapConverter.class) - @Column(name = "tags", columnDefinition = "TEXT") - private Map tags; - - @Column(name = "configuration", columnDefinition = "TEXT") - private String configuration; // JSON for resource configuration - - @Column(name = "cost_per_hour") - private BigDecimal costPerHour; - - @Column(name = "monthly_cost") - private BigDecimal monthlyCost; - - @Column(name = "created_in_cloud") - private LocalDateTime createdInCloud; - - @Column(name = "last_modified_in_cloud") - private LocalDateTime lastModifiedInCloud; - - @Column(name = "last_sync") - private LocalDateTime lastSync; - - @Column(name = "metadata", columnDefinition = "TEXT") - private String metadata; // JSON for additional resource metadata + /** + * 리소스 이름 + */ + @Column(name = "name", nullable = false, length = 255) + private String name; - // ==================== Factory Methods ==================== + /** + * 클라우드 프로바이더 (AWS, GCP, Azure 등) + */ + @Column(name = "provider", nullable = false, length = 50) + private String provider; /** - * 통합 CloudResource 생성 팩토리 메서드 - * - * 모든 리소스 타입(VM, Storage, VPC, RDS 등)을 하나의 메서드로 생성합니다. - * 도메인별 상세 속성은 ResourceRegistrationRequest의 attributes에서 추출합니다. - * - * @param request 리소스 등록 요청 DTO - * @param provider 클라우드 프로바이더 - * @param service 클라우드 서비스 - * @param tenant 테넌트 - * @return CloudResource 엔티티 + * 리전 */ - public static CloudResource create( - ResourceRegistrationRequest request, - CloudProvider provider, - CloudService service, - Tenant tenant - ) { - LocalDateTime now = LocalDateTime.now(); - - CloudResource resource = CloudResource.builder() - .resourceId(request.getResourceId()) - .resourceName(request.getResourceName()) - .displayName(request.getResourceName()) - .provider(provider) - .service(service) - .tenant(tenant) - .status(Status.ACTIVE) - .resourceType(request.getResourceType()) - .lifecycleState(determineInitialLifecycleState(request)) - .tags(request.getTags()) - .createdInCloud(now) - .lastModifiedInCloud(now) - .lastSync(now) - .build(); - - // 도메인별 속성 적용 - applyAttributes(resource, request); - - return resource; - } + @Column(name = "region", nullable = false, length = 50) + private String region; /** - * 초기 생명주기 상태 결정 - * 요청에 명시된 상태가 있으면 사용, 없으면 리소스 타입에 따라 기본값 적용 + * 리소스 타입 (INSTANCE, CLUSTER, BUCKET 등) */ - private static LifecycleState determineInitialLifecycleState(ResourceRegistrationRequest request) { - if (request.getInitialLifecycleState() != null) { - return request.getInitialLifecycleState(); - } - - // 리소스 타입별 기본 생명주기 상태 - return switch (request.getResourceType()) { - case INSTANCE -> LifecycleState.PENDING; - default -> LifecycleState.RUNNING; - }; - } + @Column(name = "type", nullable = false, length = 50) + private String type; /** - * 도메인별 속성을 CloudResource에 적용 + * 테넌트 (리소스 소유 테넌트) */ - private static void applyAttributes(CloudResource resource, ResourceRegistrationRequest request) { - // instanceSize - String instanceSize = request.getAttributeAsString( - ResourceRegistrationRequest.AttributeKeys.INSTANCE_SIZE); - if (instanceSize != null) { - resource.setInstanceSize(instanceSize); - } - - // configuration (cidrBlock, JSON 설정 등) - String configuration = request.getAttributeAsString( - ResourceRegistrationRequest.AttributeKeys.CONFIGURATION); - if (configuration != null) { - resource.setConfiguration(configuration); - } - - // cpuCores - Integer cpuCores = request.getAttributeAsInteger( - ResourceRegistrationRequest.AttributeKeys.CPU_CORES); - if (cpuCores != null) { - resource.setCpuCores(cpuCores); - } - - // memoryGb - Integer memoryGb = request.getAttributeAsInteger( - ResourceRegistrationRequest.AttributeKeys.MEMORY_GB); - if (memoryGb != null) { - resource.setMemoryGb(memoryGb); - } - - // storageGb - Long storageGb = request.getAttributeAsLong( - ResourceRegistrationRequest.AttributeKeys.STORAGE_GB); - if (storageGb != null) { - resource.setStorageGb(storageGb); - } - - // instanceType - String instanceType = request.getAttributeAsString( - ResourceRegistrationRequest.AttributeKeys.INSTANCE_TYPE); - if (instanceType != null) { - resource.setInstanceType(instanceType); - } - } + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tenant_id", nullable = false) + private Tenant tenant; + // ==================== 확장 필드 (JSON) ==================== + /** - * VM 인스턴스용 CloudResource 생성 - * CSP에서 생성된 VM 인스턴스 정보를 CloudResource 엔티티로 변환합니다. - * - * @param resourceId 인스턴스 ID (CSP에서 부여한 ID) - * @param resourceName 리소스 이름 (태그에서 추출 또는 resourceId) - * @param provider 클라우드 프로바이더 - * @param service 클라우드 서비스 (EC2, Compute Engine 등) - * @param tenant 테넌트 - * @param instanceSize 인스턴스 크기 - * @param tags 태그 맵 - * @return CloudResource 엔티티 + * 쿠버네티스 Spec - 선언된 상태/설정 (리소스 타입별 설정 정보) + * JSON 형태로 저장: { "cpuCores": 4, "memoryGb": 8, "instanceType": "t3.medium" } */ - public static CloudResource createVmInstance( - String resourceId, - String resourceName, - CloudProvider provider, - CloudService service, - Tenant tenant, - String instanceSize, - Map tags - ) { - LocalDateTime now = LocalDateTime.now(); - return CloudResource.builder() - .resourceId(resourceId) - .resourceName(resourceName) - .displayName(resourceName) - .provider(provider) - .service(service) - .tenant(tenant) - .status(Status.ACTIVE) - .resourceType(ResourceType.INSTANCE) - .lifecycleState(LifecycleState.PENDING) - .instanceSize(instanceSize) - .tags(tags) - .createdInCloud(now) - .lastModifiedInCloud(now) - .lastSync(now) - .build(); - } + @Column(name = "properties", columnDefinition = "JSON") + private String properties; /** - * Object Storage (버킷/컨테이너)용 CloudResource 생성 - * CSP에서 생성된 스토리지 컨테이너 정보를 CloudResource 엔티티로 변환합니다. - * - * @param containerName 컨테이너 이름 (S3 버킷명, Azure Blob 컨테이너명 등) - * @param provider 클라우드 프로바이더 - * @param service 클라우드 서비스 (S3, BlobStorage 등) - * @param tenant 테넌트 - * @param tags 태그 맵 - * @return CloudResource 엔티티 + * 쿠버네티스 Status - 관측된 상태/런타임 정보 + * JSON 형태로 저장: { "state": "running", "ipAddress": "10.0.0.1", "costPerHour": 0.05 } */ - public static CloudResource createStorageBucket( - String containerName, - CloudProvider provider, - CloudService service, - Tenant tenant, - Map tags - ) { - LocalDateTime now = LocalDateTime.now(); - return CloudResource.builder() - .resourceId(containerName) - .resourceName(containerName) - .displayName(containerName) - .provider(provider) - .service(service) - .tenant(tenant) - .status(Status.ACTIVE) - .resourceType(ResourceType.BUCKET) - .lifecycleState(LifecycleState.RUNNING) - .tags(tags) - .createdInCloud(now) - .lastModifiedInCloud(now) - .lastSync(now) - .build(); - } + @Column(name = "status", columnDefinition = "JSON") + private String status; /** - * VPC 네트워크용 CloudResource 생성 - * CSP에서 생성된 VPC 정보를 CloudResource 엔티티로 변환합니다. - * - * @param vpcId VPC ID (CSP에서 부여한 ID) - * @param resourceName 리소스 이름 (VPC 이름 또는 vpcId) - * @param provider 클라우드 프로바이더 - * @param service 클라우드 서비스 (EC2, VirtualNetwork 등) - * @param tenant 테넌트 - * @param cidrBlock CIDR 블록 (configuration에 저장) - * @param tags 태그 맵 - * @return CloudResource 엔티티 + * 태그/라벨 (JSON Map) + * JSON 형태로 저장: { "environment": "production", "team": "backend" } */ - public static CloudResource createVpc( - String vpcId, - String resourceName, - CloudProvider provider, - CloudService service, - Tenant tenant, - String cidrBlock, - Map tags - ) { - LocalDateTime now = LocalDateTime.now(); - return CloudResource.builder() - .resourceId(vpcId) - .resourceName(resourceName) - .displayName(resourceName) - .provider(provider) - .service(service) - .tenant(tenant) - .status(Status.ACTIVE) - .resourceType(ResourceType.NETWORK) - .lifecycleState(LifecycleState.RUNNING) - .tags(tags) - .configuration(cidrBlock) - .createdInCloud(now) - .lastModifiedInCloud(now) - .lastSync(now) - .build(); - } + @Column(name = "labels", columnDefinition = "JSON") + private String labels; + // ==================== 내부 Enum ==================== + + /** + * 리소스 타입 + */ public enum ResourceType { INSTANCE, - VOLUME, - SNAPSHOT, - IMAGE, NETWORK, - SUBNET, - SECURITY_GROUP, - LOAD_BALANCER, - DATABASE, BUCKET, - FUNCTION, - CONTAINER, CLUSTER, - NODE, - POD, - SERVICE, - INGRESS, - CONFIG_MAP, - SECRET, - PERSISTENT_VOLUME, - PERSISTENT_VOLUME_CLAIM + DATABASE, + LOAD_BALANCER, + SECURITY_GROUP, + SUBNET, + ROUTE_TABLE, + INTERNET_GATEWAY, + NAT_GATEWAY, + VPC_ENDPOINT, + OTHER } + /** + * 리소스 생명주기 상태 + */ public enum LifecycleState { + UNKNOWN, PENDING, RUNNING, - STOPPING, STOPPED, - TERMINATING, - TERMINATED, - FAILED, - UNKNOWN + TERMINATED; + + /** + * LifecycleState를 소문자 문자열로 변환합니다. + * + * @return 소문자 상태 문자열 (예: "running", "stopped") + */ + public String toLowerCase() { + return name().toLowerCase(); + } } } diff --git a/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResourceWorkerMap.java b/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResourceWorkerMap.java new file mode 100644 index 00000000..21cb34dd --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResourceWorkerMap.java @@ -0,0 +1,65 @@ +package com.agenticcp.core.domain.cloud.entity; + +import com.agenticcp.core.common.entity.BaseEntity; +import com.agenticcp.core.domain.user.entity.Worker; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Cloud Resource Worker Map 엔티티 + * + *

    리소스-워커 접근 권한 매핑을 담당하는 엔티티입니다. + * 쿼리 레벨 필터링을 위해 사용됩니다.

    + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-01-XX + */ +@Entity +@Table(name = "cloud_resource_worker_map", indexes = { + @Index(name = "idx_crwm_resource", columnList = "cloud_resource_id"), + @Index(name = "idx_crwm_worker", columnList = "worker_id"), + @Index(name = "idx_crwm_access_type", columnList = "access_type"), + @Index(name = "idx_crwm_expires", columnList = "expires_at"), + @Index(name = "idx_crwm_worker_deleted", columnList = "worker_id, is_deleted") +}, uniqueConstraints = { + @UniqueConstraint(name = "uk_crwm_resource_worker", columnNames = {"cloud_resource_id", "worker_id", "is_deleted"}) +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = false) +public class CloudResourceWorkerMap extends BaseEntity { + + @NotNull(message = "Cloud Resource는 필수입니다") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cloud_resource_id", nullable = false) + private CloudResource cloudResource; + + @NotNull(message = "Worker는 필수입니다") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "worker_id", nullable = false) + private Worker worker; + + @NotNull(message = "접근 타입은 필수입니다") + @Column(name = "access_type", nullable = false, length = 50) + private String accessType; // CREATOR, ORGANIZATION, GRANTED + + @Column(name = "grant_reason", length = 255) + private String grantReason; + + @Column(name = "granted_by", length = 255) + private String grantedBy; + + @Column(name = "expires_at") + private LocalDateTime expiresAt; +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudResourceRepository.java b/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudResourceRepository.java index 86363501..d00a95bc 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudResourceRepository.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudResourceRepository.java @@ -2,8 +2,6 @@ import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; import com.agenticcp.core.domain.cloud.entity.CloudResource; -import com.agenticcp.core.domain.cloud.entity.CloudResource.LifecycleState; -import com.agenticcp.core.domain.cloud.entity.CloudResource.ResourceType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -37,7 +35,6 @@ public interface CloudResourceRepository extends JpaRepository findByTenantKey(@Param("tenantKey") String tenantKey); @@ -60,7 +57,6 @@ public interface CloudResourceRepository extends JpaRepository findOptionalByResourceId(@Param("resourceId") String resourceId); @@ -81,130 +77,98 @@ public interface CloudResourceRepository extends JpaRepository findByResourceType(@Param("resourceType") ResourceType resourceType); + List findByResourceType(@Param("resourceType") String resourceType); /** * 테넌트 키 + 리소스 타입별 조회 * * @param tenantKey 테넌트 키 - * @param resourceType 리소스 타입 + * @param resourceType 리소스 타입 (String) * @return 클라우드 리소스 목록 */ @Query("SELECT cr FROM CloudResource cr " + - "JOIN FETCH cr.provider " + "WHERE cr.tenant.tenantKey = :tenantKey " + - "AND cr.resourceType = :resourceType " + + "AND cr.type = :resourceType " + "AND cr.isDeleted = false") List findByTenantKeyAndResourceType( @Param("tenantKey") String tenantKey, - @Param("resourceType") ResourceType resourceType); + @Param("resourceType") String resourceType); // ==================== 프로바이더(CSP)별 조회 ==================== /** - * 프로바이더 타입별 조회 (AWS, Azure, GCP 등) + * 프로바이더별 조회 (AWS, Azure, GCP 등) + * 쿠버네티스 스타일: provider 필드 (String) 사용 * - * @param providerType 프로바이더 타입 + * @param provider 프로바이더 (String, 예: "AWS", "AZURE", "GCP") * @return 클라우드 리소스 목록 */ @Query("SELECT cr FROM CloudResource cr " + - "JOIN FETCH cr.provider p " + - "WHERE p.providerType = :providerType " + + "WHERE cr.provider = :provider " + "AND cr.isDeleted = false") - List findByProviderType(@Param("providerType") ProviderType providerType); + List findByProvider(@Param("provider") String provider); /** - * 테넌트 키 + 프로바이더 타입별 조회 + * 테넌트 키 + 프로바이더별 조회 * * @param tenantKey 테넌트 키 - * @param providerType 프로바이더 타입 + * @param provider 프로바이더 (String) * @return 클라우드 리소스 목록 */ @Query("SELECT cr FROM CloudResource cr " + - "JOIN FETCH cr.provider p " + "WHERE cr.tenant.tenantKey = :tenantKey " + - "AND p.providerType = :providerType " + + "AND cr.provider = :provider " + "AND cr.isDeleted = false") - List findByTenantKeyAndProviderType( + List findByTenantKeyAndProvider( @Param("tenantKey") String tenantKey, - @Param("providerType") ProviderType providerType); - - // ==================== 생명주기 상태별 조회 ==================== - - /** - * 생명주기 상태별 조회 - * - * @param lifecycleState 생명주기 상태 (RUNNING, STOPPED 등) - * @return 클라우드 리소스 목록 - */ - @Query("SELECT cr FROM CloudResource cr " + - "JOIN FETCH cr.provider " + - "WHERE cr.lifecycleState = :lifecycleState " + - "AND cr.isDeleted = false") - List findByLifecycleState(@Param("lifecycleState") LifecycleState lifecycleState); - - /** - * 특정 생명주기 상태를 제외한 조회 (동기화용) - * TERMINATED 상태를 제외한 활성 리소스 조회 등에 활용 - * - * @param excludeStates 제외할 상태 목록 - * @return 클라우드 리소스 목록 - */ - @Query("SELECT cr FROM CloudResource cr " + - "JOIN FETCH cr.provider " + - "WHERE cr.lifecycleState NOT IN :excludeStates " + - "AND cr.isDeleted = false") - List findByLifecycleStateNotIn(@Param("excludeStates") List excludeStates); + @Param("provider") String provider); /** - * 테넌트 키 + 프로바이더 타입 + 리소스 타입별 조회 + * 테넌트 키 + 프로바이더 + 리소스 타입별 조회 * * @param tenantKey 테넌트 키 - * @param providerType 프로바이더 타입 - * @param resourceType 리소스 타입 + * @param provider 프로바이더 (String) + * @param resourceType 리소스 타입 (String) * @return 클라우드 리소스 목록 */ @Query("SELECT cr FROM CloudResource cr " + - "JOIN FETCH cr.provider p " + "WHERE cr.tenant.tenantKey = :tenantKey " + - "AND p.providerType = :providerType " + - "AND cr.resourceType = :resourceType " + + "AND cr.provider = :provider " + + "AND cr.type = :resourceType " + "AND cr.isDeleted = false") - List findByTenantKeyAndProviderTypeAndResourceType( + List findByTenantKeyAndProviderAndResourceType( @Param("tenantKey") String tenantKey, - @Param("providerType") ProviderType providerType, - @Param("resourceType") ResourceType resourceType); + @Param("provider") String provider, + @Param("resourceType") String resourceType); // ==================== 상태 업데이트 (Modifying) ==================== /** - * 생명주기 상태 업데이트 + * 상태 업데이트 (쿠버네티스 스타일) * CSP에서 자원 시작/중지/종료 후 DB 상태 동기화에 사용 + * status 필드 (JSON)에 상태 정보를 저장합니다. * * @param resourceId 리소스 ID - * @param lifecycleState 새로운 생명주기 상태 - * @param lastModifiedInCloud 클라우드에서 수정된 시간 + * @param status 상태 정보 (JSON String) * @return 업데이트된 행 수 */ @Modifying @Query("UPDATE CloudResource cr SET " + - "cr.lifecycleState = :lifecycleState, " + - "cr.lastModifiedInCloud = :lastModifiedInCloud, " + + "cr.status = :status, " + "cr.updatedAt = CURRENT_TIMESTAMP " + "WHERE cr.resourceId = :resourceId " + "AND cr.isDeleted = false") - int updateLifecycleState( + int updateStatus( @Param("resourceId") String resourceId, - @Param("lifecycleState") LifecycleState lifecycleState, - @Param("lastModifiedInCloud") LocalDateTime lastModifiedInCloud); + @Param("status") String status); /** * 소프트 삭제 처리 @@ -216,7 +180,6 @@ int updateLifecycleState( @Modifying @Query("UPDATE CloudResource cr SET " + "cr.isDeleted = true, " + - "cr.lifecycleState = 'TERMINATED', " + "cr.updatedAt = CURRENT_TIMESTAMP " + "WHERE cr.resourceId = :resourceId") int softDeleteByResourceId(@Param("resourceId") String resourceId); @@ -252,15 +215,14 @@ int updateLastSync( long countByTenantKey(@Param("tenantKey") String tenantKey); /** - * 프로바이더 타입별 리소스 수 조회 + * 프로바이더별 리소스 수 조회 * - * @param providerType 프로바이더 타입 + * @param provider 프로바이더 (String) * @return 리소스 수 */ @Query("SELECT COUNT(cr) FROM CloudResource cr " + - "JOIN cr.provider p " + - "WHERE p.providerType = :providerType " + + "WHERE cr.provider = :provider " + "AND cr.isDeleted = false") - long countByProviderType(@Param("providerType") ProviderType providerType); + long countByProvider(@Param("provider") String provider); } diff --git a/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudResourceWorkerMapRepository.java b/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudResourceWorkerMapRepository.java new file mode 100644 index 00000000..5df68851 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudResourceWorkerMapRepository.java @@ -0,0 +1,110 @@ +package com.agenticcp.core.domain.cloud.repository; + +import com.agenticcp.core.domain.cloud.entity.CloudResourceWorkerMap; +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.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * Cloud Resource Worker Map Repository + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-01-XX + */ +@Repository +public interface CloudResourceWorkerMapRepository extends JpaRepository { + + /** + * Cloud Resource ID로 Worker Map 목록 조회 + * 만료되지 않은 것만 조회 + * + * @param cloudResourceId Cloud Resource ID + * @return Worker Map 목록 + */ + @Query("SELECT crwm FROM CloudResourceWorkerMap crwm " + + "WHERE crwm.cloudResource.id = :cloudResourceId " + + "AND crwm.isDeleted = false " + + "AND (crwm.expiresAt IS NULL OR crwm.expiresAt > :now)") + List findByCloudResourceId( + @Param("cloudResourceId") Long cloudResourceId, + @Param("now") LocalDateTime now + ); + + /** + * Worker ID로 Worker Map 목록 조회 + * 만료되지 않은 것만 조회 + * + * @param workerId Worker ID + * @return Worker Map 목록 + */ + @Query("SELECT crwm FROM CloudResourceWorkerMap crwm " + + "WHERE crwm.worker.id = :workerId " + + "AND crwm.isDeleted = false " + + "AND (crwm.expiresAt IS NULL OR crwm.expiresAt > :now)") + List findByWorkerId( + @Param("workerId") Long workerId, + @Param("now") LocalDateTime now + ); + + /** + * Cloud Resource ID와 Worker ID로 Worker Map 조회 + * 만료되지 않은 것만 조회 + * + * @param cloudResourceId Cloud Resource ID + * @param workerId Worker ID + * @return Worker Map (Optional) + */ + @Query("SELECT crwm FROM CloudResourceWorkerMap crwm " + + "WHERE crwm.cloudResource.id = :cloudResourceId " + + "AND crwm.worker.id = :workerId " + + "AND crwm.isDeleted = false " + + "AND (crwm.expiresAt IS NULL OR crwm.expiresAt > :now)") + Optional findByCloudResourceIdAndWorkerId( + @Param("cloudResourceId") Long cloudResourceId, + @Param("workerId") Long workerId, + @Param("now") LocalDateTime now + ); + + /** + * Worker가 접근 가능한 Cloud Resource ID 목록 조회 + * 만료되지 않은 것만 조회 + * + * @param workerId Worker ID + * @return Cloud Resource ID 목록 + */ + @Query("SELECT crwm.cloudResource.id FROM CloudResourceWorkerMap crwm " + + "WHERE crwm.worker.id = :workerId " + + "AND crwm.isDeleted = false " + + "AND (crwm.expiresAt IS NULL OR crwm.expiresAt > :now)") + List findCloudResourceIdsByWorkerId( + @Param("workerId") Long workerId, + @Param("now") LocalDateTime now + ); + + /** + * Worker가 특정 Cloud Resource에 접근 가능한지 확인 + * 만료되지 않은 것만 확인 + * + * @param cloudResourceId Cloud Resource ID + * @param workerId Worker ID + * @return 접근 가능 여부 + */ + @Query("SELECT CASE WHEN COUNT(crwm) > 0 THEN true ELSE false END " + + "FROM CloudResourceWorkerMap crwm " + + "WHERE crwm.cloudResource.id = :cloudResourceId " + + "AND crwm.worker.id = :workerId " + + "AND crwm.isDeleted = false " + + "AND (crwm.expiresAt IS NULL OR crwm.expiresAt > :now)") + boolean existsByCloudResourceIdAndWorkerId( + @Param("cloudResourceId") Long cloudResourceId, + @Param("workerId") Long workerId, + @Param("now") LocalDateTime now + ); +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/helper/CloudResourceManagementHelper.java b/src/main/java/com/agenticcp/core/domain/cloud/service/helper/CloudResourceManagementHelper.java index 7c5d78da..6c4930c9 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/service/helper/CloudResourceManagementHelper.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/service/helper/CloudResourceManagementHelper.java @@ -5,8 +5,8 @@ import com.agenticcp.core.domain.cloud.entity.CloudProvider; import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; import com.agenticcp.core.domain.cloud.entity.CloudResource; -import com.agenticcp.core.domain.cloud.entity.CloudResource.LifecycleState; import com.agenticcp.core.domain.cloud.entity.CloudService; +import com.fasterxml.jackson.databind.ObjectMapper; import com.agenticcp.core.domain.cloud.repository.CloudProviderRepository; import com.agenticcp.core.domain.cloud.repository.CloudResourceRepository; import com.agenticcp.core.domain.cloud.repository.CloudServiceRepository; @@ -64,11 +64,47 @@ public CloudResource registerResource( String serviceKey, ResourceRegistrationRequest request ) { - CloudProvider provider = findProvider(providerType); - CloudService service = findServiceOrCreate(providerType, serviceKey, provider); Tenant tenant = findCurrentTenant(); - CloudResource cloudResource = CloudResource.create(request, provider, service, tenant); + // 쿠버네티스 스타일: CloudResource 직접 생성 + CloudResource cloudResource = CloudResource.builder() + .resourceId(request.getResourceId()) + .name(request.getResourceName()) + .provider(providerType.name()) + .region("us-east-1") // TODO: request에서 region 추출 + .type(request.getResourceType()) + .tenant(tenant) + .build(); + + // properties (Spec) 구성 + if (!request.getAttributes().isEmpty()) { + try { + String propertiesJson = objectMapper.writeValueAsString(request.getAttributes()); + cloudResource.setProperties(propertiesJson); + } catch (Exception e) { + log.warn("[CloudResourceManagementHelper] properties JSON 변환 실패: {}", e.getMessage()); + } + } + + // status (Status) 구성 + if (!request.getInitialStatus().isEmpty()) { + try { + String statusJson = objectMapper.writeValueAsString(request.getInitialStatus()); + cloudResource.setStatus(statusJson); + } catch (Exception e) { + log.warn("[CloudResourceManagementHelper] status JSON 변환 실패: {}", e.getMessage()); + } + } + + // labels 구성 + if (!request.getTags().isEmpty()) { + try { + String labelsJson = objectMapper.writeValueAsString(request.getTags()); + cloudResource.setLabels(labelsJson); + } catch (Exception e) { + log.warn("[CloudResourceManagementHelper] labels JSON 변환 실패: {}", e.getMessage()); + } + } CloudResource savedResource = cloudResourceRepository.save(cloudResource); log.debug("[CloudResourceManagementHelper] 리소스 등록 완료: resourceType={}, resourceId={}", @@ -78,29 +114,50 @@ public CloudResource registerResource( // ==================== 공통 작업 ==================== + private final ObjectMapper objectMapper = new ObjectMapper(); + /** - * 리소스의 생명주기 상태를 업데이트합니다. - * - * @param resourceId 리소스 ID - * @param lifecycleState 새로운 생명주기 상태 + * 리소스의 상태를 업데이트합니다 (쿠버네티스 스타일). + * + * @param resourceId 리소스 ID + * @param state 상태 값 (예: "running", "stopped", "terminated") */ - public void updateLifecycleState(String resourceId, LifecycleState lifecycleState) { + public void updateLifecycleState(String resourceId, String state) { try { - int updatedCount = cloudResourceRepository.updateLifecycleState( - resourceId, lifecycleState, LocalDateTime.now()); + // 쿠버네티스 스타일: status 필드에 JSON으로 저장 + Map statusMap = Map.of("state", state); + String statusJson; + try { + statusJson = objectMapper.writeValueAsString(statusMap); + } catch (Exception e) { + log.warn("[CloudResourceManagementHelper] JSON 변환 실패: resourceId={}, error={}", resourceId, e.getMessage()); + return; + } + + int updatedCount = cloudResourceRepository.updateStatus(resourceId, statusJson); if (updatedCount > 0) { - log.debug("[CloudResourceManagementHelper] 생명주기 상태 업데이트 완료: resourceId={}, state={}", - resourceId, lifecycleState); + log.debug("[CloudResourceManagementHelper] 상태 업데이트 완료: resourceId={}, state={}", + resourceId, state); } else { log.debug("[CloudResourceManagementHelper] DB에 리소스가 없어 상태 업데이트 스킵: resourceId={}", resourceId); } } catch (Exception e) { - log.warn("[CloudResourceManagementHelper] 생명주기 상태 업데이트 실패: resourceId={}, error={}", + log.warn("[CloudResourceManagementHelper] 상태 업데이트 실패: resourceId={}, error={}", resourceId, e.getMessage()); } } + /** + * 리소스의 상태를 업데이트합니다 (LifecycleState enum 사용). + * + * @param resourceId 리소스 ID + * @param lifecycleState 생명주기 상태 enum + */ + public void updateLifecycleState(String resourceId, CloudResource.LifecycleState lifecycleState) { + updateLifecycleState(resourceId, lifecycleState.toLowerCase()); + } + /** * 리소스를 소프트 삭제합니다. * diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/storage/ObjectStorageUseCaseService.java b/src/main/java/com/agenticcp/core/domain/cloud/service/storage/ObjectStorageUseCaseService.java index 44c2f564..81307a3d 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/service/storage/ObjectStorageUseCaseService.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/service/storage/ObjectStorageUseCaseService.java @@ -96,7 +96,7 @@ public CloudResource createContainer(CreateObjectStorageContainerRequest request ResourceRegistrationRequest registrationRequest = ResourceRegistrationRequest.builder() .resourceId(request.getContainerName()) .resourceName(request.getContainerName()) - .resourceType(CloudResource.ResourceType.BUCKET) + .resourceType("BUCKET") .tags(request.getTags()) .build(); diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/vm/VmUseCaseService.java b/src/main/java/com/agenticcp/core/domain/cloud/service/vm/VmUseCaseService.java index 0ff3fdf9..b8eaf2ab 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/service/vm/VmUseCaseService.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/service/vm/VmUseCaseService.java @@ -10,7 +10,6 @@ import com.agenticcp.core.common.exception.BusinessException; import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; import com.agenticcp.core.domain.cloud.entity.CloudResource; -import com.agenticcp.core.domain.cloud.entity.CloudResource.LifecycleState; import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; import com.agenticcp.core.domain.cloud.port.model.VmQuery; import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; @@ -147,7 +146,7 @@ public CloudResource createInstance(VmCreateRequest request) { ResourceRegistrationRequest registrationRequest = ResourceRegistrationRequest.builder() .resourceId(instanceId) .resourceName(resourceName) - .resourceType(CloudResource.ResourceType.INSTANCE) + .resourceType("INSTANCE") .tags(request.getTags()) .attributes(Map.of(AttributeKeys.INSTANCE_SIZE, request.getInstanceSize())) .build(); @@ -224,7 +223,7 @@ public void startInstance(ProviderType providerType, String accountScope, String vmPortRouter.lifecycle(providerType).startInstance(instanceId, session); // DB 상태 업데이트: RUNNING - resourceHelper.updateLifecycleState(instanceId, LifecycleState.RUNNING); + resourceHelper.updateLifecycleState(instanceId, "running"); log.info("VM 인스턴스 시작 완료: provider={}, instanceId={}", providerType, instanceId); } @@ -251,7 +250,7 @@ public void stopInstance(ProviderType providerType, String accountScope, String vmPortRouter.lifecycle(providerType).stopInstance(instanceId, session); // DB 상태 업데이트: STOPPED - resourceHelper.updateLifecycleState(instanceId, LifecycleState.STOPPED); + resourceHelper.updateLifecycleState(instanceId, "stopped"); log.info("VM 인스턴스 중지 완료: provider={}, instanceId={}", providerType, instanceId); } @@ -279,7 +278,7 @@ public void rebootInstance(ProviderType providerType, String accountScope, Strin vmPortRouter.lifecycle(providerType).rebootInstance(instanceId, session); // DB 상태 업데이트: 재부팅 후 RUNNING 상태 유지 (lastModifiedInCloud만 업데이트) - resourceHelper.updateLifecycleState(instanceId, LifecycleState.RUNNING); + resourceHelper.updateLifecycleState(instanceId, "running"); log.info("VM 인스턴스 재부팅 완료: provider={}, instanceId={}", providerType, instanceId); } @@ -306,7 +305,7 @@ public void terminateInstance(ProviderType providerType, String accountScope, St vmPortRouter.lifecycle(providerType).terminateInstance(instanceId, session); // DB 상태 업데이트: TERMINATED - resourceHelper.updateLifecycleState(instanceId, LifecycleState.TERMINATED); + resourceHelper.updateLifecycleState(instanceId, "terminated"); log.info("VM 인스턴스 종료 완료: provider={}, instanceId={}", providerType, instanceId); } diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseService.java b/src/main/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseService.java index f78a9f07..867c6afb 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseService.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseService.java @@ -114,7 +114,7 @@ public CloudResource createVpc(VpcCreateRequest request) { ResourceRegistrationRequest registrationRequest = ResourceRegistrationRequest.builder() .resourceId(vpc.getResourceId()) .resourceName(resourceName) - .resourceType(CloudResource.ResourceType.NETWORK) + .resourceType("NETWORK") .tags(request.getTags()) .attributes(Map.of(AttributeKeys.CONFIGURATION, request.getCidrBlock())) .build(); diff --git a/src/main/java/com/agenticcp/core/domain/organization/entity/OrganizationUser.java b/src/main/java/com/agenticcp/core/domain/organization/entity/OrganizationUser.java new file mode 100644 index 00000000..a4ecf35b --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/organization/entity/OrganizationUser.java @@ -0,0 +1,57 @@ +package com.agenticcp.core.domain.organization.entity; + +import com.agenticcp.core.common.entity.BaseEntity; +import com.agenticcp.core.common.enums.Status; +import com.agenticcp.core.domain.user.entity.User; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * Organization User 엔티티 + * + *

    조직-사용자 매핑을 담당하는 엔티티입니다. + * Organization ↔ User (M:N) 관계를 관리합니다.

    + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-01-XX + */ +@Entity +@Table(name = "organization_users", indexes = { + @Index(name = "idx_ou_organization", columnList = "organization_id"), + @Index(name = "idx_ou_user", columnList = "user_id"), + @Index(name = "idx_ou_status", columnList = "status") +}, uniqueConstraints = { + @UniqueConstraint(name = "uk_ou_organization_user", columnNames = {"organization_id", "user_id", "is_deleted"}) +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = false) +public class OrganizationUser extends BaseEntity { + + @NotNull(message = "Organization은 필수입니다") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organization_id", nullable = false) + private Organization organization; + + @NotNull(message = "User는 필수입니다") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "org_role", length = 50) + private String orgRole; // 조직 내 역할 (ORG_ADMIN, ORG_MEMBER 등) + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + @Builder.Default + private Status status = Status.ACTIVE; +} + diff --git a/src/main/java/com/agenticcp/core/domain/organization/repository/OrganizationUserRepository.java b/src/main/java/com/agenticcp/core/domain/organization/repository/OrganizationUserRepository.java new file mode 100644 index 00000000..b3abf3bf --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/organization/repository/OrganizationUserRepository.java @@ -0,0 +1,83 @@ +package com.agenticcp.core.domain.organization.repository; + +import com.agenticcp.core.domain.organization.entity.OrganizationUser; +import com.agenticcp.core.common.enums.Status; +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; + +/** + * Organization User Repository + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-01-XX + */ +@Repository +public interface OrganizationUserRepository extends JpaRepository { + + /** + * Organization ID로 OrganizationUser 목록 조회 + * + * @param organizationId Organization ID + * @return OrganizationUser 목록 + */ + List findByOrganizationIdAndIsDeletedFalse(Long organizationId); + + /** + * User ID로 OrganizationUser 목록 조회 + * + * @param userId User ID + * @return OrganizationUser 목록 + */ + List findByUserIdAndIsDeletedFalse(Long userId); + + /** + * Organization ID와 User ID로 OrganizationUser 조회 + * + * @param organizationId Organization ID + * @param userId User ID + * @return OrganizationUser (Optional) + */ + Optional findByOrganizationIdAndUserIdAndIsDeletedFalse(Long organizationId, Long userId); + + /** + * User가 속한 Organization ID 목록 조회 + * + * @param userId User ID + * @return Organization ID 목록 + */ + @Query("SELECT DISTINCT ou.organization.id FROM OrganizationUser ou " + + "WHERE ou.user.id = :userId AND ou.isDeleted = false") + List findOrganizationIdsByUserId(@Param("userId") Long userId); + + /** + * User가 특정 Organization에 속하는지 확인 + * + * @param userId User ID + * @param organizationId Organization ID + * @return 존재 여부 + */ + boolean existsByUserIdAndOrganizationIdAndIsDeletedFalse(Long userId, Long organizationId); + + /** + * Organization ID와 Status로 OrganizationUser 목록 조회 + * + * @param organizationId Organization ID + * @param status Status + * @return OrganizationUser 목록 + */ + @Query("SELECT ou FROM OrganizationUser ou " + + "WHERE ou.organization.id = :organizationId " + + "AND ou.status = :status " + + "AND ou.isDeleted = false") + List findByOrganizationIdAndStatus( + @Param("organizationId") Long organizationId, + @Param("status") Status status + ); +} + diff --git a/src/main/java/com/agenticcp/core/domain/platform/service/MultiCloudEnvironmentService.java b/src/main/java/com/agenticcp/core/domain/platform/service/MultiCloudEnvironmentService.java index ea24a8ac..917c0a75 100644 --- a/src/main/java/com/agenticcp/core/domain/platform/service/MultiCloudEnvironmentService.java +++ b/src/main/java/com/agenticcp/core/domain/platform/service/MultiCloudEnvironmentService.java @@ -62,7 +62,7 @@ public MultiCloudEnvironment detectEnvironment(String tenantId) { } return true; }) - .map(r -> r.getProvider().getProviderType().name()) + .map(r -> r.getProvider()) // provider는 이미 String .collect(Collectors.toSet()); if (providers.isEmpty()) { diff --git a/src/main/java/com/agenticcp/core/domain/user/entity/Worker.java b/src/main/java/com/agenticcp/core/domain/user/entity/Worker.java new file mode 100644 index 00000000..444e6452 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/user/entity/Worker.java @@ -0,0 +1,76 @@ +package com.agenticcp.core.domain.user.entity; + +import com.agenticcp.core.common.entity.BaseEntity; +import com.agenticcp.core.common.enums.Status; +import com.agenticcp.core.domain.organization.entity.Organization; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Worker 엔티티 + * + *

    User와 Tenant를 연결하는 엔티티입니다. + * User ↔ Worker (1:N): 한 User는 여러 Worker를 가질 수 있음 + * Worker → Tenant (N:1): Worker는 하나의 Tenant에만 속함

    + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-01-XX + */ +@Entity +@Table(name = "workers", indexes = { + @Index(name = "idx_workers_tenant", columnList = "tenant_id"), + @Index(name = "idx_workers_user", columnList = "user_id"), + @Index(name = "idx_workers_organization", columnList = "organization_id"), + @Index(name = "idx_workers_key", columnList = "worker_key"), + @Index(name = "idx_workers_user_tenant", columnList = "user_id, tenant_id") +}, uniqueConstraints = { + @UniqueConstraint(name = "uk_workers_key", columnNames = "worker_key") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = false) +public class Worker extends BaseEntity { + + @NotBlank(message = "Worker 키는 필수입니다") + @Column(name = "worker_key", nullable = false, unique = true, length = 100) + private String workerKey; + + @NotBlank(message = "Worker 이름은 필수입니다") + @Column(name = "worker_name", nullable = false, length = 255) + private String workerName; + + @NotNull(message = "Tenant는 필수입니다") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tenant_id", nullable = false) + private Tenant tenant; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organization_id") + private Organization organization; + + @NotNull(message = "User는 필수입니다") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + @Builder.Default + private Status status = Status.ACTIVE; + + @OneToMany(mappedBy = "worker", fetch = FetchType.LAZY) + private List roleAssignments; +} + diff --git a/src/main/java/com/agenticcp/core/domain/user/entity/WorkerRoleAssignment.java b/src/main/java/com/agenticcp/core/domain/user/entity/WorkerRoleAssignment.java new file mode 100644 index 00000000..fd53e6a4 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/user/entity/WorkerRoleAssignment.java @@ -0,0 +1,67 @@ +package com.agenticcp.core.domain.user.entity; + +import com.agenticcp.core.common.entity.BaseEntity; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Worker Role Assignment 엔티티 + * + *

    Worker에 Role을 할당하는 엔티티입니다. + * Tenant 스코프를 포함하여 멀티 테넌트 환경에서 Worker의 Role을 관리합니다.

    + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-01-XX + */ +@Entity +@Table(name = "worker_role_assignments", indexes = { + @Index(name = "idx_wra_tenant", columnList = "tenant_id"), + @Index(name = "idx_wra_worker", columnList = "worker_id"), + @Index(name = "idx_wra_role", columnList = "role_id"), + @Index(name = "idx_wra_tenant_worker", columnList = "tenant_id, worker_id"), + @Index(name = "idx_wra_expires", columnList = "expires_at") +}, uniqueConstraints = { + @UniqueConstraint(name = "uk_wra_tenant_worker_role", columnNames = {"tenant_id", "worker_id", "role_id", "is_deleted"}) +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = false) +public class WorkerRoleAssignment extends BaseEntity { + + @NotNull(message = "Tenant는 필수입니다") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tenant_id", nullable = false) + private Tenant tenant; + + @NotNull(message = "Worker는 필수입니다") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "worker_id", nullable = false) + private Worker worker; + + @NotNull(message = "Role은 필수입니다") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "role_id", nullable = false) + private Role role; + + @Column(name = "assigned_by", length = 255) + private String assignedBy; + + @Column(name = "assigned_at", nullable = false) + @Builder.Default + private LocalDateTime assignedAt = LocalDateTime.now(); + + @Column(name = "expires_at") + private LocalDateTime expiresAt; +} + diff --git a/src/main/java/com/agenticcp/core/domain/user/repository/WorkerRepository.java b/src/main/java/com/agenticcp/core/domain/user/repository/WorkerRepository.java new file mode 100644 index 00000000..ccbf4e01 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/user/repository/WorkerRepository.java @@ -0,0 +1,74 @@ +package com.agenticcp.core.domain.user.repository; + +import com.agenticcp.core.domain.user.entity.Worker; +import com.agenticcp.core.domain.tenant.entity.Tenant; +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; + +/** + * Worker Repository + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-01-XX + */ +@Repository +public interface WorkerRepository extends JpaRepository { + + /** + * User ID로 Worker 목록 조회 + * + * @param userId User ID + * @return Worker 목록 + */ + List findByUserIdAndIsDeletedFalse(Long userId); + + /** + * User ID와 Tenant ID로 Worker 조회 + * + * @param userId User ID + * @param tenantId Tenant ID + * @return Worker (Optional) + */ + Optional findByUserIdAndTenantIdAndIsDeletedFalse(Long userId, Long tenantId); + + /** + * Tenant ID로 Worker 목록 조회 + * + * @param tenantId Tenant ID + * @return Worker 목록 + */ + List findByTenantIdAndIsDeletedFalse(Long tenantId); + + /** + * Worker Key로 Worker 조회 + * + * @param workerKey Worker Key + * @return Worker (Optional) + */ + Optional findByWorkerKeyAndIsDeletedFalse(String workerKey); + + /** + * User가 속한 모든 Tenant ID 목록 조회 + * + * @param userId User ID + * @return Tenant ID 목록 + */ + @Query("SELECT DISTINCT w.tenant.id FROM Worker w WHERE w.user.id = :userId AND w.isDeleted = false") + List findTenantIdsByUserId(@Param("userId") Long userId); + + /** + * User가 특정 Tenant에 속하는지 확인 + * + * @param userId User ID + * @param tenantId Tenant ID + * @return 존재 여부 + */ + boolean existsByUserIdAndTenantIdAndIsDeletedFalse(Long userId, Long tenantId); +} + diff --git a/src/main/java/com/agenticcp/core/domain/user/repository/WorkerRoleAssignmentRepository.java b/src/main/java/com/agenticcp/core/domain/user/repository/WorkerRoleAssignmentRepository.java new file mode 100644 index 00000000..e7977dac --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/user/repository/WorkerRoleAssignmentRepository.java @@ -0,0 +1,60 @@ +package com.agenticcp.core.domain.user.repository; + +import com.agenticcp.core.domain.user.entity.WorkerRoleAssignment; +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.time.LocalDateTime; +import java.util.List; + +/** + * Worker Role Assignment Repository + * + * @author AgenticCP Team + * @version 1.0.0 + * @since 2025-01-XX + */ +@Repository +public interface WorkerRoleAssignmentRepository extends JpaRepository { + + /** + * Tenant ID와 Worker ID로 Role Assignment 목록 조회 + * 만료되지 않은 것만 조회 + * + * @param tenantId Tenant ID + * @param workerId Worker ID + * @return Role Assignment 목록 + */ + @Query("SELECT wra FROM WorkerRoleAssignment wra " + + "WHERE wra.tenant.id = :tenantId " + + "AND wra.worker.id = :workerId " + + "AND wra.isDeleted = false " + + "AND (wra.expiresAt IS NULL OR wra.expiresAt > :now)") + List findByTenantIdAndWorkerId( + @Param("tenantId") Long tenantId, + @Param("workerId") Long workerId, + @Param("now") LocalDateTime now + ); + + /** + * Tenant ID와 Worker ID로 Role ID 목록 조회 + * 만료되지 않은 것만 조회 + * + * @param tenantId Tenant ID + * @param workerId Worker ID + * @return Role ID 목록 + */ + @Query("SELECT wra.role.id FROM WorkerRoleAssignment wra " + + "WHERE wra.tenant.id = :tenantId " + + "AND wra.worker.id = :workerId " + + "AND wra.isDeleted = false " + + "AND (wra.expiresAt IS NULL OR wra.expiresAt > :now)") + List findRoleIdsByTenantIdAndWorkerId( + @Param("tenantId") Long tenantId, + @Param("workerId") Long workerId, + @Param("now") LocalDateTime now + ); +} + diff --git a/src/main/java/com/agenticcp/core/domain/user/service/PermissionService.java b/src/main/java/com/agenticcp/core/domain/user/service/PermissionService.java index 6370b633..22603b85 100644 --- a/src/main/java/com/agenticcp/core/domain/user/service/PermissionService.java +++ b/src/main/java/com/agenticcp/core/domain/user/service/PermissionService.java @@ -11,15 +11,19 @@ import com.agenticcp.core.domain.user.dto.PermissionResponse; import com.agenticcp.core.domain.user.dto.UpdatePermissionRequest; import com.agenticcp.core.domain.user.entity.Permission; +import com.agenticcp.core.domain.user.entity.Role; import com.agenticcp.core.domain.user.repository.PermissionRepository; import com.agenticcp.core.domain.user.repository.RoleRepository; +import com.agenticcp.core.domain.user.repository.WorkerRoleAssignmentRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; /** @@ -37,6 +41,7 @@ public class PermissionService { private final PermissionRepository permissionRepository; private final RoleRepository roleRepository; + private final WorkerRoleAssignmentRepository workerRoleAssignmentRepository; /** * 모든 권한 조회 (현재 테넌트) @@ -359,5 +364,151 @@ public PermissionResponse toPermissionResponse(Permission permission) { .updatedBy(permission.getUpdatedBy()) .build(); } + + /** + * 권한 체크 메서드 + * + *

    Worker가 특정 리소스에 대해 특정 액션을 수행할 권한이 있는지 확인합니다.

    + * + *

    권한 체크 흐름:

    + *
      + *
    1. Tenant 격리 확인: resource.tenantId == tenantId
    2. + *
    3. Worker의 Role 조회 (WorkerRoleAssignment)
    4. + *
    5. Role의 Permission 확인 (Role.permissions)
    6. + *
    7. resourceType과 action에 대한 권한 확인
    8. + *
    + * + * @param workerId Worker ID (null이면 TenantContextHolder에서 가져옴) + * @param tenantId Tenant ID (null이면 TenantContextHolder에서 가져옴) + * @param action 액션 (예: "START", "STOP", "DELETE") + * @param resource 리소스 객체 (tenantId, type 필드 필요) + * @return 권한 있음: true, 권한 없음: false + */ + @Transactional(readOnly = true) + public boolean can(Long workerId, Long tenantId, String action, Object resource) { + // workerId나 tenantId가 null이면 TenantContextHolder에서 가져오기 + if (workerId == null || tenantId == null) { + TenantContextHolder.TenantWorkerContext context = TenantContextHolder.getCurrentTenantAndWorkerOrThrow(); + if (workerId == null) { + workerId = context.getWorkerId(); + } + if (tenantId == null) { + tenantId = context.getTenantId(); + } + } + log.debug("Permission check: workerId={}, tenantId={}, action={}", workerId, tenantId, action); + + // 1단계: Tenant 격리 확인 + Long resourceTenantId = extractTenantId(resource); + if (resourceTenantId == null || !resourceTenantId.equals(tenantId)) { + log.warn("Tenant isolation violation: resource.tenantId={}, tenantId={}", resourceTenantId, tenantId); + return false; + } + + // 2단계: Worker의 Role 조회 + List roleIds = workerRoleAssignmentRepository.findRoleIdsByTenantIdAndWorkerId( + tenantId, workerId, LocalDateTime.now() + ); + + if (roleIds.isEmpty()) { + log.debug("No roles assigned to worker: workerId={}, tenantId={}", workerId, tenantId); + return false; + } + + // 3단계: Role의 Permission 확인 + List roles = roleRepository.findAllById(roleIds); + Set permissionIds = roles.stream() + .flatMap(role -> role.getPermissions().stream()) + .map(Permission::getId) + .collect(Collectors.toSet()); + + if (permissionIds.isEmpty()) { + log.debug("No permissions found for roles: roleIds={}", roleIds); + return false; + } + + // 4단계: resourceType과 action에 대한 권한 확인 + String resourceType = extractResourceType(resource); + List matchingPermissions = permissionRepository.findAllById(permissionIds).stream() + .filter(permission -> + resourceType != null && resourceType.equals(permission.getResource()) && + action != null && action.equals(permission.getAction()) + ) + .collect(Collectors.toList()); + + boolean hasPermission = !matchingPermissions.isEmpty(); + log.debug("Permission check result: workerId={}, tenantId={}, action={}, resourceType={}, hasPermission={}", + workerId, tenantId, action, resourceType, hasPermission); + + return hasPermission; + } + + /** + * 리소스에서 Tenant ID 추출 + * + * @param resource 리소스 객체 + * @return Tenant ID (없으면 null) + */ + private Long extractTenantId(Object resource) { + if (resource == null) { + return null; + } + + try { + // 리소스가 tenantId 필드를 가진 경우 + java.lang.reflect.Field tenantIdField = resource.getClass().getDeclaredField("tenantId"); + tenantIdField.setAccessible(true); + Object tenantId = tenantIdField.get(resource); + + if (tenantId instanceof Long) { + return (Long) tenantId; + } + + // tenant 필드를 가진 경우 (Tenant 엔티티) + java.lang.reflect.Field tenantField = resource.getClass().getDeclaredField("tenant"); + tenantField.setAccessible(true); + Object tenant = tenantField.get(resource); + + if (tenant instanceof Tenant) { + return ((Tenant) tenant).getId(); + } + } catch (NoSuchFieldException | IllegalAccessException e) { + log.debug("Failed to extract tenantId from resource: {}", e.getMessage()); + } + + return null; + } + + /** + * 리소스에서 Resource Type 추출 + * + * @param resource 리소스 객체 + * @return Resource Type (없으면 null) + */ + private String extractResourceType(Object resource) { + if (resource == null) { + return null; + } + + try { + // type 필드 확인 + java.lang.reflect.Field typeField = resource.getClass().getDeclaredField("type"); + typeField.setAccessible(true); + Object type = typeField.get(resource); + + if (type instanceof String) { + return (String) type; + } + + // Enum인 경우 + if (type instanceof Enum) { + return ((Enum) type).name(); + } + } catch (NoSuchFieldException | IllegalAccessException e) { + log.debug("Failed to extract type from resource: {}", e.getMessage()); + } + + return null; + } } diff --git a/src/test/java/com/agenticcp/core/common/context/TenantContextHolderTest.java b/src/test/java/com/agenticcp/core/common/context/TenantContextHolderTest.java new file mode 100644 index 00000000..d9733fa6 --- /dev/null +++ b/src/test/java/com/agenticcp/core/common/context/TenantContextHolderTest.java @@ -0,0 +1,231 @@ +package com.agenticcp.core.common.context; + +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.user.entity.User; +import com.agenticcp.core.domain.user.entity.Worker; +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 static org.assertj.core.api.Assertions.*; + +/** + * TenantContextHolder 단위 테스트 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@DisplayName("TenantContextHolder 단위 테스트") +class TenantContextHolderTest { + + private Tenant testTenant; + private Worker testWorker; + private User testUser; + + @BeforeEach + void setUp() { + testUser = User.builder() + .username("testuser") + .build(); + testUser.setId(1L); + + testTenant = Tenant.builder() + .tenantKey("tenant-1") + .tenantName("Tenant 1") + .build(); + testTenant.setId(100L); + testTenant.setIsDeleted(false); + + testWorker = Worker.builder() + .workerKey("worker-1") + .user(testUser) + .tenant(testTenant) + .build(); + testWorker.setId(10L); + testWorker.setIsDeleted(false); + } + + @AfterEach + void tearDown() { + TenantContextHolder.clear(); + } + + @Nested + @DisplayName("Tenant 컨텍스트 관리 테스트") + class TenantContextTest { + + @Test + @DisplayName("Tenant를 설정하고 조회할 수 있어야 한다") + void setTenant_ThenGetCurrentTenant_ReturnsTenant() { + // When + TenantContextHolder.setTenant(testTenant); + Tenant result = TenantContextHolder.getCurrentTenant(); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(100L); + assertThat(result.getTenantKey()).isEqualTo("tenant-1"); + } + + @Test + @DisplayName("Tenant Key를 설정하고 조회할 수 있어야 한다") + void setTenantKey_ThenGetCurrentTenantKey_ReturnsTenantKey() { + // When + TenantContextHolder.setTenantKey("tenant-1"); + String result = TenantContextHolder.getCurrentTenantKey(); + + // Then + assertThat(result).isEqualTo("tenant-1"); + } + + @Test + @DisplayName("Tenant가 설정되지 않았으면 getCurrentTenantOrThrow는 예외를 발생시켜야 한다") + void getCurrentTenantOrThrow_WithoutTenant_ThrowsException() { + // When & Then + assertThatThrownBy(TenantContextHolder::getCurrentTenantOrThrow) + .isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("Tenant Key가 설정되지 않았으면 getCurrentTenantKeyOrThrow는 예외를 발생시켜야 한다") + void getCurrentTenantKeyOrThrow_WithoutTenantKey_ThrowsException() { + // When & Then + assertThatThrownBy(TenantContextHolder::getCurrentTenantKeyOrThrow) + .isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("clear()를 호출하면 Tenant 컨텍스트가 제거되어야 한다") + void clear_RemovesTenantContext() { + // Given + TenantContextHolder.setTenant(testTenant); + + // When + TenantContextHolder.clear(); + + // Then + assertThat(TenantContextHolder.getCurrentTenant()).isNull(); + assertThat(TenantContextHolder.getCurrentTenantKey()).isNull(); + } + + @Test + @DisplayName("hasTenantContext()는 Tenant Key가 설정되어 있으면 true를 반환해야 한다") + void hasTenantContext_WithTenantKey_ReturnsTrue() { + // Given + TenantContextHolder.setTenantKey("tenant-1"); + + // When + boolean result = TenantContextHolder.hasTenantContext(); + + // Then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("hasTenantContext()는 Tenant Key가 설정되지 않았으면 false를 반환해야 한다") + void hasTenantContext_WithoutTenantKey_ReturnsFalse() { + // When + boolean result = TenantContextHolder.hasTenantContext(); + + // Then + assertThat(result).isFalse(); + } + } + + @Nested + @DisplayName("Worker 컨텍스트 관리 테스트") + class WorkerContextTest { + + @Test + @DisplayName("Tenant와 Worker를 함께 설정하고 조회할 수 있어야 한다") + void setCurrentTenantAndWorker_ThenGetCurrentWorker_ReturnsWorker() { + // When + TenantContextHolder.setCurrentTenantAndWorker(testTenant, testWorker); + Worker result = TenantContextHolder.getCurrentWorker(); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(10L); + assertThat(result.getWorkerKey()).isEqualTo("worker-1"); + assertThat(TenantContextHolder.getCurrentTenant().getId()).isEqualTo(100L); + } + + @Test + @DisplayName("Worker가 설정되지 않았으면 getCurrentWorkerOrThrow는 예외를 발생시켜야 한다") + void getCurrentWorkerOrThrow_WithoutWorker_ThrowsException() { + // When & Then + assertThatThrownBy(TenantContextHolder::getCurrentWorkerOrThrow) + .isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("Tenant와 Worker를 함께 조회할 수 있어야 한다") + void getCurrentTenantAndWorker_ReturnsBoth() { + // Given + TenantContextHolder.setCurrentTenantAndWorker(testTenant, testWorker); + + // When + TenantContextHolder.TenantWorkerContext context = TenantContextHolder.getCurrentTenantAndWorker(); + + // Then + assertThat(context).isNotNull(); + assertThat(context.getTenant()).isNotNull(); + assertThat(context.getWorker()).isNotNull(); + assertThat(context.getTenantId()).isEqualTo(100L); + assertThat(context.getWorkerId()).isEqualTo(10L); + } + + @Test + @DisplayName("Tenant나 Worker가 설정되지 않았으면 getCurrentTenantAndWorkerOrThrow는 예외를 발생시켜야 한다") + void getCurrentTenantAndWorkerOrThrow_WithoutContext_ThrowsException() { + // When & Then + assertThatThrownBy(TenantContextHolder::getCurrentTenantAndWorkerOrThrow) + .isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("clear()를 호출하면 Worker 컨텍스트도 제거되어야 한다") + void clear_RemovesWorkerContext() { + // Given + TenantContextHolder.setCurrentTenantAndWorker(testTenant, testWorker); + + // When + TenantContextHolder.clear(); + + // Then + assertThat(TenantContextHolder.getCurrentWorker()).isNull(); + } + } + + @Nested + @DisplayName("ThreadLocal 격리 테스트") + class ThreadLocalIsolationTest { + + @Test + @DisplayName("다른 스레드에서 설정한 컨텍스트는 현재 스레드에 영향을 주지 않아야 한다") + void contextIsolation_BetweenThreads_IsIsolated() throws InterruptedException { + // Given + TenantContextHolder.setCurrentTenantAndWorker(testTenant, testWorker); + + // When + Thread otherThread = new Thread(() -> { + Tenant otherTenant = Tenant.builder() + .tenantKey("tenant-2") + .build(); + otherTenant.setId(200L); + TenantContextHolder.setTenant(otherTenant); + }); + otherThread.start(); + otherThread.join(); + + // Then + Tenant currentTenant = TenantContextHolder.getCurrentTenant(); + assertThat(currentTenant).isNotNull(); + assertThat(currentTenant.getId()).isEqualTo(100L); // 원래 스레드의 값 유지 + } + } +} + diff --git a/src/test/java/com/agenticcp/core/common/context/TenantContextInterceptorTest.java b/src/test/java/com/agenticcp/core/common/context/TenantContextInterceptorTest.java new file mode 100644 index 00000000..bb47d571 --- /dev/null +++ b/src/test/java/com/agenticcp/core/common/context/TenantContextInterceptorTest.java @@ -0,0 +1,290 @@ +package com.agenticcp.core.common.context; + +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.user.entity.User; +import com.agenticcp.core.domain.user.entity.Worker; +import com.agenticcp.core.domain.user.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +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.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +/** + * TenantContextInterceptor 단위 테스트 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("TenantContextInterceptor 단위 테스트") +class TenantContextInterceptorTest { + + @Mock + private TenantContextService tenantContextService; + + @Mock + private UserRepository userRepository; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @InjectMocks + private TenantContextInterceptor interceptor; + + private User testUser; + private Tenant testTenant; + private Worker testWorker; + + @BeforeEach + void setUp() { + testUser = User.builder() + .username("testuser") + .build(); + testUser.setId(1L); + + testTenant = Tenant.builder() + .tenantKey("tenant-1") + .tenantName("Tenant 1") + .build(); + testTenant.setId(100L); + testTenant.setIsDeleted(false); + + testWorker = Worker.builder() + .workerKey("worker-1") + .user(testUser) + .tenant(testTenant) + .build(); + testWorker.setId(10L); + testWorker.setIsDeleted(false); + + // SecurityContext 설정 + Authentication authentication = new UsernamePasswordAuthenticationToken("testuser", null); + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(authentication); + SecurityContextHolder.setContext(securityContext); + } + + @AfterEach + void tearDown() { + TenantContextHolder.clear(); + SecurityContextHolder.clearContext(); + } + + @Nested + @DisplayName("정상 처리 테스트") + class SuccessTest { + + @Test + @DisplayName("X-Tenant-Id 헤더가 있고 User가 Tenant에 속하면 컨텍스트를 설정하고 true를 반환해야 한다") + void preHandle_WithValidTenantId_SetsContextAndReturnsTrue() throws Exception { + // Given + when(request.getRequestURI()).thenReturn("/api/vms"); + when(request.getHeader("X-Tenant-Id")).thenReturn("100"); + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + when(tenantContextService.validateTenantAccessOrThrow(1L, 100L)) + .thenReturn(testWorker); + + // When + boolean result = interceptor.preHandle(request, response, null); + + // Then + assertThat(result).isTrue(); + assertThat(TenantContextHolder.getCurrentTenant()).isNotNull(); + assertThat(TenantContextHolder.getCurrentTenant().getId()).isEqualTo(100L); + assertThat(TenantContextHolder.getCurrentWorker()).isNotNull(); + assertThat(TenantContextHolder.getCurrentWorker().getId()).isEqualTo(10L); + verify(tenantContextService).validateTenantAccessOrThrow(1L, 100L); + } + } + + @Nested + @DisplayName("스킵 경로 테스트") + class SkipPathTest { + + @Test + @DisplayName("/health 경로는 스킵해야 한다") + void preHandle_WithHealthPath_Skips() throws Exception { + // Given + when(request.getRequestURI()).thenReturn("/health"); + + // When + boolean result = interceptor.preHandle(request, response, null); + + // Then + assertThat(result).isTrue(); + verify(tenantContextService, never()).validateTenantAccessOrThrow(anyLong(), anyLong()); + } + + @Test + @DisplayName("/auth 경로는 스킵해야 한다") + void preHandle_WithAuthPath_Skips() throws Exception { + // Given + when(request.getRequestURI()).thenReturn("/auth/login"); + + // When + boolean result = interceptor.preHandle(request, response, null); + + // Then + assertThat(result).isTrue(); + verify(tenantContextService, never()).validateTenantAccessOrThrow(anyLong(), anyLong()); + } + + @Test + @DisplayName("/swagger 경로는 스킵해야 한다") + void preHandle_WithSwaggerPath_Skips() throws Exception { + // Given + when(request.getRequestURI()).thenReturn("/swagger-ui/index.html"); + + // When + boolean result = interceptor.preHandle(request, response, null); + + // Then + assertThat(result).isTrue(); + verify(tenantContextService, never()).validateTenantAccessOrThrow(anyLong(), anyLong()); + } + } + + @Nested + @DisplayName("인증 없음 테스트") + class NoAuthenticationTest { + + @Test + @DisplayName("인증이 없으면 스킵해야 한다") + void preHandle_WithoutAuthentication_Skips() throws Exception { + // Given + SecurityContextHolder.clearContext(); + when(request.getRequestURI()).thenReturn("/api/vms"); + + // When + boolean result = interceptor.preHandle(request, response, null); + + // Then + assertThat(result).isTrue(); + verify(tenantContextService, never()).validateTenantAccessOrThrow(anyLong(), anyLong()); + } + + @Test + @DisplayName("User를 찾을 수 없으면 스킵해야 한다") + void preHandle_WithUserNotFound_Skips() throws Exception { + // Given + when(request.getRequestURI()).thenReturn("/api/vms"); + when(userRepository.findByUsername("testuser")).thenReturn(Optional.empty()); + + // When + boolean result = interceptor.preHandle(request, response, null); + + // Then + assertThat(result).isTrue(); + verify(tenantContextService, never()).validateTenantAccessOrThrow(anyLong(), anyLong()); + } + } + + @Nested + @DisplayName("Tenant ID 헤더 테스트") + class TenantIdHeaderTest { + + @Test + @DisplayName("X-Tenant-Id 헤더가 없으면 예외를 발생시켜야 한다") + void preHandle_WithoutTenantIdHeader_ThrowsException() throws Exception { + // Given + when(request.getRequestURI()).thenReturn("/api/vms"); + when(request.getHeader("X-Tenant-Id")).thenReturn(null); + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + + // When & Then + assertThatThrownBy(() -> interceptor.preHandle(request, response, null)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("Tenant ID is required"); + } + + @Test + @DisplayName("X-Tenant-Id 헤더가 빈 문자열이면 예외를 발생시켜야 한다") + void preHandle_WithEmptyTenantIdHeader_ThrowsException() throws Exception { + // Given + when(request.getRequestURI()).thenReturn("/api/vms"); + when(request.getHeader("X-Tenant-Id")).thenReturn(""); + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + + // When & Then + assertThatThrownBy(() -> interceptor.preHandle(request, response, null)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("Tenant ID is required"); + } + + @Test + @DisplayName("X-Tenant-Id 헤더가 숫자가 아니면 예외를 발생시켜야 한다") + void preHandle_WithInvalidTenantIdFormat_ThrowsException() throws Exception { + // Given + when(request.getRequestURI()).thenReturn("/api/vms"); + when(request.getHeader("X-Tenant-Id")).thenReturn("invalid"); + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + + // When & Then + assertThatThrownBy(() -> interceptor.preHandle(request, response, null)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("Invalid tenant ID format"); + } + } + + @Nested + @DisplayName("Tenant 접근 검증 실패 테스트") + class TenantAccessValidationFailureTest { + + @Test + @DisplayName("User가 Tenant에 속하지 않으면 예외를 발생시켜야 한다") + void preHandle_WithNoTenantAccess_ThrowsException() throws Exception { + // Given + when(request.getRequestURI()).thenReturn("/api/vms"); + when(request.getHeader("X-Tenant-Id")).thenReturn("100"); + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + when(tenantContextService.validateTenantAccessOrThrow(1L, 100L)) + .thenThrow(new BusinessException(com.agenticcp.core.common.enums.CommonErrorCode.FORBIDDEN, "User is not a member of this tenant")); + + // When & Then + assertThatThrownBy(() -> interceptor.preHandle(request, response, null)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("User is not a member of this tenant"); + } + } + + @Nested + @DisplayName("afterCompletion 테스트") + class AfterCompletionTest { + + @Test + @DisplayName("요청 처리 완료 후 컨텍스트를 정리해야 한다") + void afterCompletion_ClearsContext() { + // Given + TenantContextHolder.setCurrentTenantAndWorker(testTenant, testWorker); + + // When + interceptor.afterCompletion(request, response, null, null); + + // Then + assertThat(TenantContextHolder.getCurrentTenant()).isNull(); + assertThat(TenantContextHolder.getCurrentWorker()).isNull(); + } + } +} + diff --git a/src/test/java/com/agenticcp/core/common/context/TenantContextServiceTest.java b/src/test/java/com/agenticcp/core/common/context/TenantContextServiceTest.java new file mode 100644 index 00000000..dd0d6f80 --- /dev/null +++ b/src/test/java/com/agenticcp/core/common/context/TenantContextServiceTest.java @@ -0,0 +1,244 @@ +package com.agenticcp.core.common.context; + +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.user.entity.User; +import com.agenticcp.core.domain.user.entity.Worker; +import com.agenticcp.core.domain.user.repository.WorkerRepository; +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.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.Collections; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +/** + * TenantContextService 단위 테스트 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("TenantContextService 단위 테스트") +class TenantContextServiceTest { + + @Mock + private WorkerRepository workerRepository; + + @Mock + private TenantRepository tenantRepository; + + @InjectMocks + private TenantContextService tenantContextService; + + private User testUser; + private Tenant testTenant1; + private Tenant testTenant2; + private Worker testWorker1; + private Worker testWorker2; + + @BeforeEach + void setUp() { + testUser = User.builder() + .username("testuser") + .build(); + testUser.setId(1L); + + testTenant1 = Tenant.builder() + .tenantKey("tenant-1") + .tenantName("Tenant 1") + .build(); + testTenant1.setId(100L); + testTenant1.setIsDeleted(false); + + testTenant2 = Tenant.builder() + .tenantKey("tenant-2") + .tenantName("Tenant 2") + .build(); + testTenant2.setId(200L); + testTenant2.setIsDeleted(false); + + testWorker1 = Worker.builder() + .workerKey("worker-1") + .user(testUser) + .tenant(testTenant1) + .build(); + testWorker1.setId(10L); + testWorker1.setIsDeleted(false); + + testWorker2 = Worker.builder() + .workerKey("worker-2") + .user(testUser) + .tenant(testTenant2) + .build(); + testWorker2.setId(20L); + testWorker2.setIsDeleted(false); + } + + @Nested + @DisplayName("getAvailableTenantIds 테스트") + class GetAvailableTenantIdsTest { + + @Test + @DisplayName("User가 속한 모든 Tenant ID 목록을 반환해야 한다") + void getAvailableTenantIds_WithMultipleTenants_ReturnsAllTenantIds() { + // Given + when(workerRepository.findTenantIdsByUserId(1L)) + .thenReturn(Arrays.asList(100L, 200L)); + + // When + List result = tenantContextService.getAvailableTenantIds(1L); + + // Then + assertThat(result).hasSize(2); + assertThat(result).containsExactlyInAnyOrder(100L, 200L); + verify(workerRepository).findTenantIdsByUserId(1L); + } + + @Test + @DisplayName("User가 속한 Tenant가 없으면 빈 목록을 반환해야 한다") + void getAvailableTenantIds_WithNoTenants_ReturnsEmptyList() { + // Given + when(workerRepository.findTenantIdsByUserId(1L)) + .thenReturn(Collections.emptyList()); + + // When + List result = tenantContextService.getAvailableTenantIds(1L); + + // Then + assertThat(result).isEmpty(); + verify(workerRepository).findTenantIdsByUserId(1L); + } + } + + @Nested + @DisplayName("validateTenantAccess 테스트") + class ValidateTenantAccessTest { + + @Test + @DisplayName("User가 Tenant에 속하면 Worker를 반환해야 한다") + void validateTenantAccess_WithValidAccess_ReturnsWorker() { + // Given + when(tenantRepository.findById(100L)) + .thenReturn(Optional.of(testTenant1)); + when(workerRepository.findByUserIdAndTenantIdAndIsDeletedFalse(1L, 100L)) + .thenReturn(Optional.of(testWorker1)); + + // When + Optional result = tenantContextService.validateTenantAccess(1L, 100L); + + // Then + assertThat(result).isPresent(); + assertThat(result.get().getId()).isEqualTo(10L); + assertThat(result.get().getTenant().getId()).isEqualTo(100L); + verify(tenantRepository).findById(100L); + verify(workerRepository).findByUserIdAndTenantIdAndIsDeletedFalse(1L, 100L); + } + + @Test + @DisplayName("Tenant가 존재하지 않으면 빈 Optional을 반환해야 한다") + void validateTenantAccess_WithNonExistentTenant_ReturnsEmpty() { + // Given + when(tenantRepository.findById(999L)) + .thenReturn(Optional.empty()); + + // When + Optional result = tenantContextService.validateTenantAccess(1L, 999L); + + // Then + assertThat(result).isEmpty(); + verify(tenantRepository).findById(999L); + verify(workerRepository, never()).findByUserIdAndTenantIdAndIsDeletedFalse(anyLong(), anyLong()); + } + + @Test + @DisplayName("Tenant가 삭제되었으면 빈 Optional을 반환해야 한다") + void validateTenantAccess_WithDeletedTenant_ReturnsEmpty() { + // Given + Tenant deletedTenant = Tenant.builder() + .tenantKey("deleted-tenant") + .build(); + deletedTenant.setId(300L); + deletedTenant.setIsDeleted(true); + when(tenantRepository.findById(300L)) + .thenReturn(Optional.of(deletedTenant)); + + // When + Optional result = tenantContextService.validateTenantAccess(1L, 300L); + + // Then + assertThat(result).isEmpty(); + verify(tenantRepository).findById(300L); + verify(workerRepository, never()).findByUserIdAndTenantIdAndIsDeletedFalse(anyLong(), anyLong()); + } + + @Test + @DisplayName("User가 Tenant에 속하지 않으면 빈 Optional을 반환해야 한다") + void validateTenantAccess_WithNoAccess_ReturnsEmpty() { + // Given + when(tenantRepository.findById(100L)) + .thenReturn(Optional.of(testTenant1)); + when(workerRepository.findByUserIdAndTenantIdAndIsDeletedFalse(1L, 100L)) + .thenReturn(Optional.empty()); + + // When + Optional result = tenantContextService.validateTenantAccess(1L, 100L); + + // Then + assertThat(result).isEmpty(); + verify(tenantRepository).findById(100L); + verify(workerRepository).findByUserIdAndTenantIdAndIsDeletedFalse(1L, 100L); + } + } + + @Nested + @DisplayName("validateTenantAccessOrThrow 테스트") + class ValidateTenantAccessOrThrowTest { + + @Test + @DisplayName("User가 Tenant에 속하면 Worker를 반환해야 한다") + void validateTenantAccessOrThrow_WithValidAccess_ReturnsWorker() { + // Given + when(tenantRepository.findById(100L)) + .thenReturn(Optional.of(testTenant1)); + when(workerRepository.findByUserIdAndTenantIdAndIsDeletedFalse(1L, 100L)) + .thenReturn(Optional.of(testWorker1)); + + // When + Worker result = tenantContextService.validateTenantAccessOrThrow(1L, 100L); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(10L); + assertThat(result.getTenant().getId()).isEqualTo(100L); + } + + @Test + @DisplayName("User가 Tenant에 속하지 않으면 예외를 발생시켜야 한다") + void validateTenantAccessOrThrow_WithNoAccess_ThrowsException() { + // Given + when(tenantRepository.findById(100L)) + .thenReturn(Optional.of(testTenant1)); + when(workerRepository.findByUserIdAndTenantIdAndIsDeletedFalse(1L, 100L)) + .thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> tenantContextService.validateTenantAccessOrThrow(1L, 100L)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("User is not a member of this tenant"); + } + } +} + diff --git a/src/test/java/com/agenticcp/core/common/service/AuthenticationServiceTest.java b/src/test/java/com/agenticcp/core/common/service/AuthenticationServiceTest.java index 46c3e887..64825e30 100644 --- a/src/test/java/com/agenticcp/core/common/service/AuthenticationServiceTest.java +++ b/src/test/java/com/agenticcp/core/common/service/AuthenticationServiceTest.java @@ -13,6 +13,7 @@ import com.agenticcp.core.domain.tenant.service.TenantService; import com.agenticcp.core.domain.user.service.UserAuthHistoryService; import com.agenticcp.core.common.service.TwoFactorService; +import com.agenticcp.core.common.context.TenantContextService; import jakarta.servlet.http.HttpServletRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -54,6 +55,9 @@ class AuthenticationServiceTest { @Mock private TwoFactorService twoFactorService; + @Mock + private TenantContextService tenantContextService; + @Mock private HttpServletRequest httpRequest; @@ -62,7 +66,7 @@ class AuthenticationServiceTest { @BeforeEach void setUp() { authenticationService = new AuthenticationService( - userService, jwtService, passwordEncoder, tenantService, + userService, tenantContextService, jwtService, passwordEncoder, tenantService, twoFactorService, authHistoryService ); } diff --git a/src/test/java/com/agenticcp/core/controller/VmControllerTest.java b/src/test/java/com/agenticcp/core/controller/VmControllerTest.java index a43d126d..e65b77d2 100644 --- a/src/test/java/com/agenticcp/core/controller/VmControllerTest.java +++ b/src/test/java/com/agenticcp/core/controller/VmControllerTest.java @@ -63,8 +63,10 @@ void setUp() { testInstance = CloudResource.builder() .resourceId("i-1234567890abcdef0") - .resourceName("test-instance") - .displayName("Test Instance") + .name("test-instance") + .provider("AWS") + .region("us-east-1") + .type("INSTANCE") .build(); } @@ -98,7 +100,7 @@ void setUp() { mockMvc.perform(get(BASE_URL + "/{instanceId}", "AWS", "123456789012", "i-1234567890abcdef0")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.resourceId").value("i-1234567890abcdef0")) - .andExpect(jsonPath("$.data.resourceName").value("test-instance")); + .andExpect(jsonPath("$.data.name").value("test-instance")); } @Test @@ -131,7 +133,7 @@ void setUp() { .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.data.resourceId").value("i-1234567890abcdef0")) - .andExpect(jsonPath("$.data.resourceName").value("test-instance")); + .andExpect(jsonPath("$.data.name").value("test-instance")); } @Test diff --git a/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketDiscoveryAdapterTest.java b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketDiscoveryAdapterTest.java index 671338c2..64919cdb 100644 --- a/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketDiscoveryAdapterTest.java +++ b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketDiscoveryAdapterTest.java @@ -104,7 +104,7 @@ void listBuckets_Success() throws Exception { CloudResource mockResource = CloudResource.builder() .resourceId("bucket-" + BUCKET_NAME) - .resourceName(BUCKET_NAME) + .name(BUCKET_NAME) .build(); when(mapper.toCloudResource(any(), any())).thenReturn(mockResource); @@ -127,7 +127,7 @@ void listBuckets_Success() throws Exception { Page result = adapter.listContainers(query); assertThat(result.getContent()).hasSize(1); - assertThat(result.getContent().get(0).getResourceName()).isEqualTo(BUCKET_NAME); + assertThat(result.getContent().get(0).getName()).isEqualTo(BUCKET_NAME); verify(accountCredentialManagementPort).getSession(eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(CloudProvider.ProviderType.AWS)); verify(taggingClient).getResources(any(GetResourcesRequest.class)); } diff --git a/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketManagementAdapterTest.java b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketManagementAdapterTest.java index 262c745a..b332452f 100644 --- a/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketManagementAdapterTest.java +++ b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketManagementAdapterTest.java @@ -95,7 +95,10 @@ void createContainer_Success() { CloudResource mockResource = CloudResource.builder() .resourceId(CONTAINER_NAME) - .resourceName(CONTAINER_NAME) + .name(CONTAINER_NAME) + .provider("AWS") + .region("us-east-1") + .type("BUCKET") .build(); when(mapper.toCloudResource(any(Bucket.class), any(CloudProvider.class))).thenReturn(mockResource); @@ -108,7 +111,7 @@ void createContainer_Success() { // Then assertThat(result).isNotNull(); - assertThat(result.getResourceName()).isEqualTo(CONTAINER_NAME); + assertThat(result.getName()).isEqualTo(CONTAINER_NAME); verify(awsS3Config).createS3Client(eq(mockSession), eq("us-east-1")); verify(s3Client).createBucket(any(CreateBucketRequest.class)); @@ -237,7 +240,10 @@ void updateContainer_Success() { CloudResource mockResource = CloudResource.builder() .resourceId(CONTAINER_NAME) - .resourceName(CONTAINER_NAME) + .name(CONTAINER_NAME) + .provider("AWS") + .region("us-east-1") + .type("BUCKET") .build(); when(mapper.toCloudResource(any(Bucket.class), any(CloudProvider.class))).thenReturn(mockResource); @@ -251,7 +257,7 @@ void updateContainer_Success() { // Then assertThat(result).isNotNull(); - assertThat(result.getResourceName()).isEqualTo(CONTAINER_NAME); + assertThat(result.getName()).isEqualTo(CONTAINER_NAME); verify(awsS3Config).createS3Client(eq(mockSession), isNull()); verify(s3Client).headBucket(any(HeadBucketRequest.class)); diff --git a/src/test/java/com/agenticcp/core/domain/cloud/port/VmManagementContractTest.java b/src/test/java/com/agenticcp/core/domain/cloud/port/VmManagementContractTest.java index e648af3d..607cece3 100644 --- a/src/test/java/com/agenticcp/core/domain/cloud/port/VmManagementContractTest.java +++ b/src/test/java/com/agenticcp/core/domain/cloud/port/VmManagementContractTest.java @@ -56,8 +56,10 @@ class VmManagementContractTest { void setUp() { testInstance = CloudResource.builder() .resourceId("i-1234567890abcdef0") - .resourceName("test-instance") - .displayName("Test Instance") + .name("test-instance") + .provider("AWS") + .region("us-east-1") + .type("INSTANCE") .build(); testQuery = VmQuery.builder() diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/aws/VmUseCaseServiceCreateTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/aws/VmUseCaseServiceCreateTest.java index 2e945062..76a60902 100644 --- a/src/test/java/com/agenticcp/core/domain/cloud/service/aws/VmUseCaseServiceCreateTest.java +++ b/src/test/java/com/agenticcp/core/domain/cloud/service/aws/VmUseCaseServiceCreateTest.java @@ -91,7 +91,10 @@ void tearDown() { String expectedInstanceId = "i-1234567890abcdef0"; CloudResource expectedCloudResource = CloudResource.builder() .resourceId(expectedInstanceId) - .resourceName(expectedInstanceId) + .name(expectedInstanceId) + .provider("AWS") + .region("us-east-1") + .type("INSTANCE") .build(); when(vmLifecyclePort.createInstance(any(VmCreateCommand.class))).thenReturn(expectedInstanceId); @@ -126,7 +129,10 @@ void tearDown() { String expectedInstanceId = "i-abcdef1234567890"; CloudResource expectedCloudResource = CloudResource.builder() .resourceId(expectedInstanceId) - .resourceName(expectedInstanceId) + .name(expectedInstanceId) + .provider("AWS") + .region("us-east-1") + .type("INSTANCE") .build(); when(vmLifecyclePort.createInstance(any(VmCreateCommand.class))).thenReturn(expectedInstanceId); @@ -193,7 +199,10 @@ void tearDown() { String expectedInstanceId = "i-tagged1234567890"; CloudResource expectedCloudResource = CloudResource.builder() .resourceId(expectedInstanceId) - .resourceName("test-instance") + .name("test-instance") + .provider("AWS") + .region("us-east-1") + .type("INSTANCE") .build(); when(vmLifecyclePort.createInstance(any(VmCreateCommand.class))).thenReturn(expectedInstanceId); @@ -208,7 +217,7 @@ void tearDown() { // Then assertThat(result).isNotNull(); assertThat(result.getResourceId()).isEqualTo(expectedInstanceId); - assertThat(result.getResourceName()).isEqualTo("test-instance"); + assertThat(result.getName()).isEqualTo("test-instance"); // 포트 호출 확인 verify(vmLifecyclePort).createInstance(any(VmCreateCommand.class)); diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/aws/VmUseCaseServiceTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/aws/VmUseCaseServiceTest.java index a12da12c..ae4942d5 100644 --- a/src/test/java/com/agenticcp/core/domain/cloud/service/aws/VmUseCaseServiceTest.java +++ b/src/test/java/com/agenticcp/core/domain/cloud/service/aws/VmUseCaseServiceTest.java @@ -100,7 +100,7 @@ void tearDown() { CloudResource resource = CloudResource.builder() .resourceId("i-1234567890abcdef0") - .resourceName("test-instance") + .name("test-instance") .build(); Page expectedPage = new PageImpl<>( @@ -128,7 +128,7 @@ void tearDown() { CloudResource resource = CloudResource.builder() .resourceId(instanceId) - .resourceName("test-instance") + .name("test-instance") .build(); when(vmDiscoveryPort.getInstance(eq(instanceId), any(CloudSessionCredential.class))).thenReturn(Optional.of(resource)); diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/storage/ObjectStorageUseCaseServiceDbSyncTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/storage/ObjectStorageUseCaseServiceDbSyncTest.java index 251a0f3b..f50ae097 100644 --- a/src/test/java/com/agenticcp/core/domain/cloud/service/storage/ObjectStorageUseCaseServiceDbSyncTest.java +++ b/src/test/java/com/agenticcp/core/domain/cloud/service/storage/ObjectStorageUseCaseServiceDbSyncTest.java @@ -115,7 +115,7 @@ void createContainer_Success_SavesCloudResource() { CloudResource mockCreatedContainer = CloudResource.builder() .resourceId(CONTAINER_NAME) - .resourceName(CONTAINER_NAME) + .name(CONTAINER_NAME) .build(); when(managementPort.createContainer(any())).thenReturn(mockCreatedContainer); @@ -146,7 +146,7 @@ void createContainer_DbSaveFails_CompensatingTransactionExecuted() { CloudResource mockCreatedContainer = CloudResource.builder() .resourceId(CONTAINER_NAME) - .resourceName(CONTAINER_NAME) + .name(CONTAINER_NAME) .build(); when(managementPort.createContainer(any())).thenReturn(mockCreatedContainer); @@ -179,7 +179,7 @@ void createContainer_CompensationFails_GhostResourceWarningLogged() { CloudResource mockCreatedContainer = CloudResource.builder() .resourceId(CONTAINER_NAME) - .resourceName(CONTAINER_NAME) + .name(CONTAINER_NAME) .build(); when(managementPort.createContainer(any())).thenReturn(mockCreatedContainer); diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/storage/S3BucketUseCaseServiceTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/storage/S3BucketUseCaseServiceTest.java index 286229f5..52983827 100644 --- a/src/test/java/com/agenticcp/core/domain/cloud/service/storage/S3BucketUseCaseServiceTest.java +++ b/src/test/java/com/agenticcp/core/domain/cloud/service/storage/S3BucketUseCaseServiceTest.java @@ -115,8 +115,10 @@ void setUp() { expectedContainer = CloudResource.builder() .resourceId("container-" + CONTAINER_NAME) - .resourceName(CONTAINER_NAME) - .displayName("Test Container") + .name(CONTAINER_NAME) + .provider("AWS") + .region("us-east-1") + .type("BUCKET") .build(); } @@ -135,7 +137,7 @@ void createContainer_Success() { // Then assertThat(result).isNotNull(); - assertThat(result.getResourceName()).isEqualTo(CONTAINER_NAME); + assertThat(result.getName()).isEqualTo(CONTAINER_NAME); assertThat(result.getResourceId()).isEqualTo("container-" + CONTAINER_NAME); verify(capabilityGuard).ensureSupported(AWS, "S3", "BUCKET", CapabilityGuard.Operation.TAGGING); @@ -206,8 +208,10 @@ void setUp() { expectedContainer = CloudResource.builder() .resourceId("container-" + CONTAINER_NAME) - .resourceName(CONTAINER_NAME) - .displayName("Updated Test Container") + .name(CONTAINER_NAME) + .provider("AWS") + .region("us-east-1") + .type("BUCKET") .build(); } @@ -225,8 +229,7 @@ void updateContainer_Success() { // Then assertThat(result).isNotNull(); - assertThat(result.getResourceName()).isEqualTo(CONTAINER_NAME); - assertThat(result.getDisplayName()).isEqualTo("Updated Test Container"); + assertThat(result.getName()).isEqualTo(CONTAINER_NAME); verify(accountCredentialManagementPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS); verify(capabilityGuard).ensureSupported(AWS, "S3", "BUCKET", CapabilityGuard.Operation.TAGGING); @@ -256,12 +259,18 @@ void setUp() { CloudResource container1 = CloudResource.builder() .resourceId("container-1") - .resourceName("test-container-1") + .name("test-container-1") + .provider("AWS") + .region("us-east-1") + .type("BUCKET") .build(); CloudResource container2 = CloudResource.builder() .resourceId("container-2") - .resourceName("test-container-2") + .name("test-container-2") + .provider("AWS") + .region("us-east-1") + .type("BUCKET") .build(); expectedPage = new PageImpl<>(List.of(container1, container2), PageRequest.of(0, 10), 2); @@ -280,7 +289,7 @@ void listContainers_Success() { assertThat(result).isNotNull(); assertThat(result.getTotalElements()).isEqualTo(2); assertThat(result.getContent()).hasSize(2); - assertThat(result.getContent().get(0).getResourceName()).isEqualTo("test-container-1"); + assertThat(result.getContent().get(0).getName()).isEqualTo("test-container-1"); verify(discoveryPort).listContainers(query); } @@ -314,8 +323,10 @@ class GetContainerTest { void setUp() { expectedContainer = CloudResource.builder() .resourceId("container-" + CONTAINER_NAME) - .resourceName(CONTAINER_NAME) - .displayName("Test Container") + .name(CONTAINER_NAME) + .provider("AWS") + .region("us-east-1") + .type("BUCKET") .build(); } @@ -330,7 +341,7 @@ void getContainer_Success() { // Then assertThat(result).isNotNull(); - assertThat(result.getResourceName()).isEqualTo(CONTAINER_NAME); + assertThat(result.getName()).isEqualTo(CONTAINER_NAME); assertThat(result.getResourceId()).isEqualTo("container-" + CONTAINER_NAME); verify(discoveryPort).getContainer(ACCOUNT_SCOPE, CONTAINER_NAME); diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/vm/VmUseCaseServiceDbSyncTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/vm/VmUseCaseServiceDbSyncTest.java index f5970b1a..5f837817 100644 --- a/src/test/java/com/agenticcp/core/domain/cloud/service/vm/VmUseCaseServiceDbSyncTest.java +++ b/src/test/java/com/agenticcp/core/domain/cloud/service/vm/VmUseCaseServiceDbSyncTest.java @@ -8,7 +8,7 @@ import com.agenticcp.core.domain.cloud.dto.VmDeleteRequest; import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; import com.agenticcp.core.domain.cloud.entity.CloudResource; -import com.agenticcp.core.domain.cloud.entity.CloudResource.LifecycleState; +// LifecycleState removed - using JSON status field instead import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort; @@ -122,7 +122,10 @@ void createInstance_Success_SavesCloudResource() { CloudResource mockCloudResource = CloudResource.builder() .resourceId(INSTANCE_ID) - .resourceName("test-instance") + .name("test-instance") + .provider("AWS") + .region("us-east-1") + .type("INSTANCE") .build(); when(vmLifecyclePort.createInstance(any())).thenReturn(INSTANCE_ID); @@ -139,7 +142,7 @@ void createInstance_Success_SavesCloudResource() { // Then assertThat(result).isNotNull(); assertThat(result.getResourceId()).isEqualTo(INSTANCE_ID); - assertThat(result.getResourceName()).isEqualTo("test-instance"); + assertThat(result.getName()).isEqualTo("test-instance"); verify(resourceHelper).registerResource( eq(PROVIDER_TYPE), @@ -193,7 +196,7 @@ void startInstance_Success_UpdatesLifecycleStateToRunning() { // Then verify(vmLifecyclePort).startInstance(eq(INSTANCE_ID), any()); - verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq(LifecycleState.RUNNING)); + verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq("running")); } @Test @@ -207,7 +210,7 @@ void stopInstance_Success_UpdatesLifecycleStateToStopped() { // Then verify(vmLifecyclePort).stopInstance(eq(INSTANCE_ID), any()); - verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq(LifecycleState.STOPPED)); + verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq("stopped")); } @Test @@ -221,7 +224,7 @@ void rebootInstance_Success_KeepsLifecycleStateRunning() { // Then verify(vmLifecyclePort).rebootInstance(eq(INSTANCE_ID), any()); - verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq(LifecycleState.RUNNING)); + verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq("running")); } @Test @@ -235,7 +238,7 @@ void terminateInstance_Success_UpdatesLifecycleStateToTerminated() { // Then verify(vmLifecyclePort).terminateInstance(eq(INSTANCE_ID), any()); - verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq(LifecycleState.TERMINATED)); + verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq("terminated")); } @Test @@ -250,7 +253,7 @@ void startInstance_ResourceNotInDb_CspOperationSucceeds() { // Then verify(vmLifecyclePort).startInstance(eq(INSTANCE_ID), any()); // CSP 작업 성공 - verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq(LifecycleState.RUNNING)); + verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq("running")); } } diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseServiceDbSyncTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseServiceDbSyncTest.java index 1059e29f..07fb101b 100644 --- a/src/test/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseServiceDbSyncTest.java +++ b/src/test/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseServiceDbSyncTest.java @@ -114,7 +114,10 @@ void createVpc_Success_SavesCloudResource() { CloudResource mockCreatedVpc = CloudResource.builder() .resourceId(VPC_ID) - .resourceName(VPC_NAME) + .name(VPC_NAME) + .provider("AWS") + .region("us-east-1") + .type("VPC") .build(); when(vpcManagementPort.createVpc(any())).thenReturn(mockCreatedVpc); @@ -147,7 +150,10 @@ void createVpc_DbSaveFails_CompensatingTransactionExecuted() { CloudResource mockCreatedVpc = CloudResource.builder() .resourceId(VPC_ID) - .resourceName(VPC_NAME) + .name(VPC_NAME) + .provider("AWS") + .region("us-east-1") + .type("VPC") .build(); when(vpcManagementPort.createVpc(any())).thenReturn(mockCreatedVpc); @@ -181,7 +187,7 @@ void createVpc_NoVpcName_UsesVpcIdAsResourceName() { CloudResource mockCreatedVpc = CloudResource.builder() .resourceId(VPC_ID) - .resourceName(VPC_ID) + .name(VPC_ID) .build(); when(vpcManagementPort.createVpc(any())).thenReturn(mockCreatedVpc); diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseServiceTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseServiceTest.java index 2d395459..0b689b1c 100644 --- a/src/test/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseServiceTest.java +++ b/src/test/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseServiceTest.java @@ -848,9 +848,12 @@ private CloudSessionCredential createMockSession() { private CloudResource createMockVpcResource() { CloudResource resource = CloudResource.builder() .resourceId("vpc-12345678") - .resourceName("test-vpc") - .resourceType(CloudResource.ResourceType.NETWORK) - .lifecycleState(CloudResource.LifecycleState.RUNNING) + .name("test-vpc") + .provider("AWS") + .region("us-east-1") + .type("VPC") + + .build(); return resource; } diff --git a/src/test/java/com/agenticcp/core/domain/platform/service/MultiCloudEnvironmentServiceTest.java b/src/test/java/com/agenticcp/core/domain/platform/service/MultiCloudEnvironmentServiceTest.java index 210ed1b9..fefc7e70 100644 --- a/src/test/java/com/agenticcp/core/domain/platform/service/MultiCloudEnvironmentServiceTest.java +++ b/src/test/java/com/agenticcp/core/domain/platform/service/MultiCloudEnvironmentServiceTest.java @@ -213,7 +213,7 @@ void detectEnvironment_ResourcesWithoutProvider_IgnoresAndReturnsOnPremise() { String tenantId = "tenant-no-provider"; CloudResource resourceWithoutProvider = CloudResource.builder() .resourceId("resource-no-provider") - .resourceName("Resource Without Provider") + .name("Resource Without Provider") .provider(null) // Provider 없음 .build(); @@ -239,13 +239,15 @@ void detectEnvironment_MixedResources_IgnoresNullProviders() { CloudResource validResource = CloudResource.builder() .resourceId("valid-resource") - .resourceName("Valid Resource") - .provider(awsProvider) + .name("Valid Resource") + .provider("AWS") + .region("us-east-1") + .type("INSTANCE") .build(); CloudResource invalidResource = CloudResource.builder() .resourceId("invalid-resource") - .resourceName("Invalid Resource") + .name("Invalid Resource") .provider(null) // Provider 없음 .build(); @@ -273,8 +275,8 @@ private List createMockResources(CloudProvider.ProviderType... pr return CloudResource.builder() .resourceId("resource-" + providerType.name()) - .resourceName(providerType.name() + " Resource") - .provider(provider) + .name(providerType.name() + " Resource") + .provider("AWS") .build(); }) .toList(); diff --git a/src/test/java/com/agenticcp/core/domain/user/repository/WorkerRepositoryTest.java b/src/test/java/com/agenticcp/core/domain/user/repository/WorkerRepositoryTest.java new file mode 100644 index 00000000..ea795aed --- /dev/null +++ b/src/test/java/com/agenticcp/core/domain/user/repository/WorkerRepositoryTest.java @@ -0,0 +1,268 @@ +package com.agenticcp.core.domain.user.repository; + +import com.agenticcp.core.domain.organization.entity.Organization; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.user.entity.User; +import com.agenticcp.core.domain.user.entity.Worker; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; + +/** + * WorkerRepository 통합 테스트 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@DataJpaTest +@ActiveProfiles("test") +@DisplayName("WorkerRepository 통합 테스트") +class WorkerRepositoryTest { + + @Autowired + private TestEntityManager entityManager; + + @Autowired + private WorkerRepository workerRepository; + + private User testUser; + private Tenant testTenant1; + private Tenant testTenant2; + private Organization testOrganization; + private Worker testWorker1; + private Worker testWorker2; + + @BeforeEach + void setUp() { + // User 생성 + testUser = User.builder() + .username("testuser") + .email("test@example.com") + .name("Test User") + .build(); + testUser = entityManager.persistAndFlush(testUser); + + // Organization 생성 + testOrganization = Organization.builder() + .name("Test Organization") + .build(); + testOrganization = entityManager.persistAndFlush(testOrganization); + + // Tenant 생성 + testTenant1 = Tenant.builder() + .tenantKey("tenant-1") + .tenantName("Tenant 1") + .build(); + testTenant1.setIsDeleted(false); + testTenant1 = entityManager.persistAndFlush(testTenant1); + + testTenant2 = Tenant.builder() + .tenantKey("tenant-2") + .tenantName("Tenant 2") + .build(); + testTenant2.setIsDeleted(false); + testTenant2 = entityManager.persistAndFlush(testTenant2); + + // Worker 생성 + testWorker1 = Worker.builder() + .workerKey("worker-1") + .user(testUser) + .tenant(testTenant1) + .organization(testOrganization) + .build(); + testWorker1.setIsDeleted(false); + testWorker1 = entityManager.persistAndFlush(testWorker1); + + testWorker2 = Worker.builder() + .workerKey("worker-2") + .user(testUser) + .tenant(testTenant2) + .organization(testOrganization) + .build(); + testWorker2.setIsDeleted(false); + testWorker2 = entityManager.persistAndFlush(testWorker2); + } + + @Nested + @DisplayName("findByUserIdAndIsDeletedFalse 테스트") + class FindByUserIdTest { + + @Test + @DisplayName("User ID로 Worker 목록을 조회할 수 있어야 한다") + void findByUserIdAndIsDeletedFalse_WithValidUserId_ReturnsWorkers() { + // When + List result = workerRepository.findByUserIdAndIsDeletedFalse(testUser.getId()); + + // Then + assertThat(result).hasSize(2); + assertThat(result).extracting(Worker::getWorkerKey) + .containsExactlyInAnyOrder("worker-1", "worker-2"); + } + + @Test + @DisplayName("존재하지 않는 User ID로 조회하면 빈 목록을 반환해야 한다") + void findByUserIdAndIsDeletedFalse_WithNonExistentUserId_ReturnsEmpty() { + // When + List result = workerRepository.findByUserIdAndIsDeletedFalse(999L); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("findByUserIdAndTenantIdAndIsDeletedFalse 테스트") + class FindByUserIdAndTenantIdTest { + + @Test + @DisplayName("User ID와 Tenant ID로 Worker를 조회할 수 있어야 한다") + void findByUserIdAndTenantIdAndIsDeletedFalse_WithValidIds_ReturnsWorker() { + // When + Optional result = workerRepository.findByUserIdAndTenantIdAndIsDeletedFalse( + testUser.getId(), testTenant1.getId()); + + // Then + assertThat(result).isPresent(); + assertThat(result.get().getWorkerKey()).isEqualTo("worker-1"); + assertThat(result.get().getTenant().getId()).isEqualTo(testTenant1.getId()); + } + + @Test + @DisplayName("존재하지 않는 조합으로 조회하면 빈 Optional을 반환해야 한다") + void findByUserIdAndTenantIdAndIsDeletedFalse_WithNonExistentIds_ReturnsEmpty() { + // When + Optional result = workerRepository.findByUserIdAndTenantIdAndIsDeletedFalse( + 999L, 999L); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("findByTenantIdAndIsDeletedFalse 테스트") + class FindByTenantIdTest { + + @Test + @DisplayName("Tenant ID로 Worker 목록을 조회할 수 있어야 한다") + void findByTenantIdAndIsDeletedFalse_WithValidTenantId_ReturnsWorkers() { + // When + List result = workerRepository.findByTenantIdAndIsDeletedFalse(testTenant1.getId()); + + // Then + assertThat(result).hasSize(1); + assertThat(result.get(0).getWorkerKey()).isEqualTo("worker-1"); + } + } + + @Nested + @DisplayName("findByWorkerKeyAndIsDeletedFalse 테스트") + class FindByWorkerKeyTest { + + @Test + @DisplayName("Worker Key로 Worker를 조회할 수 있어야 한다") + void findByWorkerKeyAndIsDeletedFalse_WithValidWorkerKey_ReturnsWorker() { + // When + Optional result = workerRepository.findByWorkerKeyAndIsDeletedFalse("worker-1"); + + // Then + assertThat(result).isPresent(); + assertThat(result.get().getWorkerKey()).isEqualTo("worker-1"); + assertThat(result.get().getUser().getId()).isEqualTo(testUser.getId()); + } + + @Test + @DisplayName("존재하지 않는 Worker Key로 조회하면 빈 Optional을 반환해야 한다") + void findByWorkerKeyAndIsDeletedFalse_WithNonExistentWorkerKey_ReturnsEmpty() { + // When + Optional result = workerRepository.findByWorkerKeyAndIsDeletedFalse("non-existent"); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("findTenantIdsByUserId 테스트") + class FindTenantIdsByUserIdTest { + + @Test + @DisplayName("User가 속한 모든 Tenant ID 목록을 조회할 수 있어야 한다") + void findTenantIdsByUserId_WithValidUserId_ReturnsTenantIds() { + // When + List result = workerRepository.findTenantIdsByUserId(testUser.getId()); + + // Then + assertThat(result).hasSize(2); + assertThat(result).containsExactlyInAnyOrder(testTenant1.getId(), testTenant2.getId()); + } + + @Test + @DisplayName("존재하지 않는 User ID로 조회하면 빈 목록을 반환해야 한다") + void findTenantIdsByUserId_WithNonExistentUserId_ReturnsEmpty() { + // When + List result = workerRepository.findTenantIdsByUserId(999L); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("existsByUserIdAndTenantIdAndIsDeletedFalse 테스트") + class ExistsByUserIdAndTenantIdTest { + + @Test + @DisplayName("User가 Tenant에 속하면 true를 반환해야 한다") + void existsByUserIdAndTenantIdAndIsDeletedFalse_WithValidIds_ReturnsTrue() { + // When + boolean result = workerRepository.existsByUserIdAndTenantIdAndIsDeletedFalse( + testUser.getId(), testTenant1.getId()); + + // Then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("User가 Tenant에 속하지 않으면 false를 반환해야 한다") + void existsByUserIdAndTenantIdAndIsDeletedFalse_WithInvalidIds_ReturnsFalse() { + // When + boolean result = workerRepository.existsByUserIdAndTenantIdAndIsDeletedFalse( + 999L, 999L); + + // Then + assertThat(result).isFalse(); + } + } + + @Nested + @DisplayName("삭제된 Worker 필터링 테스트") + class DeletedWorkerTest { + + @Test + @DisplayName("삭제된 Worker는 조회되지 않아야 한다") + void findByUserIdAndIsDeletedFalse_WithDeletedWorker_ExcludesDeleted() { + // Given + testWorker1.setIsDeleted(true); + entityManager.persistAndFlush(testWorker1); + + // When + List result = workerRepository.findByUserIdAndIsDeletedFalse(testUser.getId()); + + // Then + assertThat(result).hasSize(1); + assertThat(result.get(0).getWorkerKey()).isEqualTo("worker-2"); + } + } +} + diff --git a/src/test/java/com/agenticcp/core/domain/user/service/PermissionServiceCanTest.java b/src/test/java/com/agenticcp/core/domain/user/service/PermissionServiceCanTest.java new file mode 100644 index 00000000..a2c5174c --- /dev/null +++ b/src/test/java/com/agenticcp/core/domain/user/service/PermissionServiceCanTest.java @@ -0,0 +1,311 @@ +package com.agenticcp.core.domain.user.service; + +import com.agenticcp.core.common.context.TenantContextHolder; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.tenant.entity.Tenant; +import com.agenticcp.core.domain.user.entity.Permission; +import com.agenticcp.core.domain.user.entity.Role; +import com.agenticcp.core.domain.user.entity.User; +import com.agenticcp.core.domain.user.entity.Worker; +import com.agenticcp.core.domain.user.entity.WorkerRoleAssignment; +import com.agenticcp.core.domain.user.repository.PermissionRepository; +import com.agenticcp.core.domain.user.repository.RoleRepository; +import com.agenticcp.core.domain.user.repository.WorkerRoleAssignmentRepository; +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.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.*; + +/** + * PermissionService.can() 메서드 단위 테스트 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("PermissionService.can() 메서드 테스트") +class PermissionServiceCanTest { + + @Mock + private PermissionRepository permissionRepository; + + @Mock + private RoleRepository roleRepository; + + @Mock + private WorkerRoleAssignmentRepository workerRoleAssignmentRepository; + + @InjectMocks + private PermissionService permissionService; + + private Tenant testTenant; + private User testUser; + private Worker testWorker; + private Role testRole; + private Permission testPermission; + private CloudResource testResource; + + @BeforeEach + void setUp() { + testTenant = Tenant.builder() + .tenantKey("tenant-1") + .tenantName("Tenant 1") + .build(); + testTenant.setId(100L); + testTenant.setIsDeleted(false); + + testUser = User.builder() + .username("testuser") + .build(); + testUser.setId(1L); + + testWorker = Worker.builder() + .workerKey("worker-1") + .user(testUser) + .tenant(testTenant) + .build(); + testWorker.setId(10L); + testWorker.setIsDeleted(false); + + testPermission = Permission.builder() + .permissionKey("vm:start") + .permissionName("VM Start") + .resource("INSTANCE") + .action("START") + .tenant(testTenant) + .build(); + testPermission.setId(1000L); + + testRole = Role.builder() + .roleKey("vm-admin") + .roleName("VM Admin") + .permissions(List.of(testPermission)) + .tenant(testTenant) + .build(); + testRole.setId(100L); + + testResource = CloudResource.builder() + .resourceId("vm-001") + .name("Test VM") + .type("INSTANCE") + .provider("AWS") + .region("us-east-1") + .tenant(testTenant) + .build(); + testResource.setId(1L); + } + + @AfterEach + void tearDown() { + TenantContextHolder.clear(); + } + + @Nested + @DisplayName("권한 체크 성공 테스트") + class PermissionCheckSuccessTest { + + @Test + @DisplayName("Worker가 리소스에 대한 권한이 있으면 true를 반환해야 한다") + void can_WithValidPermission_ReturnsTrue() { + // Given + when(workerRoleAssignmentRepository.findRoleIdsByTenantIdAndWorkerId(100L, 10L, any(LocalDateTime.class))) + .thenReturn(Arrays.asList(100L)); + when(roleRepository.findAllById(anyList())) + .thenReturn(Arrays.asList(testRole)); + when(permissionRepository.findAllById(anyList())) + .thenReturn(Arrays.asList(testPermission)); + + // When + boolean result = permissionService.can(10L, 100L, "START", testResource); + + // Then + assertThat(result).isTrue(); + verify(workerRoleAssignmentRepository).findRoleIdsByTenantIdAndWorkerId(100L, 10L, any(LocalDateTime.class)); + verify(roleRepository).findAllById(anyList()); + verify(permissionRepository).findAllById(anyList()); + } + + @Test + @DisplayName("TenantContextHolder에서 workerId와 tenantId를 가져와서 권한 체크를 수행할 수 있어야 한다") + void can_WithContextHolder_ReturnsTrue() { + // Given + TenantContextHolder.setCurrentTenantAndWorker(testTenant, testWorker); + when(workerRoleAssignmentRepository.findRoleIdsByTenantIdAndWorkerId(100L, 10L, any(LocalDateTime.class))) + .thenReturn(Arrays.asList(100L)); + when(roleRepository.findAllById(anyList())) + .thenReturn(Arrays.asList(testRole)); + when(permissionRepository.findAllById(anyList())) + .thenReturn(Arrays.asList(testPermission)); + + // When + boolean result = permissionService.can(null, null, "START", testResource); + + // Then + assertThat(result).isTrue(); + } + } + + @Nested + @DisplayName("Tenant 격리 실패 테스트") + class TenantIsolationFailureTest { + + @Test + @DisplayName("리소스의 Tenant가 현재 Tenant와 다르면 false를 반환해야 한다") + void can_WithDifferentTenant_ReturnsFalse() { + // Given + Tenant otherTenant = Tenant.builder() + .tenantKey("tenant-2") + .build(); + otherTenant.setId(200L); + CloudResource otherTenantResource = CloudResource.builder() + .resourceId("vm-002") + .type("INSTANCE") + .tenant(otherTenant) + .build(); + otherTenantResource.setId(2L); + + // When + boolean result = permissionService.can(10L, 100L, "START", otherTenantResource); + + // Then + assertThat(result).isFalse(); + verify(workerRoleAssignmentRepository, never()).findRoleIdsByTenantIdAndWorkerId(anyLong(), anyLong(), any()); + } + + @Test + @DisplayName("리소스에 Tenant가 없으면 false를 반환해야 한다") + void can_WithNullTenant_ReturnsFalse() { + // Given + CloudResource resourceWithoutTenant = CloudResource.builder() + .resourceId("vm-003") + .type("INSTANCE") + .tenant(null) + .build(); + resourceWithoutTenant.setId(3L); + + // When + boolean result = permissionService.can(10L, 100L, "START", resourceWithoutTenant); + + // Then + assertThat(result).isFalse(); + verify(workerRoleAssignmentRepository, never()).findRoleIdsByTenantIdAndWorkerId(anyLong(), anyLong(), any()); + } + } + + @Nested + @DisplayName("Role 없음 테스트") + class NoRoleTest { + + @Test + @DisplayName("Worker에게 할당된 Role이 없으면 false를 반환해야 한다") + void can_WithNoRoles_ReturnsFalse() { + // Given + when(workerRoleAssignmentRepository.findRoleIdsByTenantIdAndWorkerId(100L, 10L, any(LocalDateTime.class))) + .thenReturn(Collections.emptyList()); + + // When + boolean result = permissionService.can(10L, 100L, "START", testResource); + + // Then + assertThat(result).isFalse(); + verify(workerRoleAssignmentRepository).findRoleIdsByTenantIdAndWorkerId(100L, 10L, any(LocalDateTime.class)); + verify(roleRepository, never()).findAllById(anyList()); + } + } + + @Nested + @DisplayName("Permission 없음 테스트") + class NoPermissionTest { + + @Test + @DisplayName("Role에 Permission이 없으면 false를 반환해야 한다") + void can_WithNoPermissions_ReturnsFalse() { + // Given + Role roleWithoutPermission = Role.builder() + .roleKey("vm-admin") + .permissions(Collections.emptyList()) + .build(); + roleWithoutPermission.setId(100L); + when(workerRoleAssignmentRepository.findRoleIdsByTenantIdAndWorkerId(100L, 10L, any(LocalDateTime.class))) + .thenReturn(Arrays.asList(100L)); + when(roleRepository.findAllById(anyList())) + .thenReturn(Arrays.asList(roleWithoutPermission)); + + // When + boolean result = permissionService.can(10L, 100L, "START", testResource); + + // Then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("Permission이 리소스 타입과 일치하지 않으면 false를 반환해야 한다") + void can_WithMismatchedResourceType_ReturnsFalse() { + // Given + Permission differentResourcePermission = Permission.builder() + .resource("BUCKET") + .action("START") + .build(); + differentResourcePermission.setId(1001L); + Role roleWithDifferentPermission = Role.builder() + .permissions(List.of(differentResourcePermission)) + .build(); + roleWithDifferentPermission.setId(100L); + when(workerRoleAssignmentRepository.findRoleIdsByTenantIdAndWorkerId(100L, 10L, any(LocalDateTime.class))) + .thenReturn(Arrays.asList(100L)); + when(roleRepository.findAllById(anyList())) + .thenReturn(Arrays.asList(roleWithDifferentPermission)); + when(permissionRepository.findAllById(anyList())) + .thenReturn(Arrays.asList(differentResourcePermission)); + + // When + boolean result = permissionService.can(10L, 100L, "START", testResource); + + // Then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("Permission이 액션과 일치하지 않으면 false를 반환해야 한다") + void can_WithMismatchedAction_ReturnsFalse() { + // Given + Permission differentActionPermission = Permission.builder() + .resource("INSTANCE") + .action("STOP") + .build(); + differentActionPermission.setId(1002L); + Role roleWithDifferentAction = Role.builder() + .permissions(List.of(differentActionPermission)) + .build(); + roleWithDifferentAction.setId(100L); + when(workerRoleAssignmentRepository.findRoleIdsByTenantIdAndWorkerId(100L, 10L, any(LocalDateTime.class))) + .thenReturn(Arrays.asList(100L)); + when(roleRepository.findAllById(anyList())) + .thenReturn(Arrays.asList(roleWithDifferentAction)); + when(permissionRepository.findAllById(anyList())) + .thenReturn(Arrays.asList(differentActionPermission)); + + // When + boolean result = permissionService.can(10L, 100L, "START", testResource); + + // Then + assertThat(result).isFalse(); + } + } +} +