Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
897 changes: 897 additions & 0 deletions docs/RBAC_SIMPLIFIED_SEQUENCE.md

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions src/main/java/com/agenticcp/core/common/config/WebMvcConfig.java
Original file line number Diff line number Diff line change
@@ -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/**"
);
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,23 @@
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
public class TenantContextHolder {

private static final ThreadLocal<Tenant> TENANT_CONTEXT = new ThreadLocal<>();
private static final ThreadLocal<String> TENANT_KEY_CONTEXT = new ThreadLocal<>();
private static final ThreadLocal<Worker> WORKER_CONTEXT = new ThreadLocal<>();

/**
* 현재 스레드에 테넌트 정보를 설정
Expand Down Expand Up @@ -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");
}

/**
Expand All @@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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
*
* <p>HTTP 헤더(`X-Tenant-Id`)에서 Tenant를 선택하고,
* 해당 Tenant의 Worker를 자동 조회하여 TenantContextHolder에 저장합니다.</p>
*
* @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);
}
}

Loading
Loading