From ef1331be4b31b6212f63bf6a0bf3d6cabcde8018 Mon Sep 17 00:00:00 2001 From: devmrko Date: Thu, 2 Jul 2026 09:34:58 +0900 Subject: [PATCH] [Developer] #617 apply DDS MCP end-user authorization --- .env.example | 12 + .gitignore | 3 + dds-backoffice/README.md | 19 +- dds-backoffice/pom.xml | 6 +- .../DdsBackofficeApplication.java | 5 +- .../config/DdsMcpIamProperties.java | 79 +++++ .../ddsbackoffice/config/SecurityConfig.java | 5 +- .../domain/DdsMcpAuthenticatedUser.java | 9 + .../domain/DdsMcpEndUserPrincipal.java | 10 + .../domain/DdsMcpEndUserPublishResult.java | 9 + .../domain/DdsMcpVectorSearchResult.java | 13 + .../ddsbackoffice/domain/DdsSqlEvidence.java | 6 +- .../DdsMcpAuthorizationChangeListener.java | 31 ++ .../service/DdsMcpBearerAuthenticator.java | 65 ++++ .../service/DdsMcpContextExecutor.java | 172 ++++++++++ .../DdsMcpDatabaseAccessTokenProvider.java | 123 +++++++ .../service/DdsMcpEndUserPublisher.java | 309 ++++++++++++++++++ .../service/DdsMcpEndUserResolver.java | 60 ++++ .../service/DdsMcpSseService.java | 113 +++++++ .../service/DdsMcpVectorSearchService.java | 105 ++++++ .../service/DdsVectorKnowledgeService.java | 27 +- .../web/DdsMcpSseController.java | 80 +++++ .../web/DdsProtectionController.java | 6 + .../web/DdsProvisionController.java | 18 +- .../web/DdsVectorKnowledgeController.java | 21 +- .../src/main/resources/application.yml | 10 + .../src/main/resources/static/css/dds.css | 3 + .../main/resources/templates/dds-admin.html | 1 + .../resources/templates/dds-evidence.html | 12 +- .../main/resources/templates/dds-home.html | 6 + .../resources/templates/dds-provision.html | 8 +- .../fragments/dds-vector-search-result.html | 2 +- .../resources/templates/fragments/layout.html | 3 +- .../resources/templates/vector-knowledge.html | 8 +- .../config/DdsMcpIamPropertiesTest.java | 40 +++ ...DdsMcpAuthorizationChangeListenerTest.java | 26 ++ .../service/DdsMcpJdbcApiTest.java | 19 ++ .../service/DdsMcpSseServiceTest.java | 34 ++ docs/adr/0001-dds-mcp-service-identity.md | 35 +- .../617-dds-mcp-end-user-context/README.md | 289 +++++----------- .../fn-authenticate-mcp-bearer.md | 25 +- .../fn-get-database-access-token.md | 26 +- .../fn-with-dds-end-user-context.md | 37 +-- .../run-dds-mcp-local-end-user-scenario.sh | 51 +++ sql/adb/44_dds_mcp_local_end_user_setup.sql | 244 ++++++++++++++ sql/adb/45_dds_mcp_local_end_user_test.sql | 59 ++++ .../vpdbackoffice/config/SecurityConfig.java | 6 +- .../DdsAuthorizationChangeNotifier.java | 37 +++ .../service/DdsAuthorizationSynchronizer.java | 7 + .../vpdbackoffice/service/GroupService.java | 20 +- .../service/PermissionService.java | 24 +- .../vpdbackoffice/service/UserService.java | 18 +- .../DdsAuthorizationChangeNotifierTest.java | 21 ++ 53 files changed, 2040 insertions(+), 337 deletions(-) create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsMcpIamProperties.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsMcpAuthenticatedUser.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsMcpEndUserPrincipal.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsMcpEndUserPublishResult.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsMcpVectorSearchResult.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpAuthorizationChangeListener.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpBearerAuthenticator.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpContextExecutor.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpDatabaseAccessTokenProvider.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpEndUserPublisher.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpEndUserResolver.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpSseService.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpVectorSearchService.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsMcpSseController.java create mode 100644 dds-backoffice/src/main/resources/templates/dds-admin.html create mode 100644 dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/config/DdsMcpIamPropertiesTest.java create mode 100644 dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/service/DdsMcpAuthorizationChangeListenerTest.java create mode 100644 dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/service/DdsMcpJdbcApiTest.java create mode 100644 dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/service/DdsMcpSseServiceTest.java create mode 100755 scripts/run-dds-mcp-local-end-user-scenario.sh create mode 100644 sql/adb/44_dds_mcp_local_end_user_setup.sql create mode 100644 sql/adb/45_dds_mcp_local_end_user_test.sql create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/DdsAuthorizationChangeNotifier.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/DdsAuthorizationSynchronizer.java create mode 100644 src/test/java/com/cloudhandson/vpdbackoffice/service/DdsAuthorizationChangeNotifierTest.java diff --git a/.env.example b/.env.example index ec9de35..ee80578 100644 --- a/.env.example +++ b/.env.example @@ -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-.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 가 이 인스턴스를 가리킴. diff --git a/.gitignore b/.gitignore index d418299..6631670 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ logs/ # Java / Maven target/ +# Locally downloaded development tools (for example SQLcl) +.tools/ + # OS .DS_Store Thumbs.db diff --git a/dds-backoffice/README.md b/dds-backoffice/README.md index 4d96acf..cd9d665 100644 --- a/dds-backoffice/README.md +++ b/dds-backoffice/README.md @@ -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_` 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_ 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→업무 사용자 매핑으로 결정한다. ## 실행 diff --git a/dds-backoffice/pom.xml b/dds-backoffice/pom.xml index 4de7b7d..4b05413 100644 --- a/dds-backoffice/pom.xml +++ b/dds-backoffice/pom.xml @@ -19,7 +19,9 @@ 21 - 23.6.0.24.10 + + 23.26.2.0.0 + 23.6.0.24.10 @@ -48,7 +50,7 @@ com.oracle.database.security oraclepki - ${oracle.jdbc.version} + ${oracle.pki.version} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/DdsBackofficeApplication.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/DdsBackofficeApplication.java index 71a03c8..651b039 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/DdsBackofficeApplication.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/DdsBackofficeApplication.java @@ -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, diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsMcpIamProperties.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsMcpIamProperties.java new file mode 100644 index 0000000..8347604 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsMcpIamProperties.java @@ -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. + * + *

The client credential proves the service may attach a DDS context; it + * is never used as the identity of an MCP caller.

+ */ +@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(); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/SecurityConfig.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/SecurityConfig.java index 64084c4..03cdae8 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/SecurityConfig.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/SecurityConfig.java @@ -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 -> { }) diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsMcpAuthenticatedUser.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsMcpAuthenticatedUser.java new file mode 100644 index 0000000..9b83e46 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsMcpAuthenticatedUser.java @@ -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 +) { +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsMcpEndUserPrincipal.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsMcpEndUserPrincipal.java new file mode 100644 index 0000000..13c5da4 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsMcpEndUserPrincipal.java @@ -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 +) { +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsMcpEndUserPublishResult.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsMcpEndUserPublishResult.java new file mode 100644 index 0000000..66c4da7 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsMcpEndUserPublishResult.java @@ -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 +) { +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsMcpVectorSearchResult.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsMcpVectorSearchResult.java new file mode 100644 index 0000000..da08305 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsMcpVectorSearchResult.java @@ -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> rows +) { +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsSqlEvidence.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsSqlEvidence.java index b1ab8b4..7efd6aa 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsSqlEvidence.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsSqlEvidence.java @@ -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); } } diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpAuthorizationChangeListener.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpAuthorizationChangeListener.java new file mode 100644 index 0000000..5667d78 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpAuthorizationChangeListener.java @@ -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); + } + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpBearerAuthenticator.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpBearerAuthenticator.java new file mode 100644 index 0000000..28b15b4 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpBearerAuthenticator.java @@ -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를 확인할 수 없습니다."); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpContextExecutor.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpContextExecutor.java new file mode 100644 index 0000000..ddd5d78 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpContextExecutor.java @@ -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 withContext(DdsMcpEndUserPrincipal principal, SqlWork 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 execute(java.sql.Connection connection) throws Exception; + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpDatabaseAccessTokenProvider.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpDatabaseAccessTokenProvider.java new file mode 100644 index 0000000..9155020 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpDatabaseAccessTokenProvider.java @@ -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 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) { + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpEndUserPublisher.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpEndUserPublisher.java new file mode 100644 index 0000000..c2a6ad6 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpEndUserPublisher.java @@ -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 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 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 allow = new ArrayList<>(); + List 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 clauses) { + return clauses.size() == 1 ? clauses.getFirst() : "(" + String.join(") OR (", clauses) + ")"; + } + + private String excludedVectorColumns(long userId) { + List 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) { + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpEndUserResolver.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpEndUserResolver.java new file mode 100644 index 0000000..1b18b5f --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpEndUserResolver.java @@ -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 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"); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpSseService.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpSseService.java new file mode 100644 index 0000000..75c93b7 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpSseService.java @@ -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; + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpVectorSearchService.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpVectorSearchService.java new file mode 100644 index 0000000..32e8bb5 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpVectorSearchService.java @@ -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> rows = new ArrayList<>(); + log.info("DDS vector query started"); + try (ResultSet result = statement.executeQuery()) { + while (result.next()) { + Map 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: 검색 임베딩을 읽을 수 없습니다."); + } + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsVectorKnowledgeService.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsVectorKnowledgeService.java index 25bdc3d..2d027b1 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsVectorKnowledgeService.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsVectorKnowledgeService.java @@ -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와 실행 계획 조회는 이 환경에서 사용할 수 없습니다."); } } diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsMcpSseController.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsMcpSseController.java new file mode 100644 index 0000000..b3f9342 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsMcpSseController.java @@ -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 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) { + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java index d480426..b869f29 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java @@ -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()); diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProvisionController.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProvisionController.java index f753d40..9165163 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProvisionController.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProvisionController.java @@ -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( diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsVectorKnowledgeController.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsVectorKnowledgeController.java index 0ef1176..8d8039e 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsVectorKnowledgeController.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsVectorKnowledgeController.java @@ -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()); } } diff --git a/dds-backoffice/src/main/resources/application.yml b/dds-backoffice/src/main/resources/application.yml index cfd5692..10eed9e 100644 --- a/dds-backoffice/src/main/resources/application.yml +++ b/dds-backoffice/src/main/resources/application.yml @@ -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} diff --git a/dds-backoffice/src/main/resources/static/css/dds.css b/dds-backoffice/src/main/resources/static/css/dds.css index b672132..a752b82 100644 --- a/dds-backoffice/src/main/resources/static/css/dds.css +++ b/dds-backoffice/src/main/resources/static/css/dds.css @@ -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; } } diff --git a/dds-backoffice/src/main/resources/templates/dds-admin.html b/dds-backoffice/src/main/resources/templates/dds-admin.html new file mode 100644 index 0000000..3e777af --- /dev/null +++ b/dds-backoffice/src/main/resources/templates/dds-admin.html @@ -0,0 +1 @@ +

Admin

운영 환경과 진단 기능

업무 사용자 검색 연결

사용자·그룹·역할·권한은 테이블에서 조회합니다. 이 설정은 그 결과를 DDS 보호 객체에 전달할 단일 기술 연결만 관리합니다.

설정 안내

Bearer 토큰 원문과 DDS 비밀번호는 권한 테이블에 저장하지 않습니다. 서버 Secret 또는 Wallet에 기술 연결 정보를 등록하세요.

보호 대상 설정

protected object

보호 대상 관리

DDS 연결 진단

통합·차단·PG·MY fixture는 업무 사용자 검증이 아닌 DB 연결 진단에만 사용합니다.

연결 진단 열기

실행 근거

SQL 실행 증거와 진단 설정

실행 근거 보기
diff --git a/dds-backoffice/src/main/resources/templates/dds-evidence.html b/dds-backoffice/src/main/resources/templates/dds-evidence.html index b5bad8f..1d9c61d 100644 --- a/dds-backoffice/src/main/resources/templates/dds-evidence.html +++ b/dds-backoffice/src/main/resources/templates/dds-evidence.html @@ -6,16 +6,20 @@

실행 근거

-

최근 지식자료 검색의 SQL ID와 보호 조건

-
도움말

토큰·바인드 값·검색 본문은 기록하지 않습니다. SQL ID와 실행 계획 predicate는 DB 커서 캐시에서 읽으므로 캐시가 비워지면 조회할 수 없습니다.

+

FGA 감사 행으로 확인하는 보호 객체 접근 내역

+
도움말

요청 ID, 업무 사용자 Context, SQL 원문, 보호 객체, 성공·실패는 FGA/Unified Audit 감사 행에서 조회합니다. 토큰 원문과 바인드 값은 표시하지 않습니다.

+
+

FGA 감사 증적

보호 VIEW SELECT 실행 뒤 생성된 감사 행

감사 trail 연동
+
다음 검색부터 요청 ID로 FGA 감사 행을 연결합니다. 감사 정책이 적용되면 SQL 원문·업무 사용자 Context·반환 코드가 이 영역에 표시됩니다.
+

표시할 실행 내역이 없습니다.

권한 검색 또는 직접 접근 검증을 실행하면 이 화면에 최근 내역이 표시됩니다.

-

지식자료 검색

status

status
+

지식자료 검색

status

status
SQL ID
-
마지막 실행
-
실행 횟수
-
-
적용 조건 보기

실행 계획 predicate
predicate

VPD predicate
predicate

+
실행 SQL과 적용 조건 보기

실행 SQL
statement

실행 계획 predicate
predicate

VPD predicate
predicate

diff --git a/dds-backoffice/src/main/resources/templates/dds-home.html b/dds-backoffice/src/main/resources/templates/dds-home.html index c725167..5e8c82f 100644 --- a/dds-backoffice/src/main/resources/templates/dds-home.html +++ b/dds-backoffice/src/main/resources/templates/dds-home.html @@ -17,6 +17,12 @@
DDS 관리 데이터를 불러오지 못했습니다.
+
+

권한 적용 흐름

권한을 정하고, 보호 정책에 반영한 뒤 결과를 확인합니다.

+ +
전체 구조 보기
사용자 테이블 사용자그룹 · 역할 · 접근 규칙MCP Bearerlocal DDS END USER ContextDATA GRANT보호 VIEW · 검색 결과
CB_APP_USER업무 사용자
CB_GROUP · CB_ROLE소속과 역할
CB_PERMISSION_RULETAG 허용·거부
CB_AGENT_BEARER_KEY요청 사용자 식별
CB_DDS_END_USER_MAPDDS 보안 사용자 매핑

업무 사용자는 기존 사용자 테이블에서 관리합니다. OCI IAM은 서비스의 DB Context attach만 승인하고, 실제 행·컬럼 권한은 해당 local DDS END USER의 DATA GRANT가 집행합니다.

+
+
역할0 권한 규칙0 diff --git a/dds-backoffice/src/main/resources/templates/dds-provision.html b/dds-backoffice/src/main/resources/templates/dds-provision.html index 57cef3f..88e1d62 100644 --- a/dds-backoffice/src/main/resources/templates/dds-provision.html +++ b/dds-backoffice/src/main/resources/templates/dds-provision.html @@ -10,8 +10,8 @@

사용자·그룹·역할의 접근 규칙을 검토한 뒤 DDS 보호 규칙에 반영합니다.

도움말 -

이 화면은 직접 DDS END USER 비교 경로와 일반 객체의 선언형 Grant를 미리 보고 게시하는 단계입니다. 토큰 기반 벡터 경로는 별도 객체별 Data Grant predicate가 요청 시 공통 권한 테이블을 다시 평가합니다.

-

그룹 자체를 DDS 그룹으로 복사하지는 않습니다. 애플리케이션 그룹의 역할 상속을 계산해 직접 비교용 DATA ROLE Grant에 반영하고, 토큰 경로에서는 같은 effective role 계산을 predicate 함수가 사용합니다.

+

게시하면 직접 DDS 비교 Grant와 MCP SSE용 local END USER/DATA ROLE/DATA GRANT를 함께 갱신합니다. MCP 요청 Bearer는 기존 사용자 테이블의 업무 사용자를 찾고, DDS는 그 사용자의 게시된 권한만 집행합니다.

+

OCI IAM client-credentials token은 서비스의 Context attach 권한만 제공합니다. MCP 업무 사용자를 IAM에 따로 만들지 않으며, 그룹 역할 상속은 기존 권한 관리 기준으로 계산합니다.

@@ -27,12 +27,12 @@ 애플리케이션 사용자매핑된 DDS END USER + DATA ROLE게시할 때 매핑 확인 그룹과 그룹에 연결된 역할그룹을 복사하지 않고 최종 predicate로 합산미리보기·게시 때 계산 테이블·VIEW SELECT 권한보호 객체별 DATA GRANT ... WHERE ...권한 변경 후 게시 - Bearer 토큰 벡터 검색기술 사용자 Context + 객체별 Data Grant predicate34_dds_token_data_grant_common_auth.sql 경계 + MCP Bearer 벡터 검색Bearer → 업무 사용자 → local DDS END USER Context → DATA GRANT권한 저장 시 자동 bulk 동기화 · 이 화면은 전체 재동기화 원문 표시 허용 컬럼AS SELECT 또는 ALL COLUMNS EXCEPT권한 변경 후 게시 -

VPD는 요청마다 권한 테이블을 평가하지만, DDS는 게시된 Grant가 바뀔 때까지 이전 선언을 계속 사용합니다. 이 화면의 게시 버튼이 두 모델을 동기화하는 경계입니다.

+

DDS는 게시된 Grant가 바뀔 때까지 이전 선언을 사용합니다. 이 화면의 게시 버튼이 기존 권한 관리와 MCP local END USER 권한을 동기화하는 경계입니다.

diff --git a/dds-backoffice/src/main/resources/templates/fragments/dds-vector-search-result.html b/dds-backoffice/src/main/resources/templates/fragments/dds-vector-search-result.html index 7551966..8e1e364 100644 --- a/dds-backoffice/src/main/resources/templates/fragments/dds-vector-search-result.html +++ b/dds-backoffice/src/main/resources/templates/fragments/dds-vector-search-result.html @@ -41,7 +41,7 @@
실행 횟수
0
실행 조건 보기 -

요청: statement

+

실행 SQL
statement

계획 predicate: predicate

VPD predicate: predicate

diff --git a/dds-backoffice/src/main/resources/templates/fragments/layout.html b/dds-backoffice/src/main/resources/templates/fragments/layout.html index 09a1a39..2d06041 100644 --- a/dds-backoffice/src/main/resources/templates/fragments/layout.html +++ b/dds-backoffice/src/main/resources/templates/fragments/layout.html @@ -22,6 +22,7 @@ + Admin
@@ -54,7 +55,7 @@
- DDS 보호 운영 · 실제 보호는 객체별 DATA GRANT가 담당합니다. 업무 사용자 검색은 Bearer → CB_AGENT_CTX → 접근 조건, 직접 접근 비교는 END USER → DATA ROLE → DATA GRANT 경로로 동작합니다. + DDS 보호 운영 · 실제 보호는 객체별 DATA GRANT가 담당합니다. MCP 검색은 Bearer → 업무 사용자 → local END USER Context → DATA GRANT, 직접 접근 비교는 END USER → DATA ROLE → DATA GRANT 경로로 동작합니다.
diff --git a/dds-backoffice/src/main/resources/templates/vector-knowledge.html b/dds-backoffice/src/main/resources/templates/vector-knowledge.html index 031d0d3..01c1818 100644 --- a/dds-backoffice/src/main/resources/templates/vector-knowledge.html +++ b/dds-backoffice/src/main/resources/templates/vector-knowledge.html @@ -109,11 +109,11 @@ 도움말

토큰은 검색하는 사용자를 확인하기 위한 임시 값이며 저장하지 않습니다. 회수되었거나 만료된 토큰은 사용할 수 없습니다.

- + -