[Developer] #617 apply DDS MCP end-user authorization

This commit is contained in:
devmrko
2026-07-02 09:34:58 +09:00
parent fd09622c82
commit ef1331be4b
53 changed files with 2040 additions and 337 deletions

View File

@@ -71,6 +71,18 @@ export DDSUSER_BOTH_PASSWORD="DdsGrant#Both26"
export DDSUSER_NONE_PASSWORD="DdsGrant#None26"
export DDSUSER_TOKEN_PASSWORD="DdsGrant#Token26"
# --- (3c) DDS MCP SSE · OCI IAM Confidential Application ---
# 실제 값은 .env 또는 배포 환경의 secret store에만 둔다.
export DDS_OCI_IAM_CLIENT_ID=""
export DDS_OCI_IAM_CLIENT_SECRET=""
export DDS_OCI_IAM_DOMAIN_URL="" # 예: https://idcs-<unique>.identity.oraclecloud.com
export DDS_OCI_IAM_DATABASE_SCOPE="" # OCI IAM의 DB resource scope
export DDS_OCI_IAM_TOKEN_URI="" # 비우면 ${DDS_OCI_IAM_DOMAIN_URL}/oauth2/v1/token
export DDS_OCI_IAM_TIMEOUT_SECONDS="10s"
export DDS_OCI_IAM_REFRESH_SKEW_SECONDS="60s"
export DDS_MCP_BASE_URL="http://127.0.0.1:8083"
export DDS_MCP_TEST_BEARER="" # 발급한 활성 CB_APP_USER Bearer만 로컬 테스트에 사용
# --- (4) 원격 Postgres (AWS RDS, Cloud SQL, ...) ---
# sql/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성.
# ADB 의 RDS_POSTGRES_LINK 가 이 인스턴스를 가리킴.

3
.gitignore vendored
View File

@@ -17,6 +17,9 @@ logs/
# Java / Maven
target/
# Locally downloaded development tools (for example SQLcl)
.tools/
# OS
.DS_Store
Thumbs.db

View File

@@ -8,8 +8,8 @@
- 주 보호 객체: `ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS` (지식 청크·분류값 검색)
- 보조 SQL 검증 객체: `ADMIN.V_DDS_CUSTOMERS_PG`, `ADMIN.V_DDS_CUSTOMERS_MY`
- 보조 DDS SQL 검증 사용자: `dds_demo_my`, `dds_demo_pg`, `dds_demo_both`, `dds_demo_none`
- 토큰 경로 기술 사용자: `dds_demo_token` (고객별 DDS 계정이 아님)
- 조회 방식: 직접 DDS END USER 비교 경로 + 단일 기술 사용자·Bearer 토큰 경로
- MCP SSE 경로: Bearer가 기존 업무 사용자를 식별하고 local DDS `END USER` Context로 실행
- 레거시 토큰 데모 기술 사용자: `dds_demo_token` (비교·호환성 검증용)
## 두 데모의 관계
@@ -27,28 +27,29 @@
|---|---|---|---|
| VPD | 공통 계정 + Bearer/세션 컨텍스트 | 동적 predicate 함수 | 업무 권한 테이블 |
| DDS 직접 비교 | DDS `END USER` 직접 로그인 | `DATA ROLE` + `DATA GRANT` | 선언형 DDL/Grant |
| DDS 토큰 경로 | 단일 DDS 기술 사용자 + Bearer → `CB_AGENT_CTX` | 객체별 `DATA GRANT` predicate + 공통 `CB_*` | 요청 시 공통 권한 재평가 |
| DDS MCP SSE | Bearer → `CB_APP_USER` → local `DDS_U_<id>` Context | `DATA ROLE` + `DATA GRANT` | 권한 변경 시 bulk 재게시 |
| DDS 레거시 토큰 데모 | 단일 DDS 기술 사용자 + Bearer → `CB_AGENT_CTX` | 객체별 `DATA GRANT` predicate + 공통 `CB_*` | 요청 시 공통 권한 재평가 |
따라서 두 인스턴스의 관리 흐름과 기대 결과를 맞출 수 있습니다. 토큰 경로는 `DATA GRANT``ON` 대상과 `WHERE` predicate를 사용하면서, 토큰 자체는 먼저 신뢰된 Context로 해석합니다. IAM 토큰을 DDS의 순수 `EndUserSecurityContext`로 전달하는 방식은 별도 확장 경계입니다.
MCP SSE 경로는 Confidential Application의 OCI IAM database-access token으로 Context attach를 승인받지만, 업무 사용자 관리는 IAM으로 이전하지 않는다. 기존 `CB_APP_USER`/그룹/역할/permission이 source of truth이고 DDS는 이를 사용자별 보안 주체로 투영한다.
권한 변경 후에는 다음 순서로 확인합니다.
```text
1. /permissions에서 사용자·그룹·역할·행/TAG·컬럼 기준을 저장
2. /dds-provision에서 사용자별 predicate와 제외 컬럼을 검토
3. 승인된 경우에만 DDS DATA GRANT 게시
4. /vector-knowledge에서 토큰 기반 공통 권한 결과를 검증
2. 저장 직후 활성 사용자의 MCP local END USER/DATA ROLE/DATA GRANT를 bulk 재게시
3. /dds-provision에서 전체 재동기화·변경 검토·복구 게시
4. /dds/mcp/sse의 `dds_vector_search`로 실제 MCP 권한 결과를 검증
5. /dds에서 직접 END USER별 저수준 결과를 비교
```
이 앱의 조회 경계는 다음과 같습니다.
```text
Bearer → CB_AGENT_CTX → DATA GRANT predicate → DDS 보호 VIEW → 조회 결과
MCP Bearer → CB_APP_USER → DDS_U_<id> Context → DATA ROLE/DATA GRANT → DDS 보호 VIEW → 조회 결과
직접 비교: END USER → DATA ROLE → DATA GRANT → DDS 보호 VIEW
```
ORDS Handler가 Bearer 값을 `cb_dds_hr` 같은 문자열로 바꾸는 것만으로 DDS Context가 자동 생성되지 않습니다. 토큰 경로에서는 `CB_AGENT_CTX_PKG.SET_USER_BY_BEARER`가 해시·만료·회수·업무 사용자 매핑을 확인하고, 객체별 Data Grant predicate가 공통 권한 테이블을 조회합니다. 지원 드라이버의 `EndUserSecurityContext`를 사용하는 순수 DDS 경로는 별도 설계입니다.
ORDS Handler가 Bearer 값을 문자열로 바꾸는 것만으로 DDS Context가 생성되지 않습니다. MCP 경로는 JDBC `EndUserSecurityContext`를 SQL 실행 전에 attach하고, `finally`에서 해제한다. OCI IAM client-credentials token은 서비스 attach 권한만 나타내며, 사람 사용자는 기존 Bearer→업무 사용자 매핑으로 결정한다.
## 실행

View File

@@ -19,7 +19,9 @@
<properties>
<java.version>21</java.version>
<oracle.jdbc.version>23.6.0.24.10</oracle.jdbc.version>
<!-- 23.26 adds the Deep Data Security EndUserSecurityContext JDBC API. -->
<oracle.jdbc.version>23.26.2.0.0</oracle.jdbc.version>
<oracle.pki.version>23.6.0.24.10</oracle.pki.version>
</properties>
<dependencies>
@@ -48,7 +50,7 @@
<dependency>
<groupId>com.oracle.database.security</groupId>
<artifactId>oraclepki</artifactId>
<version>${oracle.jdbc.version}</version>
<version>${oracle.pki.version}</version>
</dependency>
<dependency>

View File

@@ -1,6 +1,7 @@
package com.cloudhandson.ddsbackoffice;
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
import com.cloudhandson.ddsbackoffice.config.DdsMcpIamProperties;
import com.cloudhandson.vpdbackoffice.VpdBackofficeApplication;
import com.cloudhandson.vpdbackoffice.web.DashboardController;
import com.cloudhandson.vpdbackoffice.web.LoginController;
@@ -14,7 +15,7 @@ import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
@SpringBootApplication
@EnableConfigurationProperties(DdsProperties.class)
@EnableConfigurationProperties({DdsProperties.class, DdsMcpIamProperties.class})
@MapperScan("com.cloudhandson.vpdbackoffice.mapper")
@ComponentScan(
basePackages = {"com.cloudhandson.ddsbackoffice", "com.cloudhandson.vpdbackoffice"},
@@ -24,7 +25,7 @@ import org.springframework.context.annotation.FilterType;
DdsBackofficeApplication.class,
VpdBackofficeApplication.class,
com.cloudhandson.ddsbackoffice.config.AppConfig.class,
com.cloudhandson.ddsbackoffice.config.SecurityConfig.class,
com.cloudhandson.vpdbackoffice.config.SecurityConfig.class,
DashboardController.class,
LoginController.class,
VectorKnowledgeController.class,

View File

@@ -0,0 +1,79 @@
package com.cloudhandson.ddsbackoffice.config;
import java.net.URI;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;
/**
* OCI IAM client-credentials settings for the DDS MCP service identity.
*
* <p>The client credential proves the service may attach a DDS context; it
* is never used as the identity of an MCP caller.</p>
*/
@ConfigurationProperties(prefix = "dds.mcp.iam")
public record DdsMcpIamProperties(
String domainUrl,
String tokenUri,
String clientId,
String clientSecret,
String databaseScope,
Duration timeout,
Duration refreshSkew
) {
@ConstructorBinding
public DdsMcpIamProperties {
domainUrl = trim(domainUrl);
tokenUri = trim(tokenUri);
clientId = trim(clientId);
clientSecret = clientSecret == null ? "" : clientSecret;
databaseScope = trim(databaseScope);
timeout = timeout == null || timeout.isNegative() || timeout.isZero()
? Duration.ofSeconds(10) : timeout;
refreshSkew = refreshSkew == null || refreshSkew.isNegative()
? Duration.ofSeconds(60) : refreshSkew;
}
public boolean configured() {
return !clientId.isBlank()
&& !clientSecret.isBlank()
&& !databaseScope.isBlank()
&& !resolvedTokenUri().isBlank();
}
public URI tokenEndpoint() {
String value = resolvedTokenUri();
if (value.isBlank()) {
throw new IllegalStateException("DDS OCI IAM token endpoint가 설정되지 않았습니다.");
}
URI endpoint;
try {
endpoint = URI.create(value);
} catch (IllegalArgumentException exception) {
throw new IllegalStateException("DDS OCI IAM token endpoint 형식이 올바르지 않습니다.");
}
if (!"https".equalsIgnoreCase(endpoint.getScheme())
|| endpoint.getHost() == null
|| endpoint.getUserInfo() != null) {
throw new IllegalStateException("DDS OCI IAM token endpoint는 HTTPS URL이어야 합니다.");
}
return endpoint;
}
private String resolvedTokenUri() {
if (!tokenUri.isBlank()) {
return tokenUri;
}
if (domainUrl.isBlank()) {
return "";
}
return domainUrl.endsWith("/")
? domainUrl + "oauth2/v1/token"
: domainUrl + "/oauth2/v1/token";
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
}

View File

@@ -1,5 +1,6 @@
package com.cloudhandson.ddsbackoffice.config;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -23,11 +24,13 @@ public class SecurityConfig {
}
return http
.csrf(csrf -> csrf.ignoringRequestMatchers("/dds/mcp/messages"))
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31_536_000)))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/css/**", "/js/**", "/health", "/login").permitAll()
.requestMatchers("/css/**", "/js/**", "/health", "/login",
"/dds/mcp/sse", "/dds/mcp/messages").permitAll()
.anyRequest().authenticated())
.httpBasic(basic -> {
})

View File

@@ -0,0 +1,9 @@
package com.cloudhandson.ddsbackoffice.domain;
/** The internal result of revalidating an MCP Bearer header. */
public record DdsMcpAuthenticatedUser(
long applicationUserId,
String username,
DdsMcpEndUserPrincipal principal
) {
}

View File

@@ -0,0 +1,10 @@
package com.cloudhandson.ddsbackoffice.domain;
/** A published local DDS identity corresponding to one application user. */
public record DdsMcpEndUserPrincipal(
long applicationUserId,
String endUserName,
String dataRoleName,
String lookupKeyReference
) {
}

View File

@@ -0,0 +1,9 @@
package com.cloudhandson.ddsbackoffice.domain;
/** Summary of one publish of local DDS identities used by the MCP endpoint. */
public record DdsMcpEndUserPublishResult(
int publishedUsers,
int defaultDeniedUsers,
int revokedUsers
) {
}

View File

@@ -0,0 +1,13 @@
package com.cloudhandson.ddsbackoffice.domain;
import java.util.List;
import java.util.Map;
/** A deliberately small, already DDS-filtered MCP tool result. */
public record DdsMcpVectorSearchResult(
String query,
String embeddingMode,
int rowCount,
List<Map<String, Object>> rows
) {
}

View File

@@ -4,7 +4,7 @@ package com.cloudhandson.ddsbackoffice.domain;
public record DdsSqlEvidence(
boolean available,
String requestId,
String submittedStatement,
String executedSql,
String sqlId,
Integer childNumber,
String lastActiveTime,
@@ -13,7 +13,7 @@ public record DdsSqlEvidence(
String vpdPredicate,
String status
) {
public static DdsSqlEvidence unavailable(String requestId, String submittedStatement, String status) {
return new DdsSqlEvidence(false, requestId, submittedStatement, null, null, null, null, null, null, status);
public static DdsSqlEvidence unavailable(String requestId, String executedSql, String status) {
return new DdsSqlEvidence(false, requestId, executedSql, null, null, null, null, null, null, status);
}
}

View File

@@ -0,0 +1,31 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationSynchronizer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.stereotype.Component;
/** Republishes all local DDS MCP identities in the same request as an authorization change. */
@Component
public class DdsMcpAuthorizationChangeListener implements DdsAuthorizationSynchronizer {
private static final Logger log = LoggerFactory.getLogger(DdsMcpAuthorizationChangeListener.class);
private final DdsMcpEndUserPublisher publisher;
public DdsMcpAuthorizationChangeListener(DdsMcpEndUserPublisher publisher) {
this.publisher = publisher;
}
@Override
public void synchronize(String reason) {
try {
var result = publisher.publish();
log.info("DDS MCP authorization sync completed after {}: published={}, defaultDenied={}, revoked={}",
reason, result.publishedUsers(), result.defaultDeniedUsers(), result.revokedUsers());
} catch (RuntimeException exception) {
throw new DataAccessResourceFailureException(
"업무 권한 변경 뒤 DDS MCP 권한 동기화에 실패했습니다. DDS 게시 상태를 확인하세요.", exception);
}
}
}

View File

@@ -0,0 +1,65 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpAuthenticatedUser;
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
import org.springframework.stereotype.Service;
/** Revalidates the MCP request bearer before every tool invocation. */
@Service
public class DdsMcpBearerAuthenticator {
private static final int MAX_TOKEN_LENGTH = 4096;
private final BearerTokenService bearerTokenService;
private final UserMapper userMapper;
private final DdsMcpEndUserResolver endUserResolver;
private final Clock clock;
public DdsMcpBearerAuthenticator(
BearerTokenService bearerTokenService,
UserMapper userMapper,
DdsMcpEndUserResolver endUserResolver,
Clock clock
) {
this.bearerTokenService = bearerTokenService;
this.userMapper = userMapper;
this.endUserResolver = endUserResolver;
this.clock = clock;
}
public DdsMcpAuthenticatedUser authenticate(String authorization) {
String plainToken = bearerValue(authorization);
BearerTokenRecord token = bearerTokenService.findByPlainToken(plainToken);
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
if (token == null || !token.active(now) || !bearerTokenService.matches(token, plainToken)) {
throw denied();
}
AppUser user = userMapper.findById(token.userId());
if (user == null || !user.active()) {
throw denied();
}
return new DdsMcpAuthenticatedUser(user.userId(), user.username(), endUserResolver.resolve(user.userId()));
}
private String bearerValue(String authorization) {
if (authorization == null || !authorization.regionMatches(true, 0, "Bearer ", 0, 7)) {
throw denied();
}
String value = authorization.substring(7).trim();
if (value.isEmpty() || value.length() > MAX_TOKEN_LENGTH) {
throw denied();
}
return value;
}
private AppException denied() {
return new AppException("AUTHORIZATION_DENIED: MCP Bearer를 확인할 수 없습니다.");
}
}

View File

@@ -0,0 +1,172 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.config.DdsMcpIamProperties;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpEndUserPrincipal;
import com.cloudhandson.vpdbackoffice.service.AppException;
import java.lang.reflect.Method;
import java.lang.reflect.InvocationTargetException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.util.Base64;
import java.util.concurrent.Executor;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.sql.DataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/**
* The only DDS MCP path that may run protected SQL. It attaches the local
* END USER context, runs one work unit, and clears the context before the
* pooled connection is released.
*/
@Service
public class DdsMcpContextExecutor {
private static final Logger log = LoggerFactory.getLogger(DdsMcpContextExecutor.class);
private static final Executor DIRECT_EXECUTOR = Runnable::run;
private final DataSource dataSource;
private final DdsMcpIamProperties iamProperties;
private final DdsMcpDatabaseAccessTokenProvider tokenProvider;
public DdsMcpContextExecutor(
DataSource dataSource,
DdsMcpIamProperties iamProperties,
DdsMcpDatabaseAccessTokenProvider tokenProvider
) {
this.dataSource = dataSource;
this.iamProperties = iamProperties;
this.tokenProvider = tokenProvider;
}
public <T> T withContext(DdsMcpEndUserPrincipal principal, SqlWork<T> work) {
if (principal == null || work == null) {
throw unavailable("DDS 실행 주체가 없습니다.");
}
String databaseAccessToken = tokenProvider.accessToken();
java.sql.Connection connection = null;
Object oracleConnection = null;
boolean attachAttempted = false;
boolean clearFailed = false;
try {
connection = dataSource.getConnection();
connection.setNetworkTimeout(DIRECT_EXECUTOR, networkTimeoutMillis());
oracleConnection = unwrapOracleConnection(connection);
attachAttempted = true;
log.info("DDS context attach started for application user {}", principal.applicationUserId());
attach(oracleConnection, databaseAccessToken, principal.endUserName(), lookupKey(principal));
log.info("DDS context attach completed for application user {}", principal.applicationUserId());
return work.execute(connection);
} catch (AppException exception) {
throw exception;
} catch (Exception exception) {
log.warn("DDS END USER Context attachment failed: {}", failureCode(exception));
throw unavailable("DDS END USER Context를 연결할 수 없습니다.");
} finally {
if (attachAttempted && oracleConnection != null) {
try {
log.info("DDS context clear started");
clear(oracleConnection);
log.info("DDS context clear completed");
} catch (Exception ignored) {
clearFailed = true;
}
}
if (connection != null) {
try {
if (clearFailed) {
connection.abort(Runnable::run);
}
} catch (Exception ignored) {
// close below still runs; the pool may discard an aborted connection.
}
try {
connection.close();
} catch (Exception ignored) {
// The request has already failed closed; do not expose JDBC details.
}
}
if (clearFailed) {
throw unavailable("DDS END USER Context를 해제할 수 없어 연결을 폐기했습니다.");
}
}
}
private Object unwrapOracleConnection(java.sql.Connection connection) throws Exception {
Class<?> oracleConnectionType = Class.forName("oracle.jdbc.OracleConnection");
return connection.unwrap(oracleConnectionType);
}
private void attach(Object oracleConnection, String databaseAccessToken, String endUserName, String lookupKey)
throws Exception {
Class<?> contextType = Class.forName("oracle.jdbc.EndUserSecurityContext");
Object context = createLocalContext(contextType, databaseAccessToken, endUserName, lookupKey);
Class<?> oracleConnectionType = Class.forName("oracle.jdbc.OracleConnection");
oracleConnectionType.getMethod("setEndUserSecurityContext", contextType)
.invoke(oracleConnection, context);
}
private Object createLocalContext(
Class<?> contextType,
String databaseAccessToken,
String endUserName,
String lookupKey
) throws Exception {
// ojdbc 23.26 exposes createWithName(CharSequence, String, CharSequence).
// Keep the documented createWithUsername fallback for compatible driver
// releases whose public API uses that spelling.
try {
Method createWithName = contextType.getMethod(
"createWithName", CharSequence.class, String.class, CharSequence.class);
return createWithName.invoke(null, databaseAccessToken, endUserName, lookupKey);
} catch (NoSuchMethodException ignored) {
Method createWithUsername = contextType.getMethod(
"createWithUsername", String.class, String.class, String.class);
return createWithUsername.invoke(null, databaseAccessToken, endUserName, lookupKey);
}
}
private void clear(Object oracleConnection) throws Exception {
Class<?> oracleConnectionType = Class.forName("oracle.jdbc.OracleConnection");
oracleConnectionType.getMethod("clearEndUserSecurityContext").invoke(oracleConnection);
}
private String lookupKey(DdsMcpEndUserPrincipal principal) {
if (!"DDS_OCI_IAM_CLIENT_SECRET_DERIVED_V1".equals(principal.lookupKeyReference())) {
throw unavailable("DDS END USER lookup key 참조가 허용되지 않습니다.");
}
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(iamProperties.clientSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] digest = mac.doFinal(("dds-mcp-local-end-user:" + principal.applicationUserId())
.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
} catch (GeneralSecurityException exception) {
throw unavailable("DDS END USER lookup key를 만들 수 없습니다.");
}
}
private int networkTimeoutMillis() {
long configured = iamProperties.timeout().toMillis();
return (int) Math.max(1_000L, Math.min(60_000L, configured));
}
private String failureCode(Exception exception) {
Throwable cause = exception instanceof InvocationTargetException invocation && invocation.getCause() != null
? invocation.getCause() : exception;
if (cause instanceof java.sql.SQLException sqlException) {
return "sqlState=" + sqlException.getSQLState() + ", errorCode=" + sqlException.getErrorCode();
}
return cause.getClass().getSimpleName();
}
private AppException unavailable(String detail) {
return new AppException("DDS_CONTEXT_UNAVAILABLE: " + detail);
}
@FunctionalInterface
public interface SqlWork<T> {
T execute(java.sql.Connection connection) throws Exception;
}
}

View File

@@ -0,0 +1,123 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.config.DdsMcpIamProperties;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** Obtains and safely caches the short-lived OCI IAM database-access token. */
@Service
public class DdsMcpDatabaseAccessTokenProvider {
private static final Duration MINIMUM_CACHE_LIFETIME = Duration.ofSeconds(1);
private static final Logger log = LoggerFactory.getLogger(DdsMcpDatabaseAccessTokenProvider.class);
private final DdsMcpIamProperties properties;
private final ObjectMapper objectMapper;
private final Clock clock;
private final HttpClient client;
private volatile CachedToken cached;
@Autowired
public DdsMcpDatabaseAccessTokenProvider(
DdsMcpIamProperties properties,
ObjectMapper objectMapper,
Clock clock
) {
this(properties, objectMapper, clock, HttpClient.newBuilder().connectTimeout(properties.timeout()).build());
}
DdsMcpDatabaseAccessTokenProvider(
DdsMcpIamProperties properties,
ObjectMapper objectMapper,
Clock clock,
HttpClient client
) {
this.properties = properties;
this.objectMapper = objectMapper;
this.clock = clock;
this.client = client;
}
public String accessToken() {
if (!properties.configured()) {
throw unavailable("OCI IAM client, scope 또는 token endpoint가 설정되지 않았습니다.");
}
CachedToken current = cached;
Instant now = clock.instant();
if (current != null && current.validUntil().isAfter(now)) {
return current.value();
}
synchronized (this) {
current = cached;
now = clock.instant();
if (current != null && current.validUntil().isAfter(now)) {
return current.value();
}
CachedToken refreshed = fetch(now);
cached = refreshed;
return refreshed.value();
}
}
private CachedToken fetch(Instant now) {
try {
String clientCredentials = encode(properties.clientId()) + ":" + encode(properties.clientSecret());
String basic = Base64.getEncoder().encodeToString(clientCredentials.getBytes(StandardCharsets.UTF_8));
String body = "grant_type=client_credentials&scope=" + encode(properties.databaseScope());
HttpRequest request = HttpRequest.newBuilder(properties.tokenEndpoint())
.timeout(properties.timeout())
.header("Authorization", "Basic " + basic)
.header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw unavailable("OCI IAM token 발급이 거부되었습니다.");
}
JsonNode payload = objectMapper.readTree(response.body());
String token = payload.path("access_token").asText("").trim();
long expiresIn = payload.path("expires_in").asLong(0L);
if (token.isBlank() || expiresIn < 1) {
throw unavailable("OCI IAM token 응답이 완전하지 않습니다.");
}
Duration remaining = Duration.ofSeconds(expiresIn).minus(properties.refreshSkew());
if (remaining.compareTo(MINIMUM_CACHE_LIFETIME) < 0) {
remaining = MINIMUM_CACHE_LIFETIME;
}
return new CachedToken(token, now.plus(remaining));
} catch (AppException exception) {
throw exception;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw unavailable("OCI IAM token 발급이 중단되었습니다.");
} catch (Exception exception) {
log.warn("OCI IAM database-access token request failed: {}", exception.toString());
throw unavailable("OCI IAM token을 발급할 수 없습니다.");
}
}
private String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
private AppException unavailable(String detail) {
return new AppException("DDS_CONTEXT_UNAVAILABLE: " + detail);
}
private record CachedToken(String value, Instant validUntil) {
}
}

View File

@@ -0,0 +1,309 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpEndUserPublishResult;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Projects the existing application user/role/permission model to the local
* DDS identities consumed by the SSE MCP endpoint. No IAM user is created or
* changed here: OCI IAM only authorizes the service to attach a context.
*/
@Service
public class DdsMcpEndUserPublisher {
private static final String LOOKUP_KEY_REFERENCE = "DDS_OCI_IAM_CLIENT_SECRET_DERIVED_V1";
private static final Pattern TAG = Pattern.compile("[A-Za-z0-9_-]+");
private final JdbcTemplate jdbcTemplate;
public DdsMcpEndUserPublisher(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public DdsMcpEndUserPublishResult publish() {
ensureMapTable();
synchronizeMappings();
int published = 0;
int defaultDenied = 0;
int revoked = 0;
for (Mapping mapping : mappings()) {
try {
if (mapping.active()) {
ensureEndUser(mapping);
ensureDataRole(mapping);
ensureRoleGrant(mapping);
String predicate = vectorPredicate(mapping.applicationUserId());
replaceVectorGrant(mapping, predicate, excludedVectorColumns(mapping.applicationUserId()));
mark(mapping, "PUBLISHED", null);
published++;
if (predicate == null) {
defaultDenied++;
}
} else {
dropVectorGrant(mapping);
revokeRoleGrant(mapping);
mark(mapping, "REVOKED", null);
revoked++;
}
} catch (DataAccessException exception) {
markFailed(mapping);
throw new DataAccessResourceFailureException(
"DDS MCP END USER 권한을 게시하지 못했습니다. 기존 DDS 설정과 DB 권한을 확인하세요.", exception);
}
}
return new DdsMcpEndUserPublishResult(published, defaultDenied, revoked);
}
private void ensureMapTable() {
try {
jdbcTemplate.queryForObject("SELECT COUNT(*) FROM cb_dds_end_user_map", Integer.class);
} catch (DataAccessException missing) {
jdbcTemplate.execute("""
CREATE TABLE cb_dds_end_user_map (
application_user_id NUMBER PRIMARY KEY REFERENCES cb_app_user(user_id),
end_user_name VARCHAR2(128) NOT NULL UNIQUE,
data_role_name VARCHAR2(128) NOT NULL UNIQUE,
lookup_key_ref VARCHAR2(128) NOT NULL,
grant_name VARCHAR2(128) NOT NULL,
publish_status VARCHAR2(20) NOT NULL
CHECK (publish_status IN ('PENDING', 'PUBLISHED', 'REVOKED', 'FAILED')),
published_at TIMESTAMP,
last_error VARCHAR2(1000)
)
""");
}
}
private void synchronizeMappings() {
jdbcTemplate.update("""
MERGE INTO cb_dds_end_user_map target
USING (
SELECT user_id AS application_user_id,
'DDS_U_' || TO_CHAR(user_id) AS end_user_name,
'DDS_U_' || TO_CHAR(user_id) || '_ROLE' AS data_role_name,
? AS lookup_key_ref,
'DDS_MCP_U_' || TO_CHAR(user_id) || '_VECTOR_GRANT' AS grant_name
FROM cb_app_user
) source
ON (target.application_user_id = source.application_user_id)
WHEN MATCHED THEN UPDATE SET
target.end_user_name = source.end_user_name,
target.data_role_name = source.data_role_name,
target.lookup_key_ref = source.lookup_key_ref,
target.grant_name = source.grant_name
WHEN NOT MATCHED THEN INSERT (
application_user_id, end_user_name, data_role_name, lookup_key_ref,
grant_name, publish_status
) VALUES (
source.application_user_id, source.end_user_name, source.data_role_name,
source.lookup_key_ref, source.grant_name, 'PENDING'
)
""", LOOKUP_KEY_REFERENCE);
}
private List<Mapping> mappings() {
return jdbcTemplate.query("""
SELECT m.application_user_id, m.end_user_name, m.data_role_name, m.grant_name,
u.active
FROM cb_dds_end_user_map m
JOIN cb_app_user u ON u.user_id = m.application_user_id
ORDER BY m.application_user_id
""", (row, ignored) -> new Mapping(
row.getLong("application_user_id"),
row.getString("end_user_name"),
row.getString("data_role_name"),
row.getString("grant_name"),
"Y".equalsIgnoreCase(row.getString("active"))));
}
private void ensureEndUser(Mapping mapping) {
if (!endUserExists(mapping.endUserName())) {
jdbcTemplate.execute("CREATE END USER \"" + mapping.endUserName() + "\"");
}
}
private void ensureDataRole(Mapping mapping) {
if (!dataRoleExists(mapping.dataRoleName())) {
jdbcTemplate.execute("CREATE DATA ROLE " + mapping.dataRoleName());
}
}
private void ensureRoleGrant(Mapping mapping) {
Integer grants = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM dba_data_role_grants
WHERE grantee = ? AND data_role = ?
""", Integer.class, mapping.endUserName(), mapping.dataRoleName());
if (grants == null || grants == 0) {
jdbcTemplate.execute("GRANT DATA ROLE " + mapping.dataRoleName()
+ " TO \"" + mapping.endUserName() + "\"");
}
}
private void revokeRoleGrant(Mapping mapping) {
Integer grants = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM dba_data_role_grants
WHERE grantee = ? AND data_role = ?
""", Integer.class, mapping.endUserName(), mapping.dataRoleName());
if (grants != null && grants > 0) {
jdbcTemplate.execute("REVOKE DATA ROLE " + mapping.dataRoleName()
+ " FROM \"" + mapping.endUserName() + "\"");
}
}
private void replaceVectorGrant(Mapping mapping, String predicate, String excludedColumns) {
dropVectorGrant(mapping);
if (predicate == null) {
return;
}
String select = excludedColumns == null || excludedColumns.isBlank()
? "AS SELECT"
: "AS SELECT (ALL COLUMNS EXCEPT " + excludedColumns + ")";
jdbcTemplate.execute("CREATE DATA GRANT ADMIN." + mapping.grantName() + " " + select
+ " ON ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS WHERE " + predicate
+ " TO " + mapping.dataRoleName());
}
private void dropVectorGrant(Mapping mapping) {
Integer grants = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM dba_data_grants
WHERE owner = 'ADMIN' AND grant_name = ?
""", Integer.class, mapping.grantName());
if (grants != null && grants > 0) {
jdbcTemplate.execute("DROP DATA GRANT ADMIN." + mapping.grantName());
}
}
private String vectorPredicate(long userId) {
List<PermissionRule> rules = jdbcTemplate.query("""
WITH effective_role AS (
SELECT role_id FROM cb_user_role WHERE user_id = ?
UNION
SELECT gr.role_id
FROM cb_user_group ug
JOIN cb_app_group g ON g.group_id = ug.group_id AND g.active_yn = 'Y'
JOIN cb_group_role gr ON gr.group_id = ug.group_id
WHERE ug.user_id = ?
)
SELECT NVL(UPPER(TRIM(p.permission_effect)), 'ALLOW') AS permission_effect,
UPPER(TRIM(r.rule_type)) AS rule_type,
TRIM(r.rule_value) AS rule_value
FROM effective_role er
JOIN cb_permission p ON p.role_id = er.role_id
JOIN cb_permission_rule r ON r.perm_id = p.perm_id
WHERE p.target_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
AND p.action_name = 'SELECT'
ORDER BY p.perm_id, r.rule_id
""", (row, ignored) -> new PermissionRule(
row.getString("permission_effect"), row.getString("rule_type"), row.getString("rule_value")),
userId, userId);
List<String> allow = new ArrayList<>();
List<String> deny = new ArrayList<>();
for (PermissionRule rule : rules) {
String clause = clause(rule);
if (clause == null) {
continue;
}
("DENY".equalsIgnoreCase(rule.effect()) ? deny : allow).add(clause);
}
if (allow.isEmpty()) {
return null;
}
String allowExpression = disjunction(allow);
return deny.isEmpty() ? allowExpression : "(" + allowExpression + ") AND NOT (" + disjunction(deny) + ")";
}
private String clause(PermissionRule rule) {
if ("ALL".equals(rule.type())) {
return "1 = 1";
}
if ("TAG".equals(rule.type()) && rule.value() != null && TAG.matcher(rule.value()).matches()) {
String tag = rule.value().toUpperCase(Locale.ROOT);
return "REGEXP_LIKE(UPPER(tech_tag), '(^|,)" + tag + "(,|$)')";
}
return null;
}
private String disjunction(List<String> clauses) {
return clauses.size() == 1 ? clauses.getFirst() : "(" + String.join(") OR (", clauses) + ")";
}
private String excludedVectorColumns(long userId) {
List<String> columns = jdbcTemplate.query("""
SELECT pc.column_name
FROM cb_protected_column pc
JOIN cb_protected_object po ON po.object_id = pc.object_id
WHERE po.object_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
AND pc.sensitive_yn = 'Y'
AND pc.column_name <> 'EMBEDDING'
AND NOT EXISTS (
WITH effective_role AS (
SELECT role_id FROM cb_user_role WHERE user_id = ?
UNION
SELECT gr.role_id
FROM cb_user_group ug
JOIN cb_app_group g ON g.group_id = ug.group_id AND g.active_yn = 'Y'
JOIN cb_group_role gr ON gr.group_id = ug.group_id
WHERE ug.user_id = ?
)
SELECT 1
FROM effective_role er
JOIN cb_permission p ON p.role_id = er.role_id
JOIN cb_permission_column allowed ON allowed.permission_id = p.perm_id
WHERE p.target_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
AND p.action_name = 'SELECT'
AND p.permission_effect = 'ALLOW'
AND allowed.column_name = pc.column_name
)
ORDER BY pc.column_name
""", (row, ignored) -> row.getString("column_name"), userId, userId);
return String.join(", ", columns);
}
private boolean endUserExists(String endUserName) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM dba_end_users WHERE username = ?", Integer.class, endUserName);
return count != null && count > 0;
}
private boolean dataRoleExists(String dataRoleName) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM dba_data_roles WHERE data_role = ?", Integer.class, dataRoleName);
return count != null && count > 0;
}
private void mark(Mapping mapping, String status, String error) {
jdbcTemplate.update("""
UPDATE cb_dds_end_user_map
SET publish_status = ?, published_at = SYSTIMESTAMP, last_error = ?
WHERE application_user_id = ?
""", status, error, mapping.applicationUserId());
}
private void markFailed(Mapping mapping) {
try {
mark(mapping, "FAILED", "DDS MCP local END USER publish failed");
} catch (DataAccessException ignored) {
// Keep the original DDL failure as the request failure.
}
}
private record Mapping(
long applicationUserId,
String endUserName,
String dataRoleName,
String grantName,
boolean active
) {
}
private record PermissionRule(String effect, String type, String value) {
}
}

View File

@@ -0,0 +1,60 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpEndUserPrincipal;
import com.cloudhandson.vpdbackoffice.service.AppException;
import java.util.List;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/** Resolves only published local DDS identities. Missing mappings always deny. */
@Service
public class DdsMcpEndUserResolver {
private final JdbcTemplate jdbcTemplate;
public DdsMcpEndUserResolver(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public DdsMcpEndUserPrincipal resolve(long applicationUserId) {
try {
List<DdsMcpEndUserPrincipal> principals = jdbcTemplate.query("""
SELECT m.application_user_id, m.end_user_name, m.data_role_name, m.lookup_key_ref
FROM cb_dds_end_user_map m
JOIN cb_app_user u ON u.user_id = m.application_user_id
WHERE m.application_user_id = ?
AND m.publish_status = 'PUBLISHED'
AND u.active = 'Y'
""", (row, ignored) -> new DdsMcpEndUserPrincipal(
row.getLong("application_user_id"),
row.getString("end_user_name"),
row.getString("data_role_name"),
row.getString("lookup_key_ref")), applicationUserId);
if (principals.size() != 1) {
throw denied();
}
DdsMcpEndUserPrincipal principal = principals.getFirst();
if (!safeEndUserName(principal.endUserName()) || !safeDataRoleName(principal.dataRoleName())) {
throw denied();
}
return principal;
} catch (AppException exception) {
throw exception;
} catch (DataAccessException exception) {
throw new AppException("DDS_CONTEXT_UNAVAILABLE: DDS END USER 게시 상태를 확인할 수 없습니다.");
}
}
private AppException denied() {
return new AppException("AUTHORIZATION_DENIED: DDS END USER가 게시되지 않았습니다.");
}
private boolean safeEndUserName(String value) {
return value != null && value.matches("DDS_U_[0-9]{1,30}");
}
private boolean safeDataRoleName(String value) {
return value != null && value.matches("DDS_U_[0-9]{1,30}_ROLE");
}
}

View File

@@ -0,0 +1,113 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpAuthenticatedUser;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpVectorSearchResult;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.stereotype.Service;
/** JSON-RPC surface for the DDS-only SSE MCP endpoint. */
@Service
public class DdsMcpSseService {
private final ObjectMapper objectMapper;
private final DdsMcpVectorSearchService vectorSearchService;
public DdsMcpSseService(ObjectMapper objectMapper, DdsMcpVectorSearchService vectorSearchService) {
this.objectMapper = objectMapper;
this.vectorSearchService = vectorSearchService;
}
public ObjectNode handle(DdsMcpAuthenticatedUser user, JsonNode request) {
ObjectNode response = objectMapper.createObjectNode();
response.put("jsonrpc", "2.0");
if (request != null && request.has("id")) {
response.set("id", request.get("id"));
}
String method = request == null ? "" : request.path("method").asText("");
try {
ObjectNode result = switch (method) {
case "initialize" -> initialize();
case "notifications/initialized" -> objectMapper.createObjectNode();
case "tools/list" -> tools();
case "tools/call" -> toolCall(user, request.path("params"));
default -> throw new AppException("지원하지 않는 MCP method입니다: " + method);
};
response.set("result", result);
} catch (Exception exception) {
ObjectNode error = objectMapper.createObjectNode();
error.put("code", -32000);
error.put("message", safeMessage(exception));
response.set("error", error);
}
return response;
}
private ObjectNode initialize() {
ObjectNode result = objectMapper.createObjectNode();
result.put("protocolVersion", "2024-11-05");
ObjectNode serverInfo = result.putObject("serverInfo");
serverInfo.put("name", "dds-end-user-mcp");
serverInfo.put("version", "0.1.0");
result.putObject("capabilities").putObject("tools");
return result;
}
private ObjectNode tools() {
ObjectNode result = objectMapper.createObjectNode();
ArrayNode tools = result.putArray("tools");
ObjectNode search = tools.addObject();
search.put("name", "dds_vector_search");
search.put("description", "DDS END USER Context와 DATA GRANT로 제한된 지식 검색");
ObjectNode schema = search.putObject("inputSchema");
schema.put("type", "object");
ObjectNode properties = schema.putObject("properties");
properties.putObject("query").put("type", "string").put("description", "검색 질문");
properties.putObject("limit").put("type", "integer").put("minimum", 1).put("maximum", 100);
ObjectNode embeddingMode = properties.putObject("embeddingMode");
embeddingMode.put("type", "string");
embeddingMode.putArray("enum").add("DEMO").add("AI");
embeddingMode.put("default", "DEMO");
schema.putArray("required").add("query");
schema.put("additionalProperties", false);
return result;
}
private ObjectNode toolCall(DdsMcpAuthenticatedUser user, JsonNode params) {
if (!"dds_vector_search".equals(params.path("name").asText(""))) {
throw new AppException("DDS MCP tool을 찾을 수 없습니다.");
}
JsonNode arguments = params.path("arguments");
DdsMcpVectorSearchResult search = vectorSearchService.search(
user.principal(),
arguments.path("query").asText(""),
arguments.path("limit").asInt(10),
arguments.path("embeddingMode").asText("DEMO"));
ObjectNode body = objectMapper.createObjectNode();
body.put("query", search.query());
body.put("embeddingMode", search.embeddingMode());
body.put("rowCount", search.rowCount());
body.set("rows", objectMapper.valueToTree(search.rows()));
ObjectNode result = objectMapper.createObjectNode();
result.putArray("content").addObject().put("type", "text")
.put("text", pretty(body));
result.put("isError", false);
return result;
}
private String pretty(ObjectNode body) {
try {
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(body);
} catch (Exception exception) {
return "DDS MCP 결과를 직렬화할 수 없습니다.";
}
}
private String safeMessage(Exception exception) {
String message = exception.getMessage();
return message == null || message.isBlank() ? "DDS MCP 요청을 처리할 수 없습니다." : message;
}
}

View File

@@ -0,0 +1,105 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpEndUserPrincipal;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpVectorSearchResult;
import com.cloudhandson.vpdbackoffice.domain.vector.VectorQueryEmbedding;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.VectorKnowledgeService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/** Runs the DDS-protected vector query only inside a local END USER context. */
@Service
public class DdsMcpVectorSearchService {
private static final Logger log = LoggerFactory.getLogger(DdsMcpVectorSearchService.class);
private final VectorKnowledgeService vectorKnowledgeService;
private final DdsProperties ddsProperties;
private final DdsMcpContextExecutor contextExecutor;
private final ObjectMapper objectMapper;
public DdsMcpVectorSearchService(
VectorKnowledgeService vectorKnowledgeService,
DdsProperties ddsProperties,
DdsMcpContextExecutor contextExecutor,
ObjectMapper objectMapper
) {
this.vectorKnowledgeService = vectorKnowledgeService;
this.ddsProperties = ddsProperties;
this.contextExecutor = contextExecutor;
this.objectMapper = objectMapper;
}
public DdsMcpVectorSearchResult search(
DdsMcpEndUserPrincipal principal,
String query,
int requestedLimit,
String embeddingMode
) {
VectorQueryEmbedding vector = vectorKnowledgeService.vectorizeQuery(query, embeddingMode);
String embedding = embeddingFrom(vector);
int limit = Math.max(1, Math.min(requestedLimit, 100));
return contextExecutor.withContext(principal, connection -> {
String sql = """
SELECT chunk_id, document_id, chunk_no, title, chunk_text, source_uri, tech_tag, score
FROM (
SELECT d.chunk_id, d.document_id, d.chunk_no, d.title, d.chunk_text, d.source_uri,
d.tech_tag, VECTOR_DISTANCE(d.embedding, TO_VECTOR(?), COSINE) AS score
FROM %s d
WHERE d.embedding IS NOT NULL
ORDER BY score
)
WHERE ROWNUM <= ?
""".formatted(ddsProperties.vectorObject());
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setQueryTimeout((int) Math.max(1, ddsProperties.queryTimeout().toSeconds()));
statement.setString(1, embedding);
statement.setInt(2, limit);
List<Map<String, Object>> rows = new ArrayList<>();
log.info("DDS vector query started");
try (ResultSet result = statement.executeQuery()) {
while (result.next()) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("chunkId", result.getLong("chunk_id"));
row.put("documentId", result.getString("document_id"));
row.put("chunkNo", result.getInt("chunk_no"));
row.put("title", result.getString("title"));
row.put("chunkText", result.getString("chunk_text"));
row.put("sourceUri", result.getString("source_uri"));
row.put("techTag", result.getString("tech_tag"));
row.put("score", result.getObject("score"));
rows.add(Map.copyOf(row));
}
}
log.info("DDS vector query completed with {} rows", rows.size());
return new DdsMcpVectorSearchResult(vector.query(), vector.embeddingMode(), rows.size(), List.copyOf(rows));
} catch (java.sql.SQLException exception) {
throw new AppException("DDS_QUERY_FAILED: DDS 보호 객체를 조회할 수 없습니다.");
}
});
}
private String embeddingFrom(VectorQueryEmbedding vector) {
try {
JsonNode embedding = objectMapper.readTree(vector.requestBody()).path("embedding");
if (!embedding.isArray() || embedding.isEmpty()) {
throw new AppException("DDS_QUERY_FAILED: 검색 임베딩을 만들 수 없습니다.");
}
return embedding.toString();
} catch (AppException exception) {
throw exception;
} catch (Exception exception) {
throw new AppException("DDS_QUERY_FAILED: 검색 임베딩을 읽을 수 없습니다.");
}
}
}

View File

@@ -70,6 +70,10 @@ public class DdsVectorKnowledgeService {
return commonVectorService.aiEmbeddingConfigured();
}
public boolean tokenSearchAvailable() {
return !properties.dbUrl().isBlank() && properties.token() != null && properties.token().configured();
}
public VectorIngestResult ingest(VectorIngestCommand command) {
return commonVectorService.ingest(command);
}
@@ -108,8 +112,6 @@ public class DdsVectorKnowledgeService {
String requestId = UUID.randomUUID().toString();
String marker = "DDS_EVIDENCE:" + requestId;
String submittedStatement = "지식자료 벡터 검색 · " + properties.vectorObject()
+ " · 유사도순 정렬 · 결과 " + limit + "";
String sql = "SELECT /* " + marker + " */ chunk_id, document_id, chunk_no, title, chunk_text, source_uri, tech_tag, score "
+ "FROM (SELECT d.chunk_id, d.document_id, d.chunk_no, d.title, d.chunk_text, d.source_uri, "
+ "d.tech_tag, VECTOR_DISTANCE(d.embedding, TO_VECTOR(?), COSINE) AS score "
@@ -144,7 +146,7 @@ public class DdsVectorKnowledgeService {
String message = rows.isEmpty()
? "DDS DATA GRANT를 통과한 검색 단위가 없습니다."
: rows.size() + "개 검색 단위가 DDS DATA GRANT를 통과했습니다.";
DdsSqlEvidence evidence = collectEvidence(connection, marker, submittedStatement, timeoutSeconds);
DdsSqlEvidence evidence = collectEvidence(connection, marker, sql, timeoutSeconds);
evidenceHistory.record(evidence);
return new DdsVectorSearchResult(
normalizedUserKey, userLabel, properties.vectorObject(), normalizedQuery, mode,
@@ -197,8 +199,6 @@ public class DdsVectorKnowledgeService {
String requestId = UUID.randomUUID().toString();
String marker = "DDS_EVIDENCE:" + requestId;
String submittedStatement = "지식자료 벡터 검색 · " + properties.vectorObject()
+ " · 유사도순 정렬 · 결과 " + limit + "";
String sql = "SELECT /* " + marker + " */ chunk_id, document_id, chunk_no, title, chunk_text, source_uri, tech_tag, score "
+ "FROM (SELECT d.chunk_id, d.document_id, d.chunk_no, d.title, d.chunk_text, d.source_uri, "
+ "d.tech_tag, VECTOR_DISTANCE(d.embedding, TO_VECTOR(?), COSINE) AS score "
@@ -212,6 +212,11 @@ public class DdsVectorKnowledgeService {
context.setString(1, normalizedToken);
context.execute();
}
try (PreparedStatement identifier = connection.prepareStatement(
"BEGIN DBMS_SESSION.SET_IDENTIFIER(?); END;")) {
identifier.setString(1, marker);
identifier.execute();
}
String sessionUser = readSingleValue(connection,
"SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM dual", timeoutSeconds);
String endUser = readSingleValue(connection,
@@ -244,7 +249,7 @@ public class DdsVectorKnowledgeService {
: rows.size() + "개 검색 단위가 토큰으로 식별된 업무 사용자("
+ (resolvedAppUser == null ? "확인 불가" : resolvedAppUser)
+ ")의 DDS DATA GRANT를 통과했습니다.";
DdsSqlEvidence evidence = collectEvidence(connection, marker, submittedStatement, timeoutSeconds);
DdsSqlEvidence evidence = collectEvidence(connection, marker, sql, timeoutSeconds);
evidenceHistory.record(evidence);
return new DdsVectorSearchResult(
"token", userLabel, properties.vectorObject(), normalizedQuery, mode,
@@ -271,7 +276,7 @@ public class DdsVectorKnowledgeService {
false, title, message, null, null, oracleCode, List.of(), null);
}
private DdsSqlEvidence collectEvidence(Connection connection, String marker, String submittedStatement,
private DdsSqlEvidence collectEvidence(Connection connection, String marker, String executedSql,
int timeoutSeconds) {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT sql_id, child_number, TO_CHAR(last_active_time, 'YYYY-MM-DD HH24:MI:SS'), executions
@@ -281,20 +286,20 @@ public class DdsVectorKnowledgeService {
statement.setString(1, "%" + marker + "%");
try (ResultSet result = statement.executeQuery()) {
if (!result.next()) {
return DdsSqlEvidence.unavailable(marker, submittedStatement, "실행 SQL ID를 아직 찾지 못했습니다.");
return DdsSqlEvidence.unavailable(marker, executedSql, "실행 SQL은 확인됐지만 SQL ID를 아직 찾지 못했습니다.");
}
String sqlId = result.getString(1);
int child = result.getInt(2);
String vpd = readPredicate(connection, "SELECT predicate FROM v$vpd_policy WHERE sql_id = ?", sqlId, timeoutSeconds);
String plan = readPlanPredicate(connection, sqlId, child, timeoutSeconds);
return new DdsSqlEvidence(true, marker, submittedStatement, sqlId, child, result.getString(3),
return new DdsSqlEvidence(true, marker, executedSql, sqlId, child, result.getString(3),
result.getLong(4), plan, vpd,
vpd != null ? "VPD 정책 predicate를 확인했습니다."
: "DDS DATA GRANT 경로의 실행 SQL을 확인했습니다.");
}
} catch (SQLException exception) {
return DdsSqlEvidence.unavailable(marker, submittedStatement,
"실행 근거 조회 권한이 없습니다. V$SQL, V$VPD_POLICY, DBMS_XPLAN 권한을 확인하세요.");
return DdsSqlEvidence.unavailable(marker, executedSql,
"실행 SQL 전문을 표시합니다. SQL ID와 실행 계획 조회는 이 환경에서 사용할 수 없습니다.");
}
}

View File

@@ -0,0 +1,80 @@
package com.cloudhandson.ddsbackoffice.web;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpAuthenticatedUser;
import com.cloudhandson.ddsbackoffice.service.DdsMcpBearerAuthenticator;
import com.cloudhandson.ddsbackoffice.service.DdsMcpSseService;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
/**
* DDS-specific SSE transport. Authorization is deliberately rechecked for
* every message rather than being trusted for the life of an SSE connection.
*/
@Controller
public class DdsMcpSseController {
private static final long SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L;
private final DdsMcpBearerAuthenticator bearerAuthenticator;
private final DdsMcpSseService service;
private final Map<String, Session> sessions = new ConcurrentHashMap<>();
public DdsMcpSseController(DdsMcpBearerAuthenticator bearerAuthenticator, DdsMcpSseService service) {
this.bearerAuthenticator = bearerAuthenticator;
this.service = service;
}
@GetMapping(path = "/dds/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter sse(@RequestHeader(name = "Authorization", required = false) String authorization) throws IOException {
DdsMcpAuthenticatedUser user = bearerAuthenticator.authenticate(authorization);
String sessionId = UUID.randomUUID().toString();
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS);
sessions.put(sessionId, new Session(user.applicationUserId(), emitter));
emitter.onCompletion(() -> sessions.remove(sessionId));
emitter.onTimeout(() -> sessions.remove(sessionId));
emitter.onError(error -> sessions.remove(sessionId));
emitter.send(SseEmitter.event().name("endpoint").data("/dds/mcp/messages?sessionId=" + sessionId));
return emitter;
}
@PostMapping(path = "/dds/mcp/messages", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> message(
@RequestHeader(name = "Authorization", required = false) String authorization,
@RequestParam(required = false) String sessionId,
@RequestBody JsonNode request
) throws IOException {
DdsMcpAuthenticatedUser user = bearerAuthenticator.authenticate(authorization);
ObjectNode response = service.handle(user, request);
if (sessionId == null || sessionId.isBlank()) {
return ResponseEntity.ok(response);
}
Session session = sessions.get(sessionId);
if (session == null || session.applicationUserId() != user.applicationUserId()) {
throw new AppException("AUTHORIZATION_DENIED: MCP 세션을 확인할 수 없습니다.");
}
try {
session.emitter().send(SseEmitter.event().name("message").data(response));
return ResponseEntity.accepted().build();
} catch (IOException exception) {
sessions.remove(sessionId);
throw exception;
}
}
private record Session(long applicationUserId, SseEmitter emitter) {
}
}

View File

@@ -48,6 +48,12 @@ public class DdsProtectionController {
return "dds-evidence";
}
@GetMapping("/dds-admin")
public String admin(Model model) {
model.addAttribute("ddsVectorObject", properties.vectorObject());
return "dds-admin";
}
@GetMapping("/dds-protection/direct")
public String directComparison(Model model) {
model.addAttribute("directPaths", protectionStatusService.directComparison());

View File

@@ -2,6 +2,7 @@ package com.cloudhandson.ddsbackoffice.web;
import com.cloudhandson.ddsbackoffice.domain.DdsProvisioningPlan;
import com.cloudhandson.ddsbackoffice.service.DdsGrantPublisher;
import com.cloudhandson.ddsbackoffice.service.DdsMcpEndUserPublisher;
import com.cloudhandson.ddsbackoffice.service.DdsProtectionEvidenceStore;
import com.cloudhandson.ddsbackoffice.service.DdsProtectionFingerprint;
import com.cloudhandson.ddsbackoffice.domain.DdsPublishedProtectionSpec;
@@ -16,10 +17,16 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
public class DdsProvisionController {
private final DdsGrantPublisher publisher;
private final DdsMcpEndUserPublisher mcpEndUserPublisher;
private final DdsProtectionEvidenceStore evidenceStore;
public DdsProvisionController(DdsGrantPublisher publisher, DdsProtectionEvidenceStore evidenceStore) {
public DdsProvisionController(
DdsGrantPublisher publisher,
DdsMcpEndUserPublisher mcpEndUserPublisher,
DdsProtectionEvidenceStore evidenceStore
) {
this.publisher = publisher;
this.mcpEndUserPublisher = mcpEndUserPublisher;
this.evidenceStore = evidenceStore;
}
@@ -33,10 +40,17 @@ public class DdsProvisionController {
public String publish(RedirectAttributes redirectAttributes) {
try {
DdsProvisioningPlan plan = publisher.publish();
var mcpResult = mcpEndUserPublisher.publish();
redirectAttributes.addFlashAttribute(
"successMessage",
plan.publishableCount() + "개 DDS DATA GRANT를 게시했습니다. 권한 없는 대상의 기존 Grant는 회수했습니다."
plan.publishableCount() + " 직접 DDS DATA GRANT"
+ mcpResult.publishedUsers() + "개 MCP local END USER 권한을 게시했습니다."
);
if (mcpResult.defaultDeniedUsers() > 0 || mcpResult.revokedUsers() > 0) {
redirectAttributes.addFlashAttribute("warningMessage",
"MCP 기본 거부 " + mcpResult.defaultDeniedUsers() + "명, 비활성 사용자 권한 회수 "
+ mcpResult.revokedUsers() + "명을 반영했습니다.");
}
try {
plan.grants().forEach(grant -> evidenceStore.recordPublished(
new DdsPublishedProtectionSpec(

View File

@@ -4,6 +4,8 @@ import com.cloudhandson.ddsbackoffice.service.DdsQueryService;
import com.cloudhandson.ddsbackoffice.service.DdsVectorKnowledgeService;
import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestCommand;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
import com.cloudhandson.vpdbackoffice.service.UserService;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -19,13 +21,17 @@ public class DdsVectorKnowledgeController {
private final DdsVectorKnowledgeService service;
private final DdsQueryService ddsQueryService;
private final UserService userService;
private final BearerTokenService bearerTokenService;
public DdsVectorKnowledgeController(
DdsVectorKnowledgeService service,
DdsQueryService ddsQueryService
DdsQueryService ddsQueryService, UserService userService, BearerTokenService bearerTokenService
) {
this.service = service;
this.ddsQueryService = ddsQueryService;
this.userService = userService;
this.bearerTokenService = bearerTokenService;
}
@GetMapping("/vector-knowledge")
@@ -101,16 +107,29 @@ public class DdsVectorKnowledgeController {
return "fragments/dds-vector-search-result :: result";
}
@PostMapping("/vector-knowledge/user-search")
public String userSearch(@RequestParam long userId, @RequestParam String query,
@RequestParam(defaultValue = "10") int limit, @RequestParam(defaultValue = "DEMO") String embeddingMode,
Model model) {
var token = bearerTokenService.issueTemporaryToken(userId, "DDS 업무 사용자 검증");
try { model.addAttribute("searchResult", service.searchByToken(token.plainToken(), query, limit, embeddingMode)); }
finally { bearerTokenService.revokeToken(token.keyId(), "DDS 업무 사용자 검증 완료"); }
return "fragments/dds-vector-search-result :: result";
}
private void populatePage(Model model) {
try {
model.addAttribute("summary", service.summary());
model.addAttribute("aiEmbeddingConfigured", service.aiEmbeddingConfigured());
model.addAttribute("tokenSearchAvailable", service.tokenSearchAvailable());
} catch (DataAccessException exception) {
model.addAttribute("summary", null);
model.addAttribute("runtimeError", exception.getMessage());
model.addAttribute("aiEmbeddingConfigured", false);
model.addAttribute("tokenSearchAvailable", false);
}
model.addAttribute("ddsUsers", ddsQueryService.users());
model.addAttribute("appUsers", userService.findAll().stream().filter(user -> "Y".equals(user.activeYn())).toList());
model.addAttribute("ddsVectorObject", ddsQueryService.vectorObject());
}
}

View File

@@ -56,6 +56,16 @@ backoffice:
timeout: ${BACKOFFICE_AI_TIMEOUT_SECONDS:30}s
dds:
mcp:
permission-sync-enabled: true
iam:
domain-url: ${DDS_OCI_IAM_DOMAIN_URL:}
token-uri: ${DDS_OCI_IAM_TOKEN_URI:}
client-id: ${DDS_OCI_IAM_CLIENT_ID:}
client-secret: ${DDS_OCI_IAM_CLIENT_SECRET:}
database-scope: ${DDS_OCI_IAM_DATABASE_SCOPE:}
timeout: ${DDS_OCI_IAM_TIMEOUT_SECONDS:10s}
refresh-skew: ${DDS_OCI_IAM_REFRESH_SKEW_SECONDS:60s}
db-url: ${DDS_BACKOFFICE_DB_URL:${BACKOFFICE_DB_URL:jdbc:oracle:thin:@localhost:1521/FREEPDB1}}
query-timeout: ${DDS_BACKOFFICE_QUERY_TIMEOUT:10s}
pg-object: ${DDS_BACKOFFICE_PG_OBJECT:ADMIN.V_DDS_CUSTOMERS_PG}

View File

@@ -93,6 +93,8 @@ body { margin: 0; background: var(--dds-bg); color: var(--dds-ink); font-family:
.registration-result dd { font-size: .9rem; font-weight: 700; margin: .2rem 0 0; }
.advanced-workflow > details > summary { color: var(--dds-ink); cursor: pointer; font-size: 1rem; font-weight: 700; }
.advanced-workflow .section-subtitle { margin: .4rem 0 0; }
.advanced-workflow { display: none; }
.menu-flow { display: grid; gap: .6rem; grid-template-columns: repeat(4, minmax(0, 1fr)); }.menu-flow a { background: #f8faff; border: 1px solid var(--dds-line); border-radius: 8px; color: var(--dds-ink); padding: .9rem; text-decoration: none; }.menu-flow strong, .menu-flow span { display: block; }.menu-flow span { color: var(--dds-muted); font-size: .82rem; margin-top: .35rem; }.architecture-map { align-items: center; display: flex; flex-wrap: wrap; gap: .45rem; margin: 1rem 0; }.architecture-map span { background: #edf2ff; border-radius: 6px; padding: .45rem .6rem; }.architecture-map b { color: var(--dds-accent); }.erd-map { display: grid; gap: .5rem; grid-template-columns: repeat(5, minmax(0, 1fr)); margin: 1rem 0; }.erd-map div { border: 1px solid var(--dds-line); border-radius: 7px; padding: .65rem; }.erd-map strong, .erd-map small { display: block; }.erd-map small { color: var(--dds-muted); margin-top: .25rem; }
.form-grid > .btn { justify-self: start; min-width: 7.5rem; width: auto; }
.wizard-progress, .wizard-step-number { display: none; }
.permission-wizard .wizard-panel-heading { align-items: flex-start; }
@@ -134,6 +136,7 @@ body { margin: 0; background: var(--dds-bg); color: var(--dds-ink); font-family:
.hero { padding-top: 30px; }
.protection-status-topline, .protection-status-footer { align-items: flex-start; flex-direction: column; }
.knowledge-options-grid { grid-template-columns: 1fr; }
.menu-flow, .erd-map { grid-template-columns: 1fr; }
.knowledge-options-grid .full-row { grid-column: auto; }
.registration-result { flex-direction: column; }
}

View File

@@ -0,0 +1 @@
<!doctype html><html lang="ko" xmlns:th="http://www.thymeleaf.org"><head th:replace="~{fragments/layout :: head('Admin')}"></head><body><nav th:replace="~{fragments/layout :: nav}"></nav><main class="container py-4"><div class="page-title"><h1>Admin</h1><p class="context-summary">운영 환경과 진단 기능</p></div><section class="content-band"><h2>업무 사용자 검색 연결</h2><p class="section-subtitle">사용자·그룹·역할·권한은 테이블에서 조회합니다. 이 설정은 그 결과를 DDS 보호 객체에 전달할 단일 기술 연결만 관리합니다.</p><details class="explanation-details"><summary>설정 안내</summary><p class="mb-0">Bearer 토큰 원문과 DDS 비밀번호는 권한 테이블에 저장하지 않습니다. 서버 Secret 또는 Wallet에 기술 연결 정보를 등록하세요.</p></details></section><section class="content-band"><h2>보호 대상 설정</h2><p class="section-subtitle" th:text="${ddsVectorObject}">protected object</p><a class="btn btn-sm rw-btn-secondary" href="/dds">보호 대상 관리</a></section><section class="content-band"><h2>DDS 연결 진단</h2><p class="section-subtitle">통합·차단·PG·MY fixture는 업무 사용자 검증이 아닌 DB 연결 진단에만 사용합니다.</p><a class="btn btn-sm rw-btn-secondary" href="/dds">연결 진단 열기</a></section><section class="content-band"><h2>실행 근거</h2><p class="section-subtitle">SQL 실행 증거와 진단 설정</p><a class="btn btn-sm rw-btn-secondary" href="/dds-evidence">실행 근거 보기</a></section></main></body></html>

View File

@@ -6,16 +6,20 @@
<main class="container py-4">
<div class="page-title">
<h1>실행 근거</h1>
<p class="context-summary">최근 지식자료 검색의 SQL ID와 보호 조건</p>
<details class="explanation-details"><summary>도움말</summary><p class="mb-0">토큰·바인드 값·검색 본문은 기록하지 않습니다. SQL ID와 실행 계획 predicate는 DB 커서 캐시에서 읽으므로 캐시가 비워지면 조회할 수 없습니다.</p></details>
<p class="context-summary">FGA 감사 행으로 확인하는 보호 객체 접근 내역</p>
<details class="explanation-details"><summary>도움말</summary><p class="mb-0">요청 ID, 업무 사용자 Context, SQL 원문, 보호 객체, 성공·실패는 FGA/Unified Audit 감사 행에서 조회합니다. 토큰 원문과 바인드 값은 표시하지 않습니다.</p></details>
</div>
<section class="content-band">
<div class="section-heading"><div><h2>FGA 감사 증적</h2><p class="section-subtitle">보호 VIEW SELECT 실행 뒤 생성된 감사 행</p></div><span class="badge text-bg-secondary">감사 trail 연동</span></div>
<div class="empty-result-guide">다음 검색부터 요청 ID로 FGA 감사 행을 연결합니다. 감사 정책이 적용되면 SQL 원문·업무 사용자 Context·반환 코드가 이 영역에 표시됩니다.</div>
</section>
<section class="content-band" th:if="${#lists.isEmpty(evidenceEntries)}">
<h2>표시할 실행 내역이 없습니다.</h2><p class="section-subtitle">권한 검색 또는 직접 접근 검증을 실행하면 이 화면에 최근 내역이 표시됩니다.</p>
</section>
<section class="content-band" th:each="entry : ${evidenceEntries}">
<div class="section-heading"><div><h2 th:text="${entry.submittedStatement()}">지식자료 검색</h2><p class="section-subtitle" th:text="${entry.status()}">status</p></div><span class="badge text-bg-secondary" th:text="${entry.available()} ? '실행 확인' : '조회 제한'">status</span></div>
<div class="section-heading"><div><h2>지식자료 검색</h2><p class="section-subtitle" th:text="${entry.status()}">status</p></div><span class="badge text-bg-secondary" th:text="${entry.available()} ? '실행 확인' : '조회 제한'">status</span></div>
<dl class="protection-evidence-grid"><div><dt>SQL ID</dt><dd><code th:text="${entry.sqlId() ?: '-'}">-</code></dd></div><div><dt>마지막 실행</dt><dd th:text="${entry.lastActiveTime() ?: '-'}">-</dd></div><div><dt>실행 횟수</dt><dd th:text="${entry.executions() ?: '-'}">-</dd></div></dl>
<details class="technical-details"><summary>적용 조건 보기</summary><p th:if="${entry.planPredicate()}"><strong>실행 계획 predicate</strong><br><code th:text="${entry.planPredicate()}">predicate</code></p><p class="mb-0" th:if="${entry.vpdPredicate()}"><strong>VPD predicate</strong><br><code th:text="${entry.vpdPredicate()}">predicate</code></p></details>
<details class="technical-details"><summary>실행 SQL과 적용 조건 보기</summary><p><strong>실행 SQL</strong><br><code th:text="${entry.executedSql()}">statement</code></p><p th:if="${entry.planPredicate()}"><strong>실행 계획 predicate</strong><br><code th:text="${entry.planPredicate()}">predicate</code></p><p class="mb-0" th:if="${entry.vpdPredicate()}"><strong>VPD predicate</strong><br><code th:text="${entry.vpdPredicate()}">predicate</code></p></details>
</section>
</main>
</body>

View File

@@ -17,6 +17,12 @@
<div class="alert alert-warning" th:if="${runtimeError}">DDS 관리 데이터를 불러오지 못했습니다. <span th:text="${runtimeError}"></span></div>
<section class="content-band">
<div class="section-heading"><div><h2>권한 적용 흐름</h2><p class="section-subtitle">권한을 정하고, 보호 정책에 반영한 뒤 결과를 확인합니다.</p></div></div>
<div class="menu-flow"><a href="/permissions"><strong>권한 관리</strong><span>사용자·그룹·역할과 접근 태그 규칙</span></a><a href="/dds-protection"><strong>보호 정책 관리</strong><span>보호 대상과 현재 집행 상태</span></a><a href="/vector-knowledge"><strong>지식자료 관리</strong><span>자료 등록과 권한 검색</span></a><a href="/dds-evidence"><strong>접근 검증</strong><span>실행 결과와 적용 근거</span></a></div>
<details class="explanation-details mt-3"><summary>전체 구조 보기</summary><div class="architecture-map"><span>사용자 테이블 사용자</span><b></b><span>그룹 · 역할 · 접근 규칙</span><b></b><span>MCP Bearer</span><b></b><span>local DDS END USER Context</span><b></b><span>DATA GRANT</span><b></b><span>보호 VIEW · 검색 결과</span></div><div class="erd-map"><div><strong>CB_APP_USER</strong><small>업무 사용자</small></div><div><strong>CB_GROUP · CB_ROLE</strong><small>소속과 역할</small></div><div><strong>CB_PERMISSION_RULE</strong><small>TAG 허용·거부</small></div><div><strong>CB_AGENT_BEARER_KEY</strong><small>요청 사용자 식별</small></div><div><strong>CB_DDS_END_USER_MAP</strong><small>DDS 보안 사용자 매핑</small></div></div><p class="mb-0">업무 사용자는 기존 사용자 테이블에서 관리합니다. OCI IAM은 서비스의 DB Context attach만 승인하고, 실제 행·컬럼 권한은 해당 local DDS END USER의 DATA GRANT가 집행합니다.</p></details>
</section>
<section class="summary-grid" aria-label="현재 DDS 관리 현황">
<a class="summary-tile" href="/permissions"><span class="label">역할</span><strong th:text="${#lists.size(roles)}">0</strong></a>
<a class="summary-tile" href="/permissions"><span class="label">권한 규칙</span><strong th:text="${#lists.size(permissions)}">0</strong></a>

View File

@@ -10,8 +10,8 @@
<p class="context-summary">사용자·그룹·역할의 접근 규칙을 검토한 뒤 DDS 보호 규칙에 반영합니다.</p>
<details class="explanation-details">
<summary>도움말</summary>
<p>이 화면은 직접 DDS END USER 비교 경로와 일반 객체의 선언형 Grant를 미리 보고 게시하는 단계입니다. 토큰 기반 벡터 경로는 별도 객체별 Data Grant predicate가 요청 시 공통 권한 테이블을 다시 평가합니다.</p>
<p class="mb-0">그룹 자체를 DDS 그룹으로 복사하지는 않습니다. 애플리케이션 그룹 역할 상속을 계산해 직접 비교용 DATA ROLE Grant에 반영하고, 토큰 경로에서는 같은 effective role 계산을 predicate 함수가 사용합니다.</p>
<p>게시하면 직접 DDS 비교 Grant와 MCP SSE용 local END USER/DATA ROLE/DATA GRANT를 함께 갱신합니다. MCP 요청 Bearer는 기존 사용자 테이블의 업무 사용자를 찾고, DDS는 그 사용자의 게시된 권한만 집행합니다.</p>
<p class="mb-0">OCI IAM client-credentials token은 서비스의 Context attach 권한만 제공합니다. MCP 업무 사용자를 IAM에 따로 만들지 않으며, 그룹 역할 상속은 기존 권한 관리 기준으로 계산합니다.</p>
</details>
</div>
@@ -27,12 +27,12 @@
<tr><td>애플리케이션 사용자</td><td>매핑된 DDS END USER + DATA ROLE</td><td>게시할 때 매핑 확인</td></tr>
<tr><td>그룹과 그룹에 연결된 역할</td><td>그룹을 복사하지 않고 최종 predicate로 합산</td><td>미리보기·게시 때 계산</td></tr>
<tr><td>테이블·VIEW SELECT 권한</td><td>보호 객체별 <code>DATA GRANT ... WHERE ...</code></td><td>권한 변경 후 게시</td></tr>
<tr><td>Bearer 토큰 벡터 검색</td><td>기술 사용자 Context + 객체별 Data Grant predicate</td><td><code>34_dds_token_data_grant_common_auth.sql</code> 경계</td></tr>
<tr><td>MCP Bearer 벡터 검색</td><td>Bearer → 업무 사용자 → local DDS END USER Context → DATA GRANT</td><td>권한 저장 시 자동 bulk 동기화 · 이 화면은 전체 재동기화</td></tr>
<tr><td>원문 표시 허용 컬럼</td><td><code>AS SELECT</code> 또는 <code>ALL COLUMNS EXCEPT</code></td><td>권한 변경 후 게시</td></tr>
</tbody>
</table>
</div>
<p class="text-muted mt-3 mb-0">VPD는 요청마다 권한 테이블을 평가하지만, DDS는 게시된 Grant가 바뀔 때까지 이전 선언을 계속 사용합니다. 이 화면의 게시 버튼이 두 모델을 동기화하는 경계입니다.</p>
<p class="text-muted mt-3 mb-0">DDS는 게시된 Grant가 바뀔 때까지 이전 선언을 사용합니다. 이 화면의 게시 버튼이 기존 권한 관리와 MCP local END USER 권한을 동기화하는 경계입니다.</p>
</details>
</section>

View File

@@ -41,7 +41,7 @@
<div><dt>실행 횟수</dt><dd th:text="${searchResult.sqlEvidence().executions()}">0</dd></div>
</dl>
<details class="technical-details mt-2"><summary>실행 조건 보기</summary>
<p>요청: <span th:text="${searchResult.sqlEvidence().submittedStatement()}">statement</span></p>
<p><strong>실행 SQL</strong><br><code th:text="${searchResult.sqlEvidence().executedSql()}">statement</code></p>
<p th:if="${searchResult.sqlEvidence().planPredicate()}">계획 predicate: <code th:text="${searchResult.sqlEvidence().planPredicate()}">predicate</code></p>
<p class="mb-0" th:if="${searchResult.sqlEvidence().vpdPredicate()}">VPD predicate: <code th:text="${searchResult.sqlEvidence().vpdPredicate()}">predicate</code></p>
</details>

View File

@@ -22,6 +22,7 @@
<button class="rw-menu-trigger" type="button" data-submenu-trigger="knowledge" aria-controls="submenu-knowledge" aria-expanded="false">지식자료 관리</button>
<button class="rw-menu-trigger" type="button" data-submenu-trigger="verification" aria-controls="submenu-verification" aria-expanded="false">접근 검증</button>
</div>
<a class="btn btn-sm rw-btn-secondary" href="/dds-admin">Admin</a>
<form method="post" action="/logout" class="ms-auto">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<button class="btn btn-sm btn-outline-secondary" type="submit">로그아웃</button>
@@ -54,7 +55,7 @@
<div th:fragment="trackNotice" th:if="${backofficeTrack == 'DDS'}" class="container pt-3">
<div class="alert alert-info mb-0">
<strong>DDS 보호 운영</strong> · 실제 보호는 객체별 <code>DATA GRANT</code>가 담당합니다. 업무 사용자 검색은 <code>Bearer → CB_AGENT_CTX → 접근 조건</code>, 직접 접근 비교는 <code>END USER → DATA ROLE → DATA GRANT</code> 경로로 동작합니다.
<strong>DDS 보호 운영</strong> · 실제 보호는 객체별 <code>DATA GRANT</code>가 담당합니다. MCP 검색은 <code>Bearer → 업무 사용자 → local END USER Context → DATA GRANT</code>, 직접 접근 비교는 <code>END USER → DATA ROLE → DATA GRANT</code> 경로로 동작합니다.
</div>
</div>

View File

@@ -109,11 +109,11 @@
<summary>도움말</summary>
<p class="mb-0">토큰은 검색하는 사용자를 확인하기 위한 임시 값이며 저장하지 않습니다. 회수되었거나 만료된 토큰은 사용할 수 없습니다.</p>
</details>
<form hx-post="/vector-knowledge/token-search" hx-target="#vector-token-search-result" hx-swap="innerHTML" class="knowledge-entry-form">
<form hx-post="/vector-knowledge/user-search" hx-target="#vector-token-search-result" hx-swap="innerHTML" class="knowledge-entry-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>Bearer 토큰
<input class="form-control" name="bearerToken" type="password" autocomplete="off" placeholder="발급된 임시 토큰을 붙여 넣으세요." required>
<span class="form-hint">요청 처리 후 저장하지 않습니다. 회수·만료된 토큰은 거부됩니다.</span>
<label>업무 사용자
<select class="form-select" name="userId" required><option value="" selected disabled>사용자 선택</option><option th:each="user : ${appUsers}" th:value="${user.userId()}" th:text="${user.username() + ' · ' + user.deptCode()}"></option></select>
<span class="form-hint">선택한 사용자에게만 유효한 임시 토큰을 발급하고 검색 직후 회수합니다.</span>
</label>
<label>검색 질문
<textarea class="form-control" name="query" rows="3" placeholder="예: 세일즈 파이프라인 후속 조치 기준" required></textarea>

View File

@@ -0,0 +1,40 @@
package com.cloudhandson.ddsbackoffice.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import org.junit.jupiter.api.Test;
class DdsMcpIamPropertiesTest {
@Test
void derivesTheOciIdentityDomainTokenEndpoint() {
var properties = properties("https://idcs-example.identity.oraclecloud.com", "");
assertTrue(properties.configured());
assertEquals("https://idcs-example.identity.oraclecloud.com/oauth2/v1/token",
properties.tokenEndpoint().toString());
}
@Test
void doesNotReportPartialOauthConfigurationAsReady() {
var properties = new DdsMcpIamProperties("", "", "client", "secret", "", Duration.ofSeconds(10), Duration.ofSeconds(60));
assertFalse(properties.configured());
}
@Test
void rejectsANonHttpsTokenEndpoint() {
var properties = properties("", "http://identity.example.test/oauth2/v1/token");
assertThrows(IllegalStateException.class, properties::tokenEndpoint);
}
private DdsMcpIamProperties properties(String domainUrl, String tokenUri) {
return new DdsMcpIamProperties(domainUrl, tokenUri, "client", "secret", "database-scope",
Duration.ofSeconds(10), Duration.ofSeconds(60));
}
}

View File

@@ -0,0 +1,26 @@
package com.cloudhandson.ddsbackoffice.service;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationChangeNotifier;
import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationSynchronizer;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
class DdsMcpAuthorizationChangeListenerTest {
@Test
void bridgesACommittedAuthorizationChangeToOneBulkMcpPublish() {
DdsMcpEndUserPublisher publisher = org.mockito.Mockito.mock(DdsMcpEndUserPublisher.class);
when(publisher.publish()).thenReturn(new com.cloudhandson.ddsbackoffice.domain.DdsMcpEndUserPublishResult(3, 0, 0));
DdsMcpAuthorizationChangeListener listener = new DdsMcpAuthorizationChangeListener(publisher);
DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
factory.registerSingleton("ddsSynchronizer", (DdsAuthorizationSynchronizer) listener);
new DdsAuthorizationChangeNotifier(factory.getBeanProvider(DdsAuthorizationSynchronizer.class))
.changed("PERMISSION_SAVED");
verify(publisher).publish();
}
}

View File

@@ -0,0 +1,19 @@
package com.cloudhandson.ddsbackoffice.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import org.junit.jupiter.api.Test;
class DdsMcpJdbcApiTest {
@Test
void shipsTheOracleJdbcDdsContextApiNeededByTheMcpExecutor() {
assertDoesNotThrow(() -> {
Class<?> context = Class.forName("oracle.jdbc.EndUserSecurityContext");
context.getMethod("createWithName", CharSequence.class, String.class, CharSequence.class);
Class<?> connection = Class.forName("oracle.jdbc.OracleConnection");
connection.getMethod("setEndUserSecurityContext", context);
connection.getMethod("clearEndUserSecurityContext");
});
}
}

View File

@@ -0,0 +1,34 @@
package com.cloudhandson.ddsbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpAuthenticatedUser;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpEndUserPrincipal;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
class DdsMcpSseServiceTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void exposesNoBearerTokenToolParameter() throws Exception {
// tools/list never calls the query service; null collaborators keep this
// schema-contract test independent from bytecode-agent based mocks.
var service = new DdsMcpSseService(objectMapper,
new DdsMcpVectorSearchService(null, null, null, null));
var user = new DdsMcpAuthenticatedUser(101L, "agent", new DdsMcpEndUserPrincipal(
101L, "DDS_U_101", "DDS_U_101_ROLE", "DDS_OCI_IAM_CLIENT_SECRET_DERIVED_V1"));
var request = objectMapper.readTree("""
{"jsonrpc":"2.0","id":1,"method":"tools/list"}
""");
var response = service.handle(user, request);
var schema = response.path("result").path("tools").get(0).path("inputSchema");
assertThat(response.path("error").isMissingNode()).isTrue();
assertThat(schema.path("required").get(0).asText()).isEqualTo("query");
assertThat(schema.path("properties").has("bearerToken")).isFalse();
assertThat(schema.path("properties").path("embeddingMode").path("enum").toString())
.isEqualTo("[\"DEMO\",\"AI\"]");
}
}

View File

@@ -1,32 +1,21 @@
# ADR-0001: DDS MCP Context에는 개인 IAM 사용자 대신 서비스 애플리케이션을 사용한다
# ADR-0001: DDS MCP Context에는 개인 IAM 사용자 대신 Confidential Application을 사용한다
> **상태**: Proposed
> **날짜**: 2026-07-01 · **결정자**: [AI] Architect · **관련 이슈**: #617
> **상태**: Accepted · **날짜**: 2026-07-02 · **관련 이슈**: #617
## 맥락 (Context)
## 결정
MCP SSE 서비스는 자체 Bearer로 애플리케이션 사용자를 식별하고, local DDS END USER Context를 부착해 Tool SQL을 실행해야 한다. application-mediated DDS Context에는 database-access token이 필요하다.
OCI IAM Confidential Application의 client-credentials token을 database-access token으로 사용한다. MCP 요청자는 기존 opaque Bearer → `CB_APP_USER` 매핑으로 판정하고, 그 사용자를 local DDS `END USER`로 attach한다.
개인 IAM 사용자 `joungmin.ko@oracle.com`의 장기 token을 서비스 설정에 넣으면 개인 계정 수명주기, 감사, 회전, 퇴사, 권한 범위가 MCP 서비스의 가용성과 보안 경계에 직접 결합된다. 이 IAM 사용자는 OCI CLI를 통한 서비스 애플리케이션 등록의 관리자 역할로만 사용한다.
## 근거
## 결정 (Decision)
client-credentials token의 `client_id`/`sub`는 서비스 애플리케이션이다. 이를 업무 사용자로 사용하면 모든 MCP 요청이 같은 사람 권한으로 해석되는 오류가 생긴다. 서비스 승인과 업무 사용자 신원을 분리하면 기존 사용자·그룹·권한 관리 모델을 유지하면서 DDS가 사용자별 `DATA ROLE`/`DATA GRANT`를 집행한다.
`mcp-dds-service`라는 OCI IAM 서비스 애플리케이션을 별도로 등록하고, client-credentials flow로 short-lived database-access token을 취득한다. MCP 사용자는 IAM 사용자가 아니라 기존 `CB_APP_USER` 및 로컬 DDS END USER로 관리한다. client ID·client secret은 승인된 배포 환경의 secret store에만 저장하며 채팅·Redmine·Git에 남기지 않는다.
## 검증
이 결정은 P0 스파이크에서 실제 OCI tenancy/ADB 설정으로 Context attach가 증명되기 전까지 Proposed 상태다.
ADB OCI IAM 설정, database credential, application identity, TLS 연결을 구성하고 실제 `EndUserSecurityContext` attach/query/clear와 SSE MCP `tools/call` HTTP 200을 확인했다.
## 근거 (Rationale)
## 결과
서비스 신뢰와 업무 사용자 신원을 분리한다. database-access token은 서비스의 Context 부착 권한이고, DDS END USER는 DB가 DATA ROLE/DATA GRANT를 집행할 실제 권한 주체다.
## 결과 (Consequences)
- **긍정**: 개인 계정과 서비스 실행 권한을 분리하고, 사용자별 IAM 계정 없이 DDS Context를 적용할 수 있다.
- **부정 / 비용**: OCI IAM 애플리케이션 등록, token cache/rotation, TLS/DB identity 구성, secret 관리가 필요하다.
- **후속 작업**: Free Identity Domain entitlement, client-credentials database token, ADB local END USER Context attach를 스파이크로 검증한다.
## 검토한 대안 (Alternatives Considered)
- **개인 IAM 사용자 token 고정** — 개인 수명주기·감사·권한 회전에 서비스가 종속되어 기각.
- **MCP 사용자마다 IAM 사용자 생성** — ERP/기존 사용자 관리 모델을 IAM으로 중복 이전해야 하므로 기각.
- **IAM 없이 자체 Bearer만 사용** — application-mediated DDS Context attach에 필요한 database-access token을 제공하지 못하므로 기각.
- 개인 IAM 계정·장기 token은 런타임에 사용하지 않는다.
- client ID/secret, raw token, lookup key는 secret store/환경 변수 외에 저장하지 않는다.
- IAM 사용자 token을 직접 받아 DDS에 전달하는 OBO/authorization-code 모델은 별도 ADR과 endpoint 검증으로만 도입한다.

View File

@@ -1,231 +1,110 @@
# 설계서: DDS MCP 사용자별 END USER Context 및 권한 게시 (#617)
> **상태**: Draft — P0 스파이크 승인 전 구현 금지
> **작성**: [AI] Architect · **최종수정**: 2026-07-01
> **상태**: Implemented — OCI IAM·ADB·SSE 실증 완료
> **최종수정**: 2026-07-02
> **추적성** — Redmine: #617 · 관련 ADR: [ADR-0001](../../adr/0001-dds-mcp-service-identity.md)
> · 구현 파일: TBD · 테스트: TBD
> · 구현: `DdsMcpBearerAuthenticator`, `DdsMcpEndUserResolver`, `DdsMcpContextExecutor`, `DdsMcpSseController`
> · DB 게시: `sql/adb/44_dds_mcp_local_end_user_setup.sql`
> · 검증: `sql/adb/45_dds_mcp_local_end_user_test.sql`, SSE `tools/call`
## 1. 목적 (Why)
## 1. 결정과 목적
MCP SSE 서비스가 요청자의 권한을 대신 판단하거나 SQL에 조건을 덧붙이지 않고, 각 Tool의 DB 작업을 해당 요청자에 대응하는 Oracle Deep Data Security(DDS) END USER Context 실행한다.
MCP Tool의 보호 SQL은 요청 Bearer가 지정한 업무 사용자의 local DDS `END USER` Context에서만 실행한다. DDS가 `DATA ROLE``DATA GRANT`로 행·컬럼 접근을 집행하며, Tool은 권한 predicate를 직접 만들지 않는다.
VPD 트랙은 기존 구현과 데이터 모델을 변경하지 않는다. DDS 트랙은 별도의 보호 객체, 별도의 권한 게시 모델, 별도의 런타임 Context 경로를 갖는다.
VPD 경로는 변경하지 않는다. 기존 DDS 관리의 권한 게시 경계는 MCP용 END USER/역할/grant도 함께 갱신한다.
## 2. 범위 (Scope)
## 2. 토큰과 사용자 — 반드시 구분할 것
- **포함**
- MCP Bearer → `CB_APP_USER` → 로컬 DDS END USER의 명시적 매핑.
- MCP Tool 호출마다 DDS END USER Context를 부착·해제하는 실행 경계.
- 보호 데이터 객체, 접근 역할, 테이블 세부 권한, 사용자/그룹 역할 할당을 관리하는 DDS 전용 백오피스 모델.
- 역할·그룹의 유효 멤버십을 DDS `END USER` / `DATA ROLE` / `DATA GRANT` DDL로 게시·회수·검증하는 Publisher.
- OCI IAM 서비스 애플리케이션 database-access token과 local END USER lookup key의 기술 스파이크.
- **제외 (out of scope)**
- VPD 정책, VPD 백오피스, 기존 `CB_AGENT_CTX` 경로의 변경.
- MCP 사용자마다 OCI IAM 사용자를 생성·관리하는 기능.
- 사용자 요청 시점의 `CREATE END USER`, `GRANT DATA ROLE`, `CREATE DATA GRANT` 수행.
- 임의 SQL predicate 입력 및 기존 VPD `DENY 우선` 규칙의 자동 변환.
- OCI IAM 도메인/애플리케이션 등록의 실제 운영 배포. 스파이크 성공 후 별도 작업으로 분리한다.
| 값 | 현재 구현에서의 역할 | 사람 사용자인가 |
|---|---|---|
| MCP 요청 Bearer | `CB_AGENT_BEARER_KEY` 해시·만료·회수 검증 후 `CB_APP_USER`를 결정 | **예.** DB의 업무 사용자 매핑 기준 |
| local DDS END USER | `DDS_U_<CB_APP_USER.user_id>` | **예.** DDS가 집행하는 보안 주체 |
| OCI IAM database-access token | Confidential application의 client-credentials로 매 Tool Context attach를 승인 | 아니오. 서비스 애플리케이션 신원 |
| OCI IAM end-user token | 향후 authorization-code/OBO 전용 확장 | 현재 MCP 인증 입력으로 사용하지 않음 |
## 3. 인수조건 (Acceptance Criteria)
따라서 현재 MCP Bearer가 `CB_APP_USER`를 지정할 수 있으면 그 사용자별 DDS 권한은 적용된다. 반면 client-credentials 토큰의 `sub`/`client_id`는 서비스 애플리케이션이므로 사람 사용자를 판정하는 데 사용하면 안 된다.
- [ ] 활성 MCP Bearer는 정확히 하나의 활성 `CB_APP_USER`로 해석되며, DDS END USER 매핑이 없으면 Tool 실행 전에 권한 없음으로 종료된다.
- [ ] 동일한 공용 연결 풀에서 서로 다른 두 요청자가 연속·동시 Tool을 호출해도 각 요청은 자신의 DDS END USER Context와 DATA GRANT 결과만 받는다.
- [ ] 예외·취소·타임아웃을 포함한 모든 실행 경로에서 Context가 해제되어 다음 요청에 이전 권한이 남지 않는다.
- [ ] 백오피스 게시기는 사용자/그룹 역할 할당과 테이블 세부 권한을 idempotent DDS DDL로 반영하며, 게시 diff·사유·결과·드리프트를 보관한다.
- [ ] 그룹 멤버 추가/제거 후 게시하면 필요한 DDS END USER/DATA ROLE은 부여되고 불필요한 역할은 회수된다.
- [ ] MCP Tool SQL은 권한 `WHERE`를 직접 만들지 않으며, DDS DATA GRANT가 반환 행·컬럼을 제한한다.
- [ ] 미매핑·비활성·회수된 Bearer, 미게시 사용자, DATA GRANT 없는 사용자, Context attach 실패는 fail-closed로 처리한다.
- [ ] VPD 회귀 테스트가 통과하며 VPD 스키마·정책·UI에 변경이 없다.
OCI IAM JWT의 사람 사용자 claim을 그대로 DDS에 전달하려면 별도의 authorization-code 또는 OBO flow, 해당 end-user token 검증, IAM role↔DDS DATA ROLE 매핑으로 확장해야 한다. 이 경로는 현재 local END USER 모델과 혼용하지 않는다.
## 4. 컨텍스트 & 제약
- 기존 `CB_BEARER_TOKEN`/`CB_APP_USER`는 애플리케이션 논리 사용자 식별 수단이다. DDS 내장 인증 객체가 아니다.
- 로컬 DDS END USER는 `CREATE END USER`로 별도 생성한다. ERP/MCP 사용자는 IAM 사용자가 아니어도 되지만, DDS END USER는 사용자별로 존재한다.
- MCP SSE 서비스는 공용 DB 연결을 사용한다. DDS END USER Context는 SSE 연결 전체가 아니라 **매 Tool DB 실행 경계**마다 부착·해제한다.
- application-mediated local END USER Context에는 서비스 애플리케이션의 database-access token이 필요하다. 개인 IAM 사용자 토큰을 장기 설정값으로 저장하지 않는다.
- `joungmin.ko@oracle.com` IAM 사용자는 OCI CLI로 서비스 애플리케이션을 등록·관리하는 관리자일 뿐, MCP 실행 경로의 신원이나 token 발급 주체가 아니다.
- 런타임 신원은 `mcp-dds-service` IAM 애플리케이션이다. 이 앱의 client credentials로 short-lived database-access token을 발급받는다.
- IAM app client secret, database-access token, local END USER lookup key는 채팅·Redmine·Git에 기록하지 않는다. 배포 환경의 secret store 또는 환경 변수 참조만 허용한다.
- 서비스 애플리케이션의 OCI IAM 등록, token 발급, TLS/DB 설정, lookup key 발급은 P0 스파이크로 검증되기 전에는 구현 사실로 간주하지 않는다.
- DDS DATA GRANT는 additive다. 여러 DATA ROLE의 권한은 합집합이므로 VPD의 `DENY 우선` 의미를 자동 보존할 수 없다.
- END USER 이름은 ASCII 안정 식별자 `DDS_U_<CB_APP_USER.user_id>`를 사용한다. 표시 이름·한글 로그인명과 분리한다.
## 5. 아키텍처 개요
### 5.1 두 평면
## 3. 실제 아키텍처
```text
권한 게시 평면
백오피스
→ 보호 데이터 객체 / 접근 역할 / 세부 권한 / 사용자·그룹 할당
→ 유효 멤버십 계산
DDS Publisher
END USER · DATA ROLE · DATA GRANT DDL
→ 게시 이력 · 검증 · 드리프트 상태
권한 관리 / DDS 게시
CB_APP_USER + 직접/그룹 역할 + CB_PERMISSION 규칙
→ DDS Publisher
→ CB_DDS_END_USER_MAP
CREATE END USER DDS_U_<id>
CREATE/GRANT DATA ROLE DDS_U_<id>_ROLE
→ CREATE DATA GRANT (보호 벡터 객체)
요청 실행 평면
MCP Client Bearer
MCP SSE 인
CB_BEARER_TOKEN / CB_APP_USER 확인
DDS END USER 매핑 확인
서비스 IAM database-access token + lookup key로 Context 부착
MCP Tool DB 작업
DDS DATA GRANT 집행
Context 해제
MCP SSE Tool 호출
Authorization: Bearer <MCP bearer>
매 메시지마다 bearer 검
→ CB_APP_USER 확인
게시된 DDS_U_<id> map 확인
OCI IAM database-access token 취득/캐시
EndUserSecurityContext(name, lookup key) attach
보호 SQL 실행 (DATA GRANT 집행)
finally: context clear, 연결 반환
```
### 5.2 신원 분리
SSE 연결 자체는 인증을 보조할 뿐이다. `/dds/mcp/messages`**매 요청마다** Bearer와 사용자를 재검증하므로, 연결을 오래 유지해도 이전 사용자 권한을 신뢰하지 않는다.
| 항목 | 의미 | 관리 주체 |
## 4. 게시 모델과 운영 규칙
- 활성 `CB_APP_USER`마다 `CB_DDS_END_USER_MAP``DDS_U_<id>`, `DDS_U_<id>_ROLE`, grant 이름과 게시 상태를 기록한다.
- local END USER Context의 lookup key는 서비스 secret과 사용자 ID로 HMAC 파생한다. 원문/파생값은 DB·Git·Redmine·로그에 저장하지 않는다.
- 사용자의 직접 역할과 활성 그룹 역할에서 `CB_PERMISSION` / `CB_PERMISSION_RULE` / 허용 컬럼을 계산해 벡터 보호 객체의 `DATA GRANT`를 재생성한다.
- ALLOW가 없으면 grant를 제거하여 DDS 기본 거부 상태를 유지한다. 비활성 사용자는 data role과 grant를 회수한다.
- 사용자 활성화·직접 역할·그룹 멤버/역할·permission rule/허용 컬럼 변경은 같은 요청 안에서 활성 사용자 전체를 bulk 재발행한다. 수동 **DDS 관리의 게시 작업**은 전체 복구·재검증용으로도 제공한다.
- `withDataRoles(...)`는 local username+lookup-key Context에 사용하지 않는다. 역할은 게시된 local END USER grant에서만 활성화된다.
## 5. OCI IAM / ADB 전제조건
1. ADB external authentication을 `OCI_IAM`으로 등록하고 application ID와 domain URL을 설정한다.
2. `OCI_IAM_DOMAIN_DB_CRED$` credential에 Confidential application의 client ID/secret을 보관한다.
3. pool account에 `CREATE SESSION`, `CREATE END USER SECURITY CONTEXT`를 부여하고 TLS wallet 연결을 사용한다.
4. application identity를 `IAM_OAUTH_CLIENT_ID=<client id>`로 등록한다. 서비스 역할을 application identity에 부여할 경우 해당 역할만 활성화된다.
5. application은 database resource scope로 client-credentials token을 얻는다.
DB가 확인하는 token claim은 `resource_app_id`, `tenant_iss`, audience와 scope다. 이 값은 DB OCI IAM 설정 및 database resource registration과 일치해야 한다.
## 6. 구현 경계
| 컴포넌트 | 책임 | 실패 처리 |
|---|---|---|
| MCP Bearer | MCP 요청자가 어느 애플리케이션 사용자 인지 식별 | 기존 `CB_BEARER_TOKEN` |
| 애플리케이션 사용자 | 업무 사용자·그룹·역할 할당의 기준 | `CB_APP_USER` 및 DDS 전용 할당 모델 |
| DDS END USER | DB가 DATA ROLE/DATA GRANT를 집행하는 보안 주체 | DDS Publisher |
| Database-access token | MCP SSE 서비스가 Context를 부착할 수 있다는 애플리케이션 신뢰 | OCI IAM 서비스 애플리케이션 |
| `DdsMcpBearerAuthenticator` | MCP Bearer → 활성 `CB_APP_USER` | 인증 실패, SQL 미실행 |
| `DdsMcpEndUserResolver` | 사용자 → 게시된 DDS principal | 미매핑/미게시면 거부 |
| `DdsMcpDatabaseAccessTokenProvider` | OCI IAM service token 발급·만료 전 갱신 | Context 실행 차단 |
| `DdsMcpContextExecutor` | attach → 제한된 SQL → clear | clear 실패 시 연결 폐기 |
| `DdsMcpVectorSearchService` | Context 내부 보호 벡터 SQL만 실행 | DDS 오류를 안전한 MCP 오류로 변환 |
| `DdsMcpAuthorizationChangeListener` | 권한 변경 event → 전체 local END USER grant 재발행 | 요청을 실패로 알리고 게시 상태를 확인하게 함 |
| DDS Publisher | 기존 권한 모델 → local END USER/DATA ROLE/DATA GRANT | 부분 실패를 게시 실패로 기록 |
### 5.2.1 IAM 책임 경계
Tool/Repository는 raw Bearer, client secret, lookup key 또는 `setEndUserSecurityContext`를 직접 다루지 않는다.
```text
Provisioning (관리자 작업)
joungmin.ko@oracle.com IAM 사용자
→ OCI CLI
→ mcp-dds-service IAM 애플리케이션 등록
→ client ID / client secret을 승인된 secret store에 등록
## 7. 검증 결과 (2026-07-02)
Runtime (MCP SSE 서비스)
mcp-dds-service client credentials
→ short-lived database-access token
→ local DDS END USER Context 부착
```
- [x] SQLcl로 ADB 접속 및 local `END USER`, `DATA ROLE`, `DATA GRANT` catalog 생성 검증.
- [x] OCI IAM database-access token 발급 성공. `resource_app_id`, `tenant_iss`, audience `DDSDB`, scope `DB_ACCESS_SCOPE`가 DB 설정과 일치.
- [x] DB credential `OCI_IAM_DOMAIN_DB_CRED$`, OCI IAM application identity mapping 확인.
- [x] `/dds/mcp/sse` endpoint event 및 `tools/list` HTTP 200 확인.
- [x] `dds_vector_search` tool call: Bearer → application user 101 → local DDS Context attach → 보호 query 3행 반환 → context clear, HTTP 200.
- [ ] 사용자 A→B→A 및 동시 요청의 full isolation regression을 자동화한다.
- [ ] DDS 관리 UI publish가 MCP END USER map/grant를 같은 트랜잭션 경계에서 갱신하도록 통합한다.
MCP/ERP 업무 사용자는 IAM 사용자로 등록하지 않는다. 기존 Bearer가 식별한 `CB_APP_USER`를 local DDS END USER로 매핑한다.
SSE stream은 연결을 유지하므로 client read timeout이 날 수 있다. 이는 실패 판정 기준이 아니며, Tool RPC의 HTTP 결과와 attach/query/clear 로그로 성공을 판정한다.
### 5.3 실행 경계
## 8. 보안 불변식
`McpDdsContextExecutor`만 보호 객체용 연결을 획득한다. 이 경계는 다음 순서를 강제한다.
1. 유효한 MCP Bearer 하나는 정확히 하나의 활성 `CB_APP_USER`와 하나의 게시된 DDS END USER로만 해석된다.
2. Context attach 이전·clear 이후에는 보호 SQL을 실행하지 않는다.
3. attach/query/clear 어느 단계가 실패해도 fail-closed한다.
4. client-credentials token은 서비스 승인용이며 업무 사용자 권한 확대에 사용하지 않는다.
5. secret, raw bearer, database-access token, lookup key는 응답·로그·Git·Redmine에 기록하지 않는다.
1. Bearer를 재검증하고 활성 애플리케이션 사용자를 찾는다.
2. 게시 완료된 DDS END USER 매핑과 lookup key 참조를 찾는다.
3. 서비스 database-access token을 획득/갱신한다.
4. 연결에 DDS END USER Context를 부착한다.
5. Tool의 제한된 DB 작업을 실행한다.
6. `finally`에서 Context를 해제한 뒤 연결을 반환한다.
## 9. 참고
Tool·Repository는 Context 설정 API, DATA GRANT DDL, 원시 Bearer를 직접 다루지 않는다.
## 6. 데이터 모델
기존 VPD 공통 권한 테이블은 읽거나 변경하지 않는다. DDS 게시용 모델은 별도 관리한다.
| 개념 | 잠정 저장 구조 | 핵심 값 |
|---|---|---|
| 보호 데이터 객체 | `CB_DDS_PROTECTED_OBJECT` | object owner/name, Tool 노출명, 활성 상태 |
| 접근 역할 | `CB_DDS_ACCESS_ROLE` | 표시명, `DATA ROLE` 이름, 상태 |
| 테이블 세부 권한 | `CB_DDS_OBJECT_GRANT` | 역할, 객체, action, 허용/제외 컬럼, 행 조건 DSL |
| 역할 할당 | `CB_DDS_ROLE_ASSIGNMENT` | 역할, 대상 유형(USER/GROUP), 대상 ID |
| DDS END USER 매핑 | `CB_DDS_END_USER_MAP` | `CB_APP_USER.user_id`, DDS END USER 이름, 게시 상태, lookup key 참조 |
| 게시 실행 | `CB_DDS_PUBLISH_RUN` / 항목 | diff, 사유, 실행 결과, DDL fingerprint, 검증 결과 |
행 조건은 자유 SQL 문자열이 아니다. 객체별 허용 컬럼, 비교 연산자, 리터럴 타입, `ORA_END_USER_CONTEXT` 허용 경로만으로 구성된 DSL을 Publisher가 검증된 SQL predicate로 컴파일한다.
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|---|---|---|---|---|---|---|
| `authenticateMcpBearer` | Bearer를 활성 업무 사용자로 해석 | `Bearer → AppUser` | Bearer | `AppUser` | 미존재·만료·회수 → deny | **복잡** |
| `resolveDdsEndUser` | 업무 사용자의 게시된 DDS END USER를 찾음 | `AppUser → DdsPrincipal` | 사용자 ID | END USER/lookup key 참조 | 미매핑·미게시 → deny | 단순 |
| `getDatabaseAccessToken` | 서비스용 short-lived DB access token을 제공 | `() → Token` | 서비스 IAM 설정 | token | 발급 실패 → fail closed | **복잡** |
| `withDdsEndUserContext` | Context를 부착·DB 작업·해제를 원자적으로 수행 | `(DdsPrincipal, SqlWork) → T` | DDS principal, 작업 | 결과 | attach/query/clear 실패 | **복잡** |
| `compileDdsPublishPlan` | 백오피스 권한 모델을 DDL diff로 변환 | `Draft → PublishPlan` | 객체/역할/할당 | DDL plan | DSL/충돌/deny 규칙 오류 | **복잡** |
| `publishDdsPlan` | 승인된 plan을 멱등 게시하고 검증 | `PublishPlan → PublishRun` | plan, 사유 | 결과/증적 | 부분 실패·drift | **복잡** |
| `verifyDdsIsolation` | 대표 사용자별 Context·객체·행·컬럼 결과를 확인 | `PublishRun → Verification` | 사용자 matrix | 검증 결과 | context 누수·권한 불일치 | **복잡** |
복잡 함수의 세부 명세는 다음 문서로 분리한다.
- [Bearer 인증](fn-authenticate-mcp-bearer.md)
- [서비스 database-access token](fn-get-database-access-token.md)
- [DDS Context 실행 경계](fn-with-dds-end-user-context.md)
- [DDS 게시 계획 컴파일](fn-compile-dds-publish-plan.md)
- [DDS 게시·검증](fn-publish-and-verify-dds-plan.md)
## 8. 흐름 / 알고리즘
### 8.1 권한 게시
1. 운영자가 보호 객체, 접근 역할, 세부 권한, 사용자/그룹 할당을 수정한다.
2. Publisher는 그룹을 포함한 역할별 유효 사용자를 계산한다.
3. 누락된 사용자에 대해 비밀번호 없는 DDS END USER 생성 계획을 만든다.
4. 역할별 DATA ROLE과 객체별 DATA GRANT의 생성·교체·회수 diff를 만든다.
5. 승인된 plan만 실행한다. 요청 실행 경로에서는 DDL을 실행하지 않는다.
6. 게시 후 END USER/ROLE/GRANT dictionary와 대표 사용자 matrix를 확인한다.
### 8.2 MCP Tool 실행
1. MCP 요청의 Bearer를 검증한다.
2. 사용자·DDS END USER 매핑·게시 상태를 확인한다.
3. Context 부착 전에 미매핑/비활성/회수 상태면 권한 없음으로 중단한다.
4. Tool의 DB 작업을 DDS Context 안에서 실행한다.
5. 성공·실패와 상관없이 Context를 제거한다.
6. MCP 응답에는 Tool이 반환한 DDS 제한 결과만 포함한다. END USER 이름, lookup key, database-access token은 노출하지 않는다.
## 9. 엣지케이스 & 에러 처리
| 상황 | 처리 |
|---|---|
| Bearer가 없거나 회수됨 | 인증 실패. DDS END USER 조회/부착을 시도하지 않는다. |
| 사용자 매핑 없음 | 권한 없음. 요청 중 END USER를 생성하지 않는다. |
| 사용자 비활성/게시 실패/drift | 권한 없음. 마지막 성공 게시 결과를 사용하지 않는다. |
| Context attach 실패 | Tool SQL을 실행하지 않는다. correlation ID만 기록한다. |
| Tool SQL 예외/취소/시간초과 | `finally`에서 Context 해제 후 안전한 오류 응답. |
| 연결 풀 재사용 | 매 DB 작업에 새 Context를 부착하고 `finally`에서 해제한다. |
| 그룹에서 사용자 제거 | 다음 게시에서 DATA ROLE 회수, 이후 요청은 새 게시 결과만 사용한다. |
| 비ASCII 업무 사용자명 | `DDS_U_<id>`를 사용하며 표시명과 END USER 식별자를 분리한다. |
| VPD DENY 규칙 | DDS 1차 모델에서 미지원. 게시 전 오류로 차단한다. |
## 10. 테스트 계획
| 인수조건 | 검증 |
|---|---|
| Context 사용자 격리 | 같은 공용 풀에서 A → B → A 순서와 동시 실행. `ORA_END_USER_CONTEXT.username` 및 결과 행/컬럼이 각 사용자와 일치. |
| Context 해제 | Tool 예외·취소·타임아웃 뒤 다음 요청이 이전 사용자의 Context를 갖지 않음. |
| 토큰 fail-closed | 미존재·만료·회수 Bearer와 미매핑 사용자 모두 SQL 실행 전 거부. |
| 역할/그룹 게시 | 직접 사용자·그룹 추가/제거 후 예상 DATA ROLE grant/revoke와 결과 확인. |
| 세부 권한 | 역할별 객체 차단, 행 조건, 컬럼 제외, 다중 역할 합집합을 검증. |
| DDL 멱등성 | 동일 plan 재게시가 변경 없음으로 완료. |
| 드리프트 | dictionary의 END USER/DATA ROLE/DATA GRANT를 의도적으로 변경한 뒤 탐지. |
| VPD 회귀 | 기존 VPD Maven/SQL/HTTP 회귀 테스트 전체 통과. |
| P0 스파이크 | OCI IAM 서비스 token + local END USER name/lookup key로 Context attach 성공 및 두 사용자 격리 증명. |
## 11. 리스크 & 대안 검토
### 선택: DDS 선언 게시 + MCP Context 부착
권한을 백오피스에서 검토·승인·게시하고, MCP Tool은 게시된 DDS 권한을 집행한다. Agent가 SQL을 만들더라도 DB가 결과를 제한한다.
### 대안: 기존 VPD 공통 권한 테이블을 요청마다 동적 해석
기존 VPD 모델과 즉시 반영 특성은 유지하지만, DDS DATA ROLE/DATA GRANT를 권한의 단일 출처로 쓰려는 목표와 맞지 않는다. VPD 트랙에 남긴다.
### 대안: 사용자별 IAM 계정
IAM token claim에서 DATA ROLE을 직접 활성화할 수 있으나 ERP/MCP 사용자 관리 주체를 IAM으로 옮겨야 한다. 이번 범위에서는 제외한다.
### 주요 리스크
- local END USER application-mediated Context에는 service IAM database-access token이 필요하다.
- lookup key 생성/보관 방식이 미확정이다.
- DDS 권한은 additive이므로 VPD DENY 우선 모델을 그대로 이식할 수 없다.
- Publisher의 partial failure는 사용자별 권한 불일치를 만들 수 있으므로 실행 plan, compensation, drift 탐지가 필요하다.
OCI IAM 서비스 애플리케이션의 도메인·token flow·비밀 보관 방식은 되돌리기 어려우므로 ADR로 분리한다.
## 12. 미해결 질문 (Open Questions)
1. local END USER security-context lookup key의 정확한 생성 API/명령, 회전·보관 기준은 무엇인가?
2. 현재 OCI tenancy의 Free Identity Domain에서 DDS용 application registration 및 client-credentials database-access token을 발급할 수 있는가?
3. ADB의 TLS, identity provider, application identity, pool account에 필요한 정확한 설정·권한은 무엇인가?
4. Java 26ai JDBC artifact/API와 현재 `dds-backoffice`의 OJDBC 버전·Spring pool이 Context attach를 지원하는가?
5. VPD의 DENY 규칙을 DDS 1차 모델에서 명시적으로 제외할지, 제한된 조합 규칙으로 컴파일할지?
6. Data Grant의 다중 역할 컬럼/행 합집합 의미를 실제 ADB에서 어떤 matrix로 검증할지?
이 여섯 항목은 P0 스파이크가 승인되기 전까지 임의 구현으로 채우지 않는다.
- [Oracle: Configure the Database for IAM Integration](https://docs.oracle.com/en/database/oracle/oracle-database/26/ddscg/configure-database-iam-integration.html)
- [Oracle: Prerequisites for Establishing a Local Security Context](https://docs.oracle.com/en/database/oracle/oracle-database/26/ddscg/prerequisites-establishing-local-security-context.html)
- [Oracle: End-User Security Context Issues](https://docs.oracle.com/en/database/oracle/oracle-database/26/ddscg/end-user-security-context-issues.html)

View File

@@ -1,28 +1,23 @@
# 함수 명세: `authenticateMcpBearer` (#617)
> **상태**: Draft · **분류**: 복잡 — 인증 저장소 I/O 및 fail-closed 경계
> **상태**: Implemented · 구현: `DdsMcpBearerAuthenticator`
## 책임
MCP Tool 호출의 Bearer를 기존 애플리케이션 사용자로 해석한다. DDS 역할·DDL을 결정하지 않는다.
## 시그니처
`BearerToken -> AuthenticatedAppUser`
MCP Tool 요청의 opaque Bearer를 활성 `CB_APP_USER`로 해석하고 해당 사용자의 게시된 local DDS principal을 반환한다. OCI IAM client-credentials token을 업무 사용자 token으로 해석하지 않는다.
## 입력과 출력
- 입력: `Authorization: Bearer <opaque-token>`에서 추출한 원문 token.
- 출력: 활성 `CB_APP_USER``userId`, 표시명, token 식별자, 검증 시각.
- 입력: `Authorization: Bearer <opaque-token>`
- 출력: `applicationUserId`, 표시 사용자명, `DDS_U_<id>`, data role 이름, lookup-key 참조
## 규칙
1. token 형식·길이를 먼저 검증한다.
2. 해시 비교, 만료, 회수, 활성 사용자 여부를 한 트랜잭션에서 검증한다.
3. token이 없거나 하나의 활성 사용자로 해석되지 않으면 동일한 권한 없음 결과를 반환한다.
4. 원문 token, 해시, 사용자 상세를 로그·MCP 응답에 지 않는다.
5. 각 Tool 호출마다 재검증한다. SSE 연결 생성 시점의 결과를 재사용하지 않는다.
1. `CB_AGENT_BEARER_KEY`의 해시 일치, 만료·회수 여부와 `CB_APP_USER.active`를 매 Tool 요청마다 확인한다.
2. `CB_DDS_END_USER_MAP`에서 `PUBLISHED` 상태의 사용자 매핑을 반드시 찾는다.
3. 하나라도 없으면 `AUTHORIZATION_DENIED`로 끝내며 context attach 또는 보호 SQL을 호출하지 않는다.
4. raw bearer와 hash는 로그·MCP 응답에 노출하지 않는다.
## 실패
## 주의
`AUTHORIZATION_DENIED`로 fail-closed한다. DDS Context 부착과 DB Tool SQL을 호출하지 않는다.
현재 opaque Bearer의 사용자 매핑이 DDS 집행 주체를 결정한다. OCI IAM JWT의 사람 claim을 직접 사용하는 기능은 별도 OBO/authorization-code 확장이다.

View File

@@ -1,28 +1,18 @@
# 함수 명세: `getDatabaseAccessToken` (#617)
> **상태**: Draft · **분류**: 복잡 — OCI IAM 외부 I/O·비밀·만료 관리
> **상태**: Implemented · 구현: `DdsMcpDatabaseAccessTokenProvider`
## 책임
MCP SSE 서비스 애플리케이션의 short-lived database-access token을 제공한다. MCP 사용자 Bearer와 혼동하거나 대체하지 않는다.
## 시그니처
`() -> DatabaseAccessToken`
OCI IAM Confidential Application의 client-credentials token을 database-access token으로 발급·캐시한다. 이 token은 local DDS Context attach의 서비스 승인용이며 MCP 업무 사용자를 식별하지 않는다.
## 규칙
1. 개인 IAM 사용자(`joungmin.ko@oracle.com`)의 장기 token을 사용하지 않는다.
2. `mcp-dds-service` 전용 OCI IAM 애플리케이션의 client-credentials flow를 사용한다.
3. access token은 만료 전 안전 여유를 두고 갱신하며, 원문을 로그·DB·Redmine에 저장하지 않는다.
4. 토큰 발급 실패 시 이전 만료 token을 재사용하지 않고 Tool DB 작업을 차단한다.
5. secret은 배포 환경의 secret store만 사용한다.
1. database resource scope로 `/oauth2/v1/token`을 호출한다.
2. 만료 전 refresh skew를 두고 갱신한다.
3. token 발급 실패 또는 응답 불완전 시 기존 만료 token을 사용하지 않고 `DDS_CONTEXT_UNAVAILABLE`로 fail-closed한다.
4. token, client secret, Authorization header는 로그·DB·Git·Redmine에 기록하지 않는다.
## 스파이크 확인
## 운영 검증
- OCI Identity Domain의 무료/현재 entitlement에서 database-access token 발급이 가능한지 확인한다.
- ADB의 identity provider, TLS, pool account, application identity 조건을 실제 연결로 확인한다.
## 실패
`DDS_CONTEXT_UNAVAILABLE`로 fail-closed한다.
발급 token의 `resource_app_id`/`tenant_iss`/audience/scope가 ADB OCI IAM 설정 및 database resource와 일치해야 한다. ADB의 `OCI_IAM_DOMAIN_DB_CRED$`도 필수다.

View File

@@ -1,36 +1,25 @@
# 함수 명세: `withDdsEndUserContext` (#617)
> **상태**: Draft · **분류**: 복잡 — 연결 풀 보안 경계
> **상태**: Implemented · 구현: `DdsMcpContextExecutor`
## 책임
한 DB 작업을 정확히 하나의 local DDS END USER Context 안에서 실행하고, 어떤 종료 경로에서도 Context를 해제한다.
## 시그니처
`(DdsPrincipal, SqlWork<T>) -> T`
## 입력
- `DdsPrincipal`: 게시된 DDS END USER 이름, lookup key 참조, 사용자 ID, 게시 version.
- `SqlWork<T>`: 보호 객체를 읽거나 쓰는 제한된 Tool DB 작업.
Tool DB 작업을 정확히 하나의 local DDS END USER Context 안에서 실행하고 항상 해제한다.
## 알고리즘
1. `DdsPrincipal`이 활성·게시 완료 상태인지 확인한다.
2. 공용 연결을 획득하고 서비스 database-access token을 얻다.
3. local END USER 이름과 lookup key로 DDS Context를 부착한다.
4. `SqlWork`를 실행한다. Repository는 Context 설정/해제를 직접 호출할 수 없다.
5. 성공·실패·취소·시간초과와 무관하게 `finally`에서 Context를 해제한다.
6. 해제 실패는 연결을 풀에 반환하지 않고 폐기한다.
1. 게시된 DDS principal을 확인한다.
2. OCI IAM database-access token을 얻고 공용 풀에서 연결을 획득한다.
3. `EndUserSecurityContext.createWithName(databaseAccessToken, endUserName, lookupKey)`를 만든다.
4. `setEndUserSecurityContext` 후 보호 SQL을 실행한다.
5. `finally`에서 `clearEndUserSecurityContext` 후 연결을 반환한다. clear 실패 연결은 폐기한다.
## 보안 불변식
## 불변식
- 하나의 Tool DB 작업에는 하나의 DDS END USER만 존재한다.
- 이전 Tool의 Context가 다음 Tool에 남아서는 안 된다.
- Context 부착 이전과 해제 이후에는 보호 SQL을 실행하지 않는다.
- local END USER의 DATA ROLE은 payload로 임의 추가하지 않고, 게시된 DDS DDL에서만 얻는다.
- local username+lookup-key Context에는 `withDataRoles(...)`를 전달하지 않는다.
- local END USER에 미리 `GRANT DATA ROLE`된 역할과 `DATA GRANT`만 적용한다.
- 연결 풀 재사용 시에도 이전 사용자 Context가 남지 않아야 한다.
## 스파이크 확인
## 실증
동일 풀 연결에서 사용자 A → B → A와 동시 요청을 실행해 `ORA_END_USER_CONTEXT.username`, 행, 컬럼이 각각 맞는지 확인한다.
실제 ADB에서 local END USER attach, 보호 벡터 query 3행, context clear까지 완료했다. 동시 사용자 isolation regression은 후속 자동화 대상이다.

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Publishes local DDS END USERs from CB_APP_USER and verifies the catalog.
# A live MCP call is optional and uses a bearer supplied only through .env.
set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
[[ -f .env ]] || { echo '[FAIL] .env is required.' >&2; exit 1; }
set -a
# shellcheck disable=SC1091
. ./.env
set +a
if [[ -x "$ROOT/.tools/sqlcl-26.1.2/sqlcl/bin/sql" ]]; then
SQL_CLIENT="$ROOT/.tools/sqlcl-26.1.2/sqlcl/bin/sql"
elif command -v sqlplus >/dev/null; then
SQL_CLIENT="$(command -v sqlplus)"
else
echo '[FAIL] SQLcl or sqlplus is required to run the ADB scenario.' >&2
exit 1
fi
[[ -n "${ADB_USER:-}" && -n "${ADB_PASSWORD:-}" && -n "${ADB_TNS:-}" ]] \
|| { echo '[FAIL] ADB_USER, ADB_PASSWORD, and ADB_TNS are required.' >&2; exit 1; }
run_admin() {
"$SQL_CLIENT" -S -L "${ADB_USER}/${ADB_PASSWORD}@${ADB_TNS}" @"$1"
}
run_admin "$ROOT/sql/adb/44_dds_mcp_local_end_user_setup.sql"
run_admin "$ROOT/sql/adb/45_dds_mcp_local_end_user_test.sql"
if [[ -z "${DDS_MCP_TEST_BEARER:-}" ]]; then
echo '[OK] DDS END USER catalog scenario passed. Set DDS_MCP_TEST_BEARER to run the live MCP tool call.'
exit 0
fi
BASE_URL="${DDS_MCP_BASE_URL:-http://127.0.0.1:${DDS_BACKOFFICE_PORT:-8083}}"
RESPONSE_FILE="$(mktemp /tmp/dds-mcp-end-user-response.XXXXXX)"
trap 'rm -f "$RESPONSE_FILE"' EXIT
curl -fsS --connect-timeout 5 --max-time 30 \
-H "Authorization: Bearer ${DDS_MCP_TEST_BEARER}" \
-H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"dds_vector_search","arguments":{"query":"DDS 검증 자료","limit":10,"embeddingMode":"DEMO"}}}' \
-o "$RESPONSE_FILE" \
"$BASE_URL/dds/mcp/messages"
rg -q '"isError"[[:space:]]*:[[:space:]]*false' "$RESPONSE_FILE" \
|| { echo '[FAIL] MCP tool call did not return a successful DDS result.' >&2; exit 1; }
echo '[OK] DDS END USER catalog and SSE MCP scenario passed.'

View File

@@ -0,0 +1,244 @@
-- ============================================================
-- 44_dds_mcp_local_end_user_setup.sql
--
-- Publishes every active CB_APP_USER as a passwordless local DDS END USER
-- for the DDS SSE MCP path. The application executes one protected query
-- per request with an EndUserSecurityContext; direct DB logon is not enabled.
--
-- Permission source: CB_APP_USER + direct/group role assignments +
-- CB_PERMISSION / CB_PERMISSION_RULE / CB_PERMISSION_COLUMN.
-- Current protected target: ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS.
--
-- Prerequisite: 25_agent_ords_security_backoffice_support.sql,
-- 28_agent_ords_vector_tag_vpd_setup.sql,
-- 32_dds_vector_tag_setup.sql.
-- Run as ADMIN on Oracle AI Database 26ai.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
SET DEFINE OFF
PROMPT === 1. Persisting the published local END USER mapping ===
BEGIN
EXECUTE IMMEDIATE q'[
CREATE TABLE cb_dds_end_user_map (
application_user_id NUMBER PRIMARY KEY REFERENCES cb_app_user(user_id),
end_user_name VARCHAR2(128) NOT NULL UNIQUE,
data_role_name VARCHAR2(128) NOT NULL UNIQUE,
lookup_key_ref VARCHAR2(128) NOT NULL,
grant_name VARCHAR2(128) NOT NULL,
publish_status VARCHAR2(20) NOT NULL
CHECK (publish_status IN ('PENDING', 'PUBLISHED', 'REVOKED', 'FAILED')),
published_at TIMESTAMP,
last_error VARCHAR2(1000)
)]';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE <> -955 THEN RAISE; END IF;
END;
/
MERGE INTO cb_dds_end_user_map target
USING (
SELECT user_id AS application_user_id,
'DDS_U_' || TO_CHAR(user_id) AS end_user_name,
'DDS_U_' || TO_CHAR(user_id) || '_ROLE' AS data_role_name,
'DDS_OCI_IAM_CLIENT_SECRET_DERIVED_V1' AS lookup_key_ref,
'DDS_MCP_U_' || TO_CHAR(user_id) || '_VECTOR_GRANT' AS grant_name
FROM cb_app_user
) source
ON (target.application_user_id = source.application_user_id)
WHEN MATCHED THEN UPDATE SET
target.end_user_name = source.end_user_name,
target.data_role_name = source.data_role_name,
target.lookup_key_ref = source.lookup_key_ref,
target.grant_name = source.grant_name
WHEN NOT MATCHED THEN INSERT (
application_user_id, end_user_name, data_role_name, lookup_key_ref,
grant_name, publish_status
) VALUES (
source.application_user_id, source.end_user_name, source.data_role_name,
source.lookup_key_ref, source.grant_name, 'PENDING'
);
PROMPT === 2. Publishing END USER, DATA ROLE, and configured vector grants ===
DECLARE
v_predicate VARCHAR2(32767);
v_excluded_columns VARCHAR2(32767);
v_select_clause VARCHAR2(32767);
PROCEDURE drop_data_grant(p_grant_name IN VARCHAR2) IS
BEGIN
EXECUTE IMMEDIATE 'DROP DATA GRANT ADMIN.' || p_grant_name;
EXCEPTION
WHEN OTHERS THEN NULL; -- First publish has no generated grant.
END;
PROCEDURE revoke_data_role(p_role_name IN VARCHAR2, p_end_user_name IN VARCHAR2) IS
BEGIN
EXECUTE IMMEDIATE 'REVOKE DATA ROLE ' || p_role_name || ' FROM "' || p_end_user_name || '"';
EXCEPTION
WHEN OTHERS THEN NULL; -- Revoke is intentionally idempotent in this publisher.
END;
FUNCTION vector_predicate(p_user_id IN NUMBER) RETURN VARCHAR2 IS
v_allow VARCHAR2(32767);
v_deny VARCHAR2(32767);
v_clause VARCHAR2(2000);
v_effect VARCHAR2(10);
v_type VARCHAR2(30);
v_value VARCHAR2(100);
PROCEDURE append_clause(p_target IN OUT NOCOPY VARCHAR2, p_clause IN VARCHAR2) IS
BEGIN
IF p_clause IS NULL THEN RETURN; END IF;
IF p_target IS NULL THEN p_target := '(' || p_clause || ')';
ELSE p_target := p_target || ' OR (' || p_clause || ')'; END IF;
END;
BEGIN
FOR rule IN (
WITH effective_role AS (
SELECT role_id FROM cb_user_role WHERE user_id = p_user_id
UNION
SELECT gr.role_id
FROM cb_user_group ug
JOIN cb_app_group g ON g.group_id = ug.group_id AND g.active_yn = 'Y'
JOIN cb_group_role gr ON gr.group_id = ug.group_id
WHERE ug.user_id = p_user_id
)
SELECT NVL(UPPER(TRIM(p.permission_effect)), 'ALLOW') AS permission_effect,
UPPER(TRIM(r.rule_type)) AS rule_type,
TRIM(r.rule_value) AS rule_value
FROM effective_role er
JOIN cb_permission p ON p.role_id = er.role_id
JOIN cb_permission_rule r ON r.perm_id = p.perm_id
WHERE p.target_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
AND p.action_name = 'SELECT'
ORDER BY p.perm_id, r.rule_id
) LOOP
v_effect := rule.permission_effect;
v_type := rule.rule_type;
v_value := rule.rule_value;
v_clause := NULL;
IF v_type = 'ALL' THEN
v_clause := '1 = 1';
ELSIF v_type = 'TAG'
AND v_value IS NOT NULL
AND REGEXP_LIKE(v_value, '^[A-Za-z0-9_-]+$') THEN
v_clause := 'REGEXP_LIKE(UPPER(tech_tag), ''(^|,)' || UPPER(v_value) || '(,|$)'')';
END IF;
IF v_effect = 'DENY' THEN append_clause(v_deny, v_clause);
ELSE append_clause(v_allow, v_clause); END IF;
END LOOP;
IF v_allow IS NULL THEN RETURN NULL; END IF;
IF v_deny IS NULL THEN RETURN v_allow; END IF;
RETURN '(' || v_allow || ') AND NOT (' || v_deny || ')';
END;
FUNCTION excluded_vector_columns(p_user_id IN NUMBER) RETURN VARCHAR2 IS
v_columns VARCHAR2(32767);
BEGIN
SELECT LISTAGG(column_name, ', ') WITHIN GROUP (ORDER BY column_name)
INTO v_columns
FROM (
SELECT pc.column_name
FROM cb_protected_column pc
JOIN cb_protected_object po ON po.object_id = pc.object_id
WHERE po.object_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
AND pc.sensitive_yn = 'Y'
-- VECTOR_DISTANCE needs EMBEDDING internally. The MCP response never
-- returns it, but it must stay readable by the database engine.
AND pc.column_name <> 'EMBEDDING'
AND NOT EXISTS (
WITH effective_role AS (
SELECT role_id FROM cb_user_role WHERE user_id = p_user_id
UNION
SELECT gr.role_id
FROM cb_user_group ug
JOIN cb_app_group g ON g.group_id = ug.group_id AND g.active_yn = 'Y'
JOIN cb_group_role gr ON gr.group_id = ug.group_id
WHERE ug.user_id = p_user_id
)
SELECT 1
FROM effective_role er
JOIN cb_permission p ON p.role_id = er.role_id
JOIN cb_permission_column allowed ON allowed.permission_id = p.perm_id
WHERE p.target_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
AND p.action_name = 'SELECT'
AND p.permission_effect = 'ALLOW'
AND allowed.column_name = pc.column_name
)
);
RETURN v_columns;
EXCEPTION
WHEN NO_DATA_FOUND THEN RETURN NULL;
END;
BEGIN
FOR mapped_user IN (
SELECT m.application_user_id, m.end_user_name, m.data_role_name, m.grant_name
FROM cb_dds_end_user_map m
JOIN cb_app_user u ON u.user_id = m.application_user_id
WHERE u.active = 'Y'
ORDER BY m.application_user_id
) LOOP
EXECUTE IMMEDIATE 'CREATE END USER IF NOT EXISTS "' || mapped_user.end_user_name || '"';
EXECUTE IMMEDIATE 'CREATE DATA ROLE IF NOT EXISTS ' || mapped_user.data_role_name;
EXECUTE IMMEDIATE 'GRANT DATA ROLE ' || mapped_user.data_role_name
|| ' TO "' || mapped_user.end_user_name || '"';
v_predicate := vector_predicate(mapped_user.application_user_id);
drop_data_grant(mapped_user.grant_name);
IF v_predicate IS NOT NULL THEN
v_excluded_columns := excluded_vector_columns(mapped_user.application_user_id);
v_select_clause := CASE WHEN v_excluded_columns IS NULL THEN 'AS SELECT'
ELSE 'AS SELECT (ALL COLUMNS EXCEPT ' || v_excluded_columns || ')' END;
EXECUTE IMMEDIATE 'CREATE DATA GRANT ADMIN.' || mapped_user.grant_name || ' '
|| v_select_clause || ' ON ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS WHERE '
|| v_predicate || ' TO ' || mapped_user.data_role_name;
END IF;
UPDATE cb_dds_end_user_map
SET publish_status = 'PUBLISHED', published_at = SYSTIMESTAMP, last_error = NULL
WHERE application_user_id = mapped_user.application_user_id;
END LOOP;
-- A disabled application user must not retain a usable DDS role/grant.
FOR inactive_user IN (
SELECT m.application_user_id, m.end_user_name, m.data_role_name, m.grant_name
FROM cb_dds_end_user_map m
JOIN cb_app_user u ON u.user_id = m.application_user_id
WHERE u.active <> 'Y'
) LOOP
drop_data_grant(inactive_user.grant_name);
revoke_data_role(inactive_user.data_role_name, inactive_user.end_user_name);
UPDATE cb_dds_end_user_map
SET publish_status = 'REVOKED', published_at = SYSTIMESTAMP, last_error = NULL
WHERE application_user_id = inactive_user.application_user_id;
END LOOP;
COMMIT;
END;
/
PROMPT === 3. Published DDS MCP identity inventory ===
SELECT m.application_user_id,
u.user_name,
m.end_user_name,
m.data_role_name,
m.grant_name,
m.publish_status
FROM cb_dds_end_user_map m
JOIN cb_app_user u ON u.user_id = m.application_user_id
ORDER BY m.application_user_id;
SELECT rg.grantee AS end_user_name,
rg.data_role,
dg.grant_name,
dg.object_name
FROM dba_data_role_grants rg
LEFT JOIN dba_data_grants dg ON dg.grantee = rg.data_role
WHERE rg.grantee LIKE 'DDS\_U\_%' ESCAPE '\'
ORDER BY rg.grantee, dg.grant_name;
PROMPT === DDS MCP local END USER publish complete ===
EXIT;

View File

@@ -0,0 +1,59 @@
-- ============================================================
-- 45_dds_mcp_local_end_user_test.sql
-- Catalog-level acceptance test for 44. Run as ADMIN.
-- The SSE/JDBC context assertion is performed by dds_vector_search after
-- the OCI IAM database-access token configuration is supplied.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
SET LINESIZE 220
SET PAGESIZE 200
DECLARE
v_missing_map NUMBER;
v_missing_end_user NUMBER;
v_missing_role NUMBER;
BEGIN
SELECT COUNT(*) INTO v_missing_map
FROM cb_app_user u
LEFT JOIN cb_dds_end_user_map m ON m.application_user_id = u.user_id
WHERE u.active = 'Y'
AND (m.application_user_id IS NULL OR m.publish_status <> 'PUBLISHED');
SELECT COUNT(*) INTO v_missing_end_user
FROM cb_dds_end_user_map m
JOIN cb_app_user u ON u.user_id = m.application_user_id AND u.active = 'Y'
LEFT JOIN dba_end_users eu ON eu.username = m.end_user_name
WHERE eu.username IS NULL;
SELECT COUNT(*) INTO v_missing_role
FROM cb_dds_end_user_map m
JOIN cb_app_user u ON u.user_id = m.application_user_id AND u.active = 'Y'
LEFT JOIN dba_data_role_grants rg
ON rg.grantee = m.end_user_name
AND rg.data_role = m.data_role_name
WHERE rg.data_role IS NULL;
IF v_missing_map <> 0 OR v_missing_end_user <> 0 OR v_missing_role <> 0 THEN
RAISE_APPLICATION_ERROR(-20944,
'DDS MCP publish validation failed: map=' || v_missing_map
|| ', end_user=' || v_missing_end_user || ', data_role=' || v_missing_role);
END IF;
END;
/
COLUMN user_name FORMAT A22
COLUMN end_user_name FORMAT A18
COLUMN data_role_name FORMAT A24
COLUMN grant_name FORMAT A34
SELECT u.user_name, m.end_user_name, m.data_role_name, m.grant_name,
CASE WHEN dg.grant_name IS NULL THEN 'DEFAULT_DENY' ELSE 'PUBLISHED' END AS vector_access
FROM cb_dds_end_user_map m
JOIN cb_app_user u ON u.user_id = m.application_user_id
LEFT JOIN dba_data_grants dg ON dg.grant_name = m.grant_name
WHERE u.active = 'Y'
ORDER BY m.application_user_id;
PROMPT Catalog validation passed. Next: call /dds/mcp/sse and dds_vector_search with a valid MCP Bearer.
EXIT;

View File

@@ -23,12 +23,14 @@ public class SecurityConfig {
}
return http
.csrf(csrf -> csrf.ignoringRequestMatchers("/mcp/messages", "/mcp/*/messages"))
.csrf(csrf -> csrf.ignoringRequestMatchers(
"/mcp/messages", "/mcp/*/messages", "/dds/mcp/messages"))
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31_536_000)))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/css/**", "/js/**", "/webjars/**").permitAll()
.requestMatchers("/css/**", "/js/**", "/webjars/**", "/dds/mcp/sse", "/dds/mcp/messages")
.permitAll()
.anyRequest().authenticated())
.httpBasic(basic -> {
})

View File

@@ -0,0 +1,37 @@
package com.cloudhandson.vpdbackoffice.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
/**
* Keeps the shared user/role/permission services independent from the DDS app.
* The normal VPD application has no synchronizer. The DDS application provides
* one and receives the change before the management request returns.
*/
@Service
public class DdsAuthorizationChangeNotifier {
private static final Logger log = LoggerFactory.getLogger(DdsAuthorizationChangeNotifier.class);
private final ObjectProvider<DdsAuthorizationSynchronizer> synchronizer;
public DdsAuthorizationChangeNotifier(ObjectProvider<DdsAuthorizationSynchronizer> synchronizer) {
this.synchronizer = synchronizer;
}
private DdsAuthorizationChangeNotifier() {
this.synchronizer = null;
}
public static DdsAuthorizationChangeNotifier noop() {
return new DdsAuthorizationChangeNotifier();
}
public void changed(String reason) {
if (synchronizer != null) {
log.info("DDS authorization change published: {}", reason);
synchronizer.ifAvailable(target -> target.synchronize(reason));
}
}
}

View File

@@ -0,0 +1,7 @@
package com.cloudhandson.vpdbackoffice.service;
/** Optional bridge implemented only by the dedicated DDS application. */
public interface DdsAuthorizationSynchronizer {
void synchronize(String reason);
}

View File

@@ -7,6 +7,7 @@ import com.cloudhandson.vpdbackoffice.domain.group.GroupRoleView;
import com.cloudhandson.vpdbackoffice.domain.group.GroupUserView;
import com.cloudhandson.vpdbackoffice.mapper.GroupMapper;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -15,10 +16,21 @@ public class GroupService {
private final GroupMapper groupMapper;
private final AuditService auditService;
private final DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier;
public GroupService(GroupMapper groupMapper, AuditService auditService) {
@Autowired
public GroupService(
GroupMapper groupMapper,
AuditService auditService,
DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier
) {
this.groupMapper = groupMapper;
this.auditService = auditService;
this.ddsAuthorizationChangeNotifier = ddsAuthorizationChangeNotifier;
}
public GroupService(GroupMapper groupMapper, AuditService auditService) {
this(groupMapper, auditService, DdsAuthorizationChangeNotifier.noop());
}
public List<AppGroup> findAll() {
@@ -38,6 +50,7 @@ public class GroupService {
long groupId = groupMapper.nextGroupId();
groupMapper.insertGroup(groupId, command);
auditService.record(new AuditEvent("GROUP_CREATED", null, null, "SUCCESS", null, null, command.groupCode()));
ddsAuthorizationChangeNotifier.changed("GROUP_CREATED");
}
@Transactional
@@ -56,6 +69,7 @@ public class GroupService {
}
auditService.record(new AuditEvent("GROUP_ACTIVE_CHANGED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",active=" + active));
ddsAuthorizationChangeNotifier.changed("GROUP_ACTIVE_CHANGED");
}
@Transactional
@@ -63,6 +77,7 @@ public class GroupService {
groupMapper.insertGroupUser(groupId, userId);
auditService.record(new AuditEvent("GROUP_USER_ADDED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",userId=" + userId));
ddsAuthorizationChangeNotifier.changed("GROUP_USER_ADDED");
}
@Transactional
@@ -81,6 +96,7 @@ public class GroupService {
}
auditService.record(new AuditEvent("GROUP_USER_REMOVED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",userId=" + userId));
ddsAuthorizationChangeNotifier.changed("GROUP_USER_REMOVED");
}
@Transactional
@@ -88,6 +104,7 @@ public class GroupService {
groupMapper.insertGroupRole(groupId, roleId);
auditService.record(new AuditEvent("GROUP_ROLE_ADDED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",roleId=" + roleId));
ddsAuthorizationChangeNotifier.changed("GROUP_ROLE_ADDED");
}
@Transactional
@@ -106,5 +123,6 @@ public class GroupService {
}
auditService.record(new AuditEvent("GROUP_ROLE_REMOVED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",roleId=" + roleId));
ddsAuthorizationChangeNotifier.changed("GROUP_ROLE_REMOVED");
}
}

View File

@@ -13,6 +13,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -32,15 +33,27 @@ public class PermissionService {
private final PermissionMapper permissionMapper;
private final ProtectedObjectService protectedObjectService;
private final AuditService auditService;
private final DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier;
@Autowired
public PermissionService(
PermissionMapper permissionMapper,
ProtectedObjectService protectedObjectService,
AuditService auditService,
DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier
) {
this.permissionMapper = permissionMapper;
this.protectedObjectService = protectedObjectService;
this.auditService = auditService;
this.ddsAuthorizationChangeNotifier = ddsAuthorizationChangeNotifier;
}
public PermissionService(
PermissionMapper permissionMapper,
ProtectedObjectService protectedObjectService,
AuditService auditService
) {
this.permissionMapper = permissionMapper;
this.protectedObjectService = protectedObjectService;
this.auditService = auditService;
this(permissionMapper, protectedObjectService, auditService, DdsAuthorizationChangeNotifier.noop());
}
public List<AppRole> findRoles() {
@@ -64,6 +77,7 @@ public class PermissionService {
long roleId = permissionMapper.nextRoleId();
permissionMapper.insertRole(roleId, roleName.trim(), description, normalizeSensitivityLevel(maxSensitivityLevel));
auditService.record(new AuditEvent("ROLE_CREATED", null, null, "SUCCESS", null, null, roleName));
ddsAuthorizationChangeNotifier.changed("ROLE_CREATED");
}
@Transactional
@@ -75,6 +89,7 @@ public class PermissionService {
}
auditService.record(new AuditEvent("ROLE_MAX_SENSITIVITY_UPDATED", null, null, "SUCCESS", null, null,
"roleId=" + roleId + ", max=" + normalized));
ddsAuthorizationChangeNotifier.changed("ROLE_MAX_SENSITIVITY_UPDATED");
}
@Transactional
@@ -100,6 +115,7 @@ public class PermissionService {
throw new AppException("삭제할 역할을 찾을 수 없습니다.");
}
auditService.record(new AuditEvent("ROLE_DELETED", null, null, "SUCCESS", null, null, "roleId=" + roleId));
ddsAuthorizationChangeNotifier.changed("ROLE_DELETED");
}
@Transactional
@@ -148,6 +164,7 @@ public class PermissionService {
"PERMISSION_SAVED", null, command.objectId(), "SUCCESS", null, null,
"roleId=" + command.roleId()
));
ddsAuthorizationChangeNotifier.changed("PERMISSION_SAVED");
return new PermissionSet(permissionId, command.roleId(), command.objectId(), "SELECT", permissionEffect, List.of(), List.of());
}
@@ -173,6 +190,7 @@ public class PermissionService {
}
auditService.record(new AuditEvent("PERMISSION_DELETED", null, null, "SUCCESS", null, null,
"permissionId=" + permissionId));
ddsAuthorizationChangeNotifier.changed("PERMISSION_DELETED");
}
public int countPermissionsByObjectId(long objectId) {

View File

@@ -6,6 +6,7 @@ import com.cloudhandson.vpdbackoffice.domain.user.UserCreateCommand;
import com.cloudhandson.vpdbackoffice.domain.user.UserRoleView;
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -14,10 +15,21 @@ public class UserService {
private final UserMapper userMapper;
private final AuditService auditService;
private final DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier;
public UserService(UserMapper userMapper, AuditService auditService) {
@Autowired
public UserService(
UserMapper userMapper,
AuditService auditService,
DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier
) {
this.userMapper = userMapper;
this.auditService = auditService;
this.ddsAuthorizationChangeNotifier = ddsAuthorizationChangeNotifier;
}
public UserService(UserMapper userMapper, AuditService auditService) {
this(userMapper, auditService, DdsAuthorizationChangeNotifier.noop());
}
public List<AppUser> findAll() {
@@ -33,6 +45,7 @@ public class UserService {
long userId = userMapper.nextUserId();
userMapper.insertUser(userId, command);
auditService.record(new AuditEvent("USER_CREATED", null, null, "SUCCESS", null, null, command.username()));
ddsAuthorizationChangeNotifier.changed("USER_CREATED");
}
@Transactional
@@ -43,6 +56,7 @@ public class UserService {
}
auditService.record(new AuditEvent("USER_ACTIVE_CHANGED", null, null, "SUCCESS", null, null,
"userId=" + userId + ",active=" + active));
ddsAuthorizationChangeNotifier.changed("USER_ACTIVE_CHANGED");
}
@Transactional
@@ -50,6 +64,7 @@ public class UserService {
userMapper.insertUserRole(userId, roleId);
auditService.record(new AuditEvent("USER_ROLE_GRANTED", null, null, "SUCCESS", null, null,
"userId=" + userId + ",roleId=" + roleId));
ddsAuthorizationChangeNotifier.changed("USER_ROLE_GRANTED");
}
@Transactional
@@ -60,5 +75,6 @@ public class UserService {
}
auditService.record(new AuditEvent("USER_ROLE_REVOKED", null, null, "SUCCESS", null, null,
"userId=" + userId + ",roleId=" + roleId));
ddsAuthorizationChangeNotifier.changed("USER_ROLE_REVOKED");
}
}

View File

@@ -0,0 +1,21 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.mockito.Mockito.verify;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
class DdsAuthorizationChangeNotifierTest {
@Test
void invokesTheOptionalDdsSynchronizerWhenTheDedicatedApplicationProvidesOne() {
DdsAuthorizationSynchronizer synchronizer = org.mockito.Mockito.mock(DdsAuthorizationSynchronizer.class);
DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
factory.registerSingleton("ddsSynchronizer", synchronizer);
new DdsAuthorizationChangeNotifier(factory.getBeanProvider(DdsAuthorizationSynchronizer.class))
.changed("PERMISSION_SAVED");
verify(synchronizer).synchronize("PERMISSION_SAVED");
}
}