Compare commits
3 Commits
ebcce66bf7
...
ef1331be4b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef1331be4b | ||
|
|
fd09622c82 | ||
|
|
b7100cc62a |
12
.env.example
12
.env.example
@@ -71,6 +71,18 @@ export DDSUSER_BOTH_PASSWORD="DdsGrant#Both26"
|
|||||||
export DDSUSER_NONE_PASSWORD="DdsGrant#None26"
|
export DDSUSER_NONE_PASSWORD="DdsGrant#None26"
|
||||||
export DDSUSER_TOKEN_PASSWORD="DdsGrant#Token26"
|
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, ...) ---
|
# --- (4) 원격 Postgres (AWS RDS, Cloud SQL, ...) ---
|
||||||
# sql/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성.
|
# sql/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성.
|
||||||
# ADB 의 RDS_POSTGRES_LINK 가 이 인스턴스를 가리킴.
|
# ADB 의 RDS_POSTGRES_LINK 가 이 인스턴스를 가리킴.
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,6 +17,9 @@ logs/
|
|||||||
# Java / Maven
|
# Java / Maven
|
||||||
target/
|
target/
|
||||||
|
|
||||||
|
# Locally downloaded development tools (for example SQLcl)
|
||||||
|
.tools/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
- 주 보호 객체: `ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS` (지식 청크·분류값 검색)
|
- 주 보호 객체: `ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS` (지식 청크·분류값 검색)
|
||||||
- 보조 SQL 검증 객체: `ADMIN.V_DDS_CUSTOMERS_PG`, `ADMIN.V_DDS_CUSTOMERS_MY`
|
- 보조 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 SQL 검증 사용자: `dds_demo_my`, `dds_demo_pg`, `dds_demo_both`, `dds_demo_none`
|
||||||
- 토큰 경로 기술 사용자: `dds_demo_token` (고객별 DDS 계정이 아님)
|
- MCP SSE 경로: Bearer가 기존 업무 사용자를 식별하고 local DDS `END USER` Context로 실행
|
||||||
- 조회 방식: 직접 DDS END USER 비교 경로 + 단일 기술 사용자·Bearer 토큰 경로
|
- 레거시 토큰 데모 기술 사용자: `dds_demo_token` (비교·호환성 검증용)
|
||||||
|
|
||||||
## 두 데모의 관계
|
## 두 데모의 관계
|
||||||
|
|
||||||
@@ -27,28 +27,29 @@
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| VPD | 공통 계정 + Bearer/세션 컨텍스트 | 동적 predicate 함수 | 업무 권한 테이블 |
|
| VPD | 공통 계정 + Bearer/세션 컨텍스트 | 동적 predicate 함수 | 업무 권한 테이블 |
|
||||||
| DDS 직접 비교 | DDS `END USER` 직접 로그인 | `DATA ROLE` + `DATA GRANT` | 선언형 DDL/Grant |
|
| 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
|
```text
|
||||||
1. /permissions에서 사용자·그룹·역할·행/TAG·컬럼 기준을 저장
|
1. /permissions에서 사용자·그룹·역할·행/TAG·컬럼 기준을 저장
|
||||||
2. /dds-provision에서 사용자별 predicate와 제외 컬럼을 검토
|
2. 저장 직후 활성 사용자의 MCP local END USER/DATA ROLE/DATA GRANT를 bulk 재게시
|
||||||
3. 승인된 경우에만 DDS DATA GRANT 게시
|
3. /dds-provision에서 전체 재동기화·변경 검토·복구 게시
|
||||||
4. /vector-knowledge에서 토큰 기반 공통 권한 결과를 검증
|
4. /dds/mcp/sse의 `dds_vector_search`로 실제 MCP 권한 결과를 검증
|
||||||
5. /dds에서 직접 END USER별 저수준 결과를 비교
|
5. /dds에서 직접 END USER별 저수준 결과를 비교
|
||||||
```
|
```
|
||||||
|
|
||||||
이 앱의 조회 경계는 다음과 같습니다.
|
이 앱의 조회 경계는 다음과 같습니다.
|
||||||
|
|
||||||
```text
|
```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
|
직접 비교: 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→업무 사용자 매핑으로 결정한다.
|
||||||
|
|
||||||
## 실행
|
## 실행
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>21</java.version>
|
<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>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
@@ -48,7 +50,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.oracle.database.security</groupId>
|
<groupId>com.oracle.database.security</groupId>
|
||||||
<artifactId>oraclepki</artifactId>
|
<artifactId>oraclepki</artifactId>
|
||||||
<version>${oracle.jdbc.version}</version>
|
<version>${oracle.pki.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.cloudhandson.ddsbackoffice;
|
package com.cloudhandson.ddsbackoffice;
|
||||||
|
|
||||||
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
||||||
|
import com.cloudhandson.ddsbackoffice.config.DdsMcpIamProperties;
|
||||||
import com.cloudhandson.vpdbackoffice.VpdBackofficeApplication;
|
import com.cloudhandson.vpdbackoffice.VpdBackofficeApplication;
|
||||||
import com.cloudhandson.vpdbackoffice.web.DashboardController;
|
import com.cloudhandson.vpdbackoffice.web.DashboardController;
|
||||||
import com.cloudhandson.vpdbackoffice.web.LoginController;
|
import com.cloudhandson.vpdbackoffice.web.LoginController;
|
||||||
@@ -14,7 +15,7 @@ import org.springframework.context.annotation.ComponentScan;
|
|||||||
import org.springframework.context.annotation.FilterType;
|
import org.springframework.context.annotation.FilterType;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableConfigurationProperties(DdsProperties.class)
|
@EnableConfigurationProperties({DdsProperties.class, DdsMcpIamProperties.class})
|
||||||
@MapperScan("com.cloudhandson.vpdbackoffice.mapper")
|
@MapperScan("com.cloudhandson.vpdbackoffice.mapper")
|
||||||
@ComponentScan(
|
@ComponentScan(
|
||||||
basePackages = {"com.cloudhandson.ddsbackoffice", "com.cloudhandson.vpdbackoffice"},
|
basePackages = {"com.cloudhandson.ddsbackoffice", "com.cloudhandson.vpdbackoffice"},
|
||||||
@@ -24,7 +25,7 @@ import org.springframework.context.annotation.FilterType;
|
|||||||
DdsBackofficeApplication.class,
|
DdsBackofficeApplication.class,
|
||||||
VpdBackofficeApplication.class,
|
VpdBackofficeApplication.class,
|
||||||
com.cloudhandson.ddsbackoffice.config.AppConfig.class,
|
com.cloudhandson.ddsbackoffice.config.AppConfig.class,
|
||||||
com.cloudhandson.ddsbackoffice.config.SecurityConfig.class,
|
com.cloudhandson.vpdbackoffice.config.SecurityConfig.class,
|
||||||
DashboardController.class,
|
DashboardController.class,
|
||||||
LoginController.class,
|
LoginController.class,
|
||||||
VectorKnowledgeController.class,
|
VectorKnowledgeController.class,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.cloudhandson.ddsbackoffice.config;
|
package com.cloudhandson.ddsbackoffice.config;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
@@ -23,11 +24,13 @@ public class SecurityConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return http
|
return http
|
||||||
|
.csrf(csrf -> csrf.ignoringRequestMatchers("/dds/mcp/messages"))
|
||||||
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
|
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
|
||||||
.includeSubDomains(true)
|
.includeSubDomains(true)
|
||||||
.maxAgeInSeconds(31_536_000)))
|
.maxAgeInSeconds(31_536_000)))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/css/**", "/js/**", "/health", "/login").permitAll()
|
.requestMatchers("/css/**", "/js/**", "/health", "/login",
|
||||||
|
"/dds/mcp/sse", "/dds/mcp/messages").permitAll()
|
||||||
.anyRequest().authenticated())
|
.anyRequest().authenticated())
|
||||||
.httpBasic(basic -> {
|
.httpBasic(basic -> {
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ package com.cloudhandson.ddsbackoffice.domain;
|
|||||||
public record DdsSqlEvidence(
|
public record DdsSqlEvidence(
|
||||||
boolean available,
|
boolean available,
|
||||||
String requestId,
|
String requestId,
|
||||||
String submittedStatement,
|
String executedSql,
|
||||||
String sqlId,
|
String sqlId,
|
||||||
Integer childNumber,
|
Integer childNumber,
|
||||||
String lastActiveTime,
|
String lastActiveTime,
|
||||||
@@ -13,7 +13,7 @@ public record DdsSqlEvidence(
|
|||||||
String vpdPredicate,
|
String vpdPredicate,
|
||||||
String status
|
String status
|
||||||
) {
|
) {
|
||||||
public static DdsSqlEvidence unavailable(String requestId, String submittedStatement, String status) {
|
public static DdsSqlEvidence unavailable(String requestId, String executedSql, String status) {
|
||||||
return new DdsSqlEvidence(false, requestId, submittedStatement, null, null, null, null, null, null, status);
|
return new DdsSqlEvidence(false, requestId, executedSql, null, null, null, null, null, null, status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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를 확인할 수 없습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: 검색 임베딩을 읽을 수 없습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,10 @@ public class DdsVectorKnowledgeService {
|
|||||||
return commonVectorService.aiEmbeddingConfigured();
|
return commonVectorService.aiEmbeddingConfigured();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean tokenSearchAvailable() {
|
||||||
|
return !properties.dbUrl().isBlank() && properties.token() != null && properties.token().configured();
|
||||||
|
}
|
||||||
|
|
||||||
public VectorIngestResult ingest(VectorIngestCommand command) {
|
public VectorIngestResult ingest(VectorIngestCommand command) {
|
||||||
return commonVectorService.ingest(command);
|
return commonVectorService.ingest(command);
|
||||||
}
|
}
|
||||||
@@ -108,8 +112,6 @@ public class DdsVectorKnowledgeService {
|
|||||||
|
|
||||||
String requestId = UUID.randomUUID().toString();
|
String requestId = UUID.randomUUID().toString();
|
||||||
String marker = "DDS_EVIDENCE:" + requestId;
|
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 "
|
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, "
|
+ "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 "
|
+ "d.tech_tag, VECTOR_DISTANCE(d.embedding, TO_VECTOR(?), COSINE) AS score "
|
||||||
@@ -144,7 +146,7 @@ public class DdsVectorKnowledgeService {
|
|||||||
String message = rows.isEmpty()
|
String message = rows.isEmpty()
|
||||||
? "DDS DATA GRANT를 통과한 검색 단위가 없습니다."
|
? "DDS DATA GRANT를 통과한 검색 단위가 없습니다."
|
||||||
: rows.size() + "개 검색 단위가 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);
|
evidenceHistory.record(evidence);
|
||||||
return new DdsVectorSearchResult(
|
return new DdsVectorSearchResult(
|
||||||
normalizedUserKey, userLabel, properties.vectorObject(), normalizedQuery, mode,
|
normalizedUserKey, userLabel, properties.vectorObject(), normalizedQuery, mode,
|
||||||
@@ -197,8 +199,6 @@ public class DdsVectorKnowledgeService {
|
|||||||
|
|
||||||
String requestId = UUID.randomUUID().toString();
|
String requestId = UUID.randomUUID().toString();
|
||||||
String marker = "DDS_EVIDENCE:" + requestId;
|
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 "
|
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, "
|
+ "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 "
|
+ "d.tech_tag, VECTOR_DISTANCE(d.embedding, TO_VECTOR(?), COSINE) AS score "
|
||||||
@@ -212,6 +212,11 @@ public class DdsVectorKnowledgeService {
|
|||||||
context.setString(1, normalizedToken);
|
context.setString(1, normalizedToken);
|
||||||
context.execute();
|
context.execute();
|
||||||
}
|
}
|
||||||
|
try (PreparedStatement identifier = connection.prepareStatement(
|
||||||
|
"BEGIN DBMS_SESSION.SET_IDENTIFIER(?); END;")) {
|
||||||
|
identifier.setString(1, marker);
|
||||||
|
identifier.execute();
|
||||||
|
}
|
||||||
String sessionUser = readSingleValue(connection,
|
String sessionUser = readSingleValue(connection,
|
||||||
"SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM dual", timeoutSeconds);
|
"SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM dual", timeoutSeconds);
|
||||||
String endUser = readSingleValue(connection,
|
String endUser = readSingleValue(connection,
|
||||||
@@ -244,7 +249,7 @@ public class DdsVectorKnowledgeService {
|
|||||||
: rows.size() + "개 검색 단위가 토큰으로 식별된 업무 사용자("
|
: rows.size() + "개 검색 단위가 토큰으로 식별된 업무 사용자("
|
||||||
+ (resolvedAppUser == null ? "확인 불가" : resolvedAppUser)
|
+ (resolvedAppUser == null ? "확인 불가" : resolvedAppUser)
|
||||||
+ ")의 DDS DATA GRANT를 통과했습니다.";
|
+ ")의 DDS DATA GRANT를 통과했습니다.";
|
||||||
DdsSqlEvidence evidence = collectEvidence(connection, marker, submittedStatement, timeoutSeconds);
|
DdsSqlEvidence evidence = collectEvidence(connection, marker, sql, timeoutSeconds);
|
||||||
evidenceHistory.record(evidence);
|
evidenceHistory.record(evidence);
|
||||||
return new DdsVectorSearchResult(
|
return new DdsVectorSearchResult(
|
||||||
"token", userLabel, properties.vectorObject(), normalizedQuery, mode,
|
"token", userLabel, properties.vectorObject(), normalizedQuery, mode,
|
||||||
@@ -271,7 +276,7 @@ public class DdsVectorKnowledgeService {
|
|||||||
false, title, message, null, null, oracleCode, List.of(), null);
|
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) {
|
int timeoutSeconds) {
|
||||||
try (PreparedStatement statement = connection.prepareStatement("""
|
try (PreparedStatement statement = connection.prepareStatement("""
|
||||||
SELECT sql_id, child_number, TO_CHAR(last_active_time, 'YYYY-MM-DD HH24:MI:SS'), executions
|
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 + "%");
|
statement.setString(1, "%" + marker + "%");
|
||||||
try (ResultSet result = statement.executeQuery()) {
|
try (ResultSet result = statement.executeQuery()) {
|
||||||
if (!result.next()) {
|
if (!result.next()) {
|
||||||
return DdsSqlEvidence.unavailable(marker, submittedStatement, "실행 SQL ID를 아직 찾지 못했습니다.");
|
return DdsSqlEvidence.unavailable(marker, executedSql, "실행 SQL은 확인됐지만 SQL ID를 아직 찾지 못했습니다.");
|
||||||
}
|
}
|
||||||
String sqlId = result.getString(1);
|
String sqlId = result.getString(1);
|
||||||
int child = result.getInt(2);
|
int child = result.getInt(2);
|
||||||
String vpd = readPredicate(connection, "SELECT predicate FROM v$vpd_policy WHERE sql_id = ?", sqlId, timeoutSeconds);
|
String vpd = readPredicate(connection, "SELECT predicate FROM v$vpd_policy WHERE sql_id = ?", sqlId, timeoutSeconds);
|
||||||
String plan = readPlanPredicate(connection, sqlId, child, 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,
|
result.getLong(4), plan, vpd,
|
||||||
vpd != null ? "VPD 정책 predicate를 확인했습니다."
|
vpd != null ? "VPD 정책 predicate를 확인했습니다."
|
||||||
: "DDS DATA GRANT 경로의 실행 SQL을 확인했습니다.");
|
: "DDS DATA GRANT 경로의 실행 SQL을 확인했습니다.");
|
||||||
}
|
}
|
||||||
} catch (SQLException exception) {
|
} catch (SQLException exception) {
|
||||||
return DdsSqlEvidence.unavailable(marker, submittedStatement,
|
return DdsSqlEvidence.unavailable(marker, executedSql,
|
||||||
"실행 근거 조회 권한이 없습니다. V$SQL, V$VPD_POLICY, DBMS_XPLAN 권한을 확인하세요.");
|
"실행 SQL 전문을 표시합니다. SQL ID와 실행 계획 조회는 이 환경에서 사용할 수 없습니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,12 @@ public class DdsProtectionController {
|
|||||||
return "dds-evidence";
|
return "dds-evidence";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/dds-admin")
|
||||||
|
public String admin(Model model) {
|
||||||
|
model.addAttribute("ddsVectorObject", properties.vectorObject());
|
||||||
|
return "dds-admin";
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/dds-protection/direct")
|
@GetMapping("/dds-protection/direct")
|
||||||
public String directComparison(Model model) {
|
public String directComparison(Model model) {
|
||||||
model.addAttribute("directPaths", protectionStatusService.directComparison());
|
model.addAttribute("directPaths", protectionStatusService.directComparison());
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.cloudhandson.ddsbackoffice.web;
|
|||||||
|
|
||||||
import com.cloudhandson.ddsbackoffice.domain.DdsProvisioningPlan;
|
import com.cloudhandson.ddsbackoffice.domain.DdsProvisioningPlan;
|
||||||
import com.cloudhandson.ddsbackoffice.service.DdsGrantPublisher;
|
import com.cloudhandson.ddsbackoffice.service.DdsGrantPublisher;
|
||||||
|
import com.cloudhandson.ddsbackoffice.service.DdsMcpEndUserPublisher;
|
||||||
import com.cloudhandson.ddsbackoffice.service.DdsProtectionEvidenceStore;
|
import com.cloudhandson.ddsbackoffice.service.DdsProtectionEvidenceStore;
|
||||||
import com.cloudhandson.ddsbackoffice.service.DdsProtectionFingerprint;
|
import com.cloudhandson.ddsbackoffice.service.DdsProtectionFingerprint;
|
||||||
import com.cloudhandson.ddsbackoffice.domain.DdsPublishedProtectionSpec;
|
import com.cloudhandson.ddsbackoffice.domain.DdsPublishedProtectionSpec;
|
||||||
@@ -16,10 +17,16 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
|||||||
public class DdsProvisionController {
|
public class DdsProvisionController {
|
||||||
|
|
||||||
private final DdsGrantPublisher publisher;
|
private final DdsGrantPublisher publisher;
|
||||||
|
private final DdsMcpEndUserPublisher mcpEndUserPublisher;
|
||||||
private final DdsProtectionEvidenceStore evidenceStore;
|
private final DdsProtectionEvidenceStore evidenceStore;
|
||||||
|
|
||||||
public DdsProvisionController(DdsGrantPublisher publisher, DdsProtectionEvidenceStore evidenceStore) {
|
public DdsProvisionController(
|
||||||
|
DdsGrantPublisher publisher,
|
||||||
|
DdsMcpEndUserPublisher mcpEndUserPublisher,
|
||||||
|
DdsProtectionEvidenceStore evidenceStore
|
||||||
|
) {
|
||||||
this.publisher = publisher;
|
this.publisher = publisher;
|
||||||
|
this.mcpEndUserPublisher = mcpEndUserPublisher;
|
||||||
this.evidenceStore = evidenceStore;
|
this.evidenceStore = evidenceStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,10 +40,17 @@ public class DdsProvisionController {
|
|||||||
public String publish(RedirectAttributes redirectAttributes) {
|
public String publish(RedirectAttributes redirectAttributes) {
|
||||||
try {
|
try {
|
||||||
DdsProvisioningPlan plan = publisher.publish();
|
DdsProvisioningPlan plan = publisher.publish();
|
||||||
|
var mcpResult = mcpEndUserPublisher.publish();
|
||||||
redirectAttributes.addFlashAttribute(
|
redirectAttributes.addFlashAttribute(
|
||||||
"successMessage",
|
"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 {
|
try {
|
||||||
plan.grants().forEach(grant -> evidenceStore.recordPublished(
|
plan.grants().forEach(grant -> evidenceStore.recordPublished(
|
||||||
new DdsPublishedProtectionSpec(
|
new DdsPublishedProtectionSpec(
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import com.cloudhandson.ddsbackoffice.service.DdsQueryService;
|
|||||||
import com.cloudhandson.ddsbackoffice.service.DdsVectorKnowledgeService;
|
import com.cloudhandson.ddsbackoffice.service.DdsVectorKnowledgeService;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestCommand;
|
import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestCommand;
|
||||||
import com.cloudhandson.vpdbackoffice.service.AppException;
|
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.dao.DataAccessException;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
@@ -19,13 +21,17 @@ public class DdsVectorKnowledgeController {
|
|||||||
|
|
||||||
private final DdsVectorKnowledgeService service;
|
private final DdsVectorKnowledgeService service;
|
||||||
private final DdsQueryService ddsQueryService;
|
private final DdsQueryService ddsQueryService;
|
||||||
|
private final UserService userService;
|
||||||
|
private final BearerTokenService bearerTokenService;
|
||||||
|
|
||||||
public DdsVectorKnowledgeController(
|
public DdsVectorKnowledgeController(
|
||||||
DdsVectorKnowledgeService service,
|
DdsVectorKnowledgeService service,
|
||||||
DdsQueryService ddsQueryService
|
DdsQueryService ddsQueryService, UserService userService, BearerTokenService bearerTokenService
|
||||||
) {
|
) {
|
||||||
this.service = service;
|
this.service = service;
|
||||||
this.ddsQueryService = ddsQueryService;
|
this.ddsQueryService = ddsQueryService;
|
||||||
|
this.userService = userService;
|
||||||
|
this.bearerTokenService = bearerTokenService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/vector-knowledge")
|
@GetMapping("/vector-knowledge")
|
||||||
@@ -101,16 +107,29 @@ public class DdsVectorKnowledgeController {
|
|||||||
return "fragments/dds-vector-search-result :: result";
|
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) {
|
private void populatePage(Model model) {
|
||||||
try {
|
try {
|
||||||
model.addAttribute("summary", service.summary());
|
model.addAttribute("summary", service.summary());
|
||||||
model.addAttribute("aiEmbeddingConfigured", service.aiEmbeddingConfigured());
|
model.addAttribute("aiEmbeddingConfigured", service.aiEmbeddingConfigured());
|
||||||
|
model.addAttribute("tokenSearchAvailable", service.tokenSearchAvailable());
|
||||||
} catch (DataAccessException exception) {
|
} catch (DataAccessException exception) {
|
||||||
model.addAttribute("summary", null);
|
model.addAttribute("summary", null);
|
||||||
model.addAttribute("runtimeError", exception.getMessage());
|
model.addAttribute("runtimeError", exception.getMessage());
|
||||||
model.addAttribute("aiEmbeddingConfigured", false);
|
model.addAttribute("aiEmbeddingConfigured", false);
|
||||||
|
model.addAttribute("tokenSearchAvailable", false);
|
||||||
}
|
}
|
||||||
model.addAttribute("ddsUsers", ddsQueryService.users());
|
model.addAttribute("ddsUsers", ddsQueryService.users());
|
||||||
|
model.addAttribute("appUsers", userService.findAll().stream().filter(user -> "Y".equals(user.activeYn())).toList());
|
||||||
model.addAttribute("ddsVectorObject", ddsQueryService.vectorObject());
|
model.addAttribute("ddsVectorObject", ddsQueryService.vectorObject());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ backoffice:
|
|||||||
timeout: ${BACKOFFICE_AI_TIMEOUT_SECONDS:30}s
|
timeout: ${BACKOFFICE_AI_TIMEOUT_SECONDS:30}s
|
||||||
|
|
||||||
dds:
|
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}}
|
db-url: ${DDS_BACKOFFICE_DB_URL:${BACKOFFICE_DB_URL:jdbc:oracle:thin:@localhost:1521/FREEPDB1}}
|
||||||
query-timeout: ${DDS_BACKOFFICE_QUERY_TIMEOUT:10s}
|
query-timeout: ${DDS_BACKOFFICE_QUERY_TIMEOUT:10s}
|
||||||
pg-object: ${DDS_BACKOFFICE_PG_OBJECT:ADMIN.V_DDS_CUSTOMERS_PG}
|
pg-object: ${DDS_BACKOFFICE_PG_OBJECT:ADMIN.V_DDS_CUSTOMERS_PG}
|
||||||
|
|||||||
@@ -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; }
|
.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 > details > summary { color: var(--dds-ink); cursor: pointer; font-size: 1rem; font-weight: 700; }
|
||||||
.advanced-workflow .section-subtitle { margin: .4rem 0 0; }
|
.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; }
|
.form-grid > .btn { justify-self: start; min-width: 7.5rem; width: auto; }
|
||||||
.wizard-progress, .wizard-step-number { display: none; }
|
.wizard-progress, .wizard-step-number { display: none; }
|
||||||
.permission-wizard .wizard-panel-heading { align-items: flex-start; }
|
.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; }
|
.hero { padding-top: 30px; }
|
||||||
.protection-status-topline, .protection-status-footer { align-items: flex-start; flex-direction: column; }
|
.protection-status-topline, .protection-status-footer { align-items: flex-start; flex-direction: column; }
|
||||||
.knowledge-options-grid { grid-template-columns: 1fr; }
|
.knowledge-options-grid { grid-template-columns: 1fr; }
|
||||||
|
.menu-flow, .erd-map { grid-template-columns: 1fr; }
|
||||||
.knowledge-options-grid .full-row { grid-column: auto; }
|
.knowledge-options-grid .full-row { grid-column: auto; }
|
||||||
.registration-result { flex-direction: column; }
|
.registration-result { flex-direction: column; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -6,16 +6,20 @@
|
|||||||
<main class="container py-4">
|
<main class="container py-4">
|
||||||
<div class="page-title">
|
<div class="page-title">
|
||||||
<h1>실행 근거</h1>
|
<h1>실행 근거</h1>
|
||||||
<p class="context-summary">최근 지식자료 검색의 SQL ID와 보호 조건</p>
|
<p class="context-summary">FGA 감사 행으로 확인하는 보호 객체 접근 내역</p>
|
||||||
<details class="explanation-details"><summary>도움말</summary><p class="mb-0">토큰·바인드 값·검색 본문은 기록하지 않습니다. SQL ID와 실행 계획 predicate는 DB 커서 캐시에서 읽으므로 캐시가 비워지면 조회할 수 없습니다.</p></details>
|
<details class="explanation-details"><summary>도움말</summary><p class="mb-0">요청 ID, 업무 사용자 Context, SQL 원문, 보호 객체, 성공·실패는 FGA/Unified Audit 감사 행에서 조회합니다. 토큰 원문과 바인드 값은 표시하지 않습니다.</p></details>
|
||||||
</div>
|
</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)}">
|
<section class="content-band" th:if="${#lists.isEmpty(evidenceEntries)}">
|
||||||
<h2>표시할 실행 내역이 없습니다.</h2><p class="section-subtitle">권한 검색 또는 직접 접근 검증을 실행하면 이 화면에 최근 내역이 표시됩니다.</p>
|
<h2>표시할 실행 내역이 없습니다.</h2><p class="section-subtitle">권한 검색 또는 직접 접근 검증을 실행하면 이 화면에 최근 내역이 표시됩니다.</p>
|
||||||
</section>
|
</section>
|
||||||
<section class="content-band" th:each="entry : ${evidenceEntries}">
|
<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>
|
<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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -17,6 +17,12 @@
|
|||||||
|
|
||||||
<div class="alert alert-warning" th:if="${runtimeError}">DDS 관리 데이터를 불러오지 못했습니다. <span th:text="${runtimeError}"></span></div>
|
<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 관리 현황">
|
<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(roles)}">0</strong></a>
|
||||||
<a class="summary-tile" href="/permissions"><span class="label">권한 규칙</span><strong th:text="${#lists.size(permissions)}">0</strong></a>
|
<a class="summary-tile" href="/permissions"><span class="label">권한 규칙</span><strong th:text="${#lists.size(permissions)}">0</strong></a>
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
<p class="context-summary">사용자·그룹·역할의 접근 규칙을 검토한 뒤 DDS 보호 규칙에 반영합니다.</p>
|
<p class="context-summary">사용자·그룹·역할의 접근 규칙을 검토한 뒤 DDS 보호 규칙에 반영합니다.</p>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p>이 화면은 직접 DDS END USER 비교 경로와 일반 객체의 선언형 Grant를 미리 보고 게시하는 단계입니다. 토큰 기반 벡터 경로는 별도 객체별 Data Grant predicate가 요청 시 공통 권한 테이블을 다시 평가합니다.</p>
|
<p>게시하면 직접 DDS 비교 Grant와 MCP SSE용 local END USER/DATA ROLE/DATA GRANT를 함께 갱신합니다. MCP 요청 Bearer는 기존 사용자 테이블의 업무 사용자를 찾고, DDS는 그 사용자의 게시된 권한만 집행합니다.</p>
|
||||||
<p class="mb-0">그룹 자체를 DDS 그룹으로 복사하지는 않습니다. 애플리케이션 그룹의 역할 상속을 계산해 직접 비교용 DATA ROLE Grant에 반영하고, 토큰 경로에서는 같은 effective role 계산을 predicate 함수가 사용합니다.</p>
|
<p class="mb-0">OCI IAM client-credentials token은 서비스의 Context attach 권한만 제공합니다. MCP 업무 사용자를 IAM에 따로 만들지 않으며, 그룹 역할 상속은 기존 권한 관리 기준으로 계산합니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -27,12 +27,12 @@
|
|||||||
<tr><td>애플리케이션 사용자</td><td>매핑된 DDS END USER + DATA ROLE</td><td>게시할 때 매핑 확인</td></tr>
|
<tr><td>애플리케이션 사용자</td><td>매핑된 DDS END USER + DATA ROLE</td><td>게시할 때 매핑 확인</td></tr>
|
||||||
<tr><td>그룹과 그룹에 연결된 역할</td><td>그룹을 복사하지 않고 최종 predicate로 합산</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>테이블·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>
|
<tr><td>원문 표시 허용 컬럼</td><td><code>AS SELECT</code> 또는 <code>ALL COLUMNS EXCEPT</code></td><td>권한 변경 후 게시</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
<div><dt>실행 횟수</dt><dd th:text="${searchResult.sqlEvidence().executions()}">0</dd></div>
|
<div><dt>실행 횟수</dt><dd th:text="${searchResult.sqlEvidence().executions()}">0</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<details class="technical-details mt-2"><summary>실행 조건 보기</summary>
|
<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 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>
|
<p class="mb-0" th:if="${searchResult.sqlEvidence().vpdPredicate()}">VPD predicate: <code th:text="${searchResult.sqlEvidence().vpdPredicate()}">predicate</code></p>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -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="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>
|
<button class="rw-menu-trigger" type="button" data-submenu-trigger="verification" aria-controls="submenu-verification" aria-expanded="false">접근 검증</button>
|
||||||
</div>
|
</div>
|
||||||
|
<a class="btn btn-sm rw-btn-secondary" href="/dds-admin">Admin</a>
|
||||||
<form method="post" action="/logout" class="ms-auto">
|
<form method="post" action="/logout" class="ms-auto">
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="submit">로그아웃</button>
|
<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 th:fragment="trackNotice" th:if="${backofficeTrack == 'DDS'}" class="container pt-3">
|
||||||
<div class="alert alert-info mb-0">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -109,11 +109,11 @@
|
|||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p class="mb-0">토큰은 검색하는 사용자를 확인하기 위한 임시 값이며 저장하지 않습니다. 회수되었거나 만료된 토큰은 사용할 수 없습니다.</p>
|
<p class="mb-0">토큰은 검색하는 사용자를 확인하기 위한 임시 값이며 저장하지 않습니다. 회수되었거나 만료된 토큰은 사용할 수 없습니다.</p>
|
||||||
</details>
|
</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}">
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
<label>Bearer 토큰
|
<label>업무 사용자
|
||||||
<input class="form-control" name="bearerToken" type="password" autocomplete="off" placeholder="발급된 임시 토큰을 붙여 넣으세요." required>
|
<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>
|
<span class="form-hint">선택한 사용자에게만 유효한 임시 토큰을 발급하고 검색 직후 회수합니다.</span>
|
||||||
</label>
|
</label>
|
||||||
<label>검색 질문
|
<label>검색 질문
|
||||||
<textarea class="form-control" name="query" rows="3" placeholder="예: 세일즈 파이프라인 후속 조치 기준" required></textarea>
|
<textarea class="form-control" name="query" rows="3" placeholder="예: 세일즈 파이프라인 후속 조치 기준" required></textarea>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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\"]");
|
||||||
|
}
|
||||||
|
}
|
||||||
21
docs/adr/0001-dds-mcp-service-identity.md
Normal file
21
docs/adr/0001-dds-mcp-service-identity.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# ADR-0001: DDS MCP Context에는 개인 IAM 사용자 대신 Confidential Application을 사용한다
|
||||||
|
|
||||||
|
> **상태**: Accepted · **날짜**: 2026-07-02 · **관련 이슈**: #617
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
OCI IAM Confidential Application의 client-credentials token을 database-access token으로 사용한다. MCP 요청자는 기존 opaque Bearer → `CB_APP_USER` 매핑으로 판정하고, 그 사용자를 local DDS `END USER`로 attach한다.
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
client-credentials token의 `client_id`/`sub`는 서비스 애플리케이션이다. 이를 업무 사용자로 사용하면 모든 MCP 요청이 같은 사람 권한으로 해석되는 오류가 생긴다. 서비스 승인과 업무 사용자 신원을 분리하면 기존 사용자·그룹·권한 관리 모델을 유지하면서 DDS가 사용자별 `DATA ROLE`/`DATA GRANT`를 집행한다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
ADB OCI IAM 설정, database credential, application identity, TLS 연결을 구성하고 실제 `EndUserSecurityContext` attach/query/clear와 SSE MCP `tools/call` HTTP 200을 확인했다.
|
||||||
|
|
||||||
|
## 결과
|
||||||
|
|
||||||
|
- 개인 IAM 계정·장기 token은 런타임에 사용하지 않는다.
|
||||||
|
- client ID/secret, raw token, lookup key는 secret store/환경 변수 외에 저장하지 않는다.
|
||||||
|
- IAM 사용자 token을 직접 받아 DDS에 전달하는 OBO/authorization-code 모델은 별도 ADR과 endpoint 검증으로만 도입한다.
|
||||||
110
docs/design/617-dds-mcp-end-user-context/README.md
Normal file
110
docs/design/617-dds-mcp-end-user-context/README.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# 설계서: DDS MCP 사용자별 END USER Context 및 권한 게시 (#617)
|
||||||
|
|
||||||
|
> **상태**: Implemented — OCI IAM·ADB·SSE 실증 완료
|
||||||
|
> **최종수정**: 2026-07-02
|
||||||
|
> **추적성** — Redmine: #617 · 관련 ADR: [ADR-0001](../../adr/0001-dds-mcp-service-identity.md)
|
||||||
|
> · 구현: `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. 결정과 목적
|
||||||
|
|
||||||
|
MCP Tool의 보호 SQL은 요청 Bearer가 지정한 업무 사용자의 local DDS `END USER` Context에서만 실행한다. DDS가 `DATA ROLE`과 `DATA GRANT`로 행·컬럼 접근을 집행하며, Tool은 권한 predicate를 직접 만들지 않는다.
|
||||||
|
|
||||||
|
VPD 경로는 변경하지 않는다. 기존 DDS 관리의 권한 게시 경계는 MCP용 END USER/역할/grant도 함께 갱신한다.
|
||||||
|
|
||||||
|
## 2. 토큰과 사용자 — 반드시 구분할 것
|
||||||
|
|
||||||
|
| 값 | 현재 구현에서의 역할 | 사람 사용자인가 |
|
||||||
|
|---|---|---|
|
||||||
|
| 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 인증 입력으로 사용하지 않음 |
|
||||||
|
|
||||||
|
따라서 현재 MCP Bearer가 `CB_APP_USER`를 지정할 수 있으면 그 사용자별 DDS 권한은 적용된다. 반면 client-credentials 토큰의 `sub`/`client_id`는 서비스 애플리케이션이므로 사람 사용자를 판정하는 데 사용하면 안 된다.
|
||||||
|
|
||||||
|
OCI IAM JWT의 사람 사용자 claim을 그대로 DDS에 전달하려면 별도의 authorization-code 또는 OBO flow, 해당 end-user token 검증, IAM role↔DDS DATA ROLE 매핑으로 확장해야 한다. 이 경로는 현재 local END USER 모델과 혼용하지 않는다.
|
||||||
|
|
||||||
|
## 3. 실제 아키텍처
|
||||||
|
|
||||||
|
```text
|
||||||
|
권한 관리 / 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 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, 연결 반환
|
||||||
|
```
|
||||||
|
|
||||||
|
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. 구현 경계
|
||||||
|
|
||||||
|
| 컴포넌트 | 책임 | 실패 처리 |
|
||||||
|
|---|---|---|
|
||||||
|
| `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 | 부분 실패를 게시 실패로 기록 |
|
||||||
|
|
||||||
|
Tool/Repository는 raw Bearer, client secret, lookup key 또는 `setEndUserSecurityContext`를 직접 다루지 않는다.
|
||||||
|
|
||||||
|
## 7. 검증 결과 (2026-07-02)
|
||||||
|
|
||||||
|
- [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를 같은 트랜잭션 경계에서 갱신하도록 통합한다.
|
||||||
|
|
||||||
|
SSE stream은 연결을 유지하므로 client read timeout이 날 수 있다. 이는 실패 판정 기준이 아니며, Tool RPC의 HTTP 결과와 attach/query/clear 로그로 성공을 판정한다.
|
||||||
|
|
||||||
|
## 8. 보안 불변식
|
||||||
|
|
||||||
|
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에 기록하지 않는다.
|
||||||
|
|
||||||
|
## 9. 참고
|
||||||
|
|
||||||
|
- [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)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 함수 명세: `authenticateMcpBearer` (#617)
|
||||||
|
|
||||||
|
> **상태**: Implemented · 구현: `DdsMcpBearerAuthenticator`
|
||||||
|
|
||||||
|
## 책임
|
||||||
|
|
||||||
|
MCP Tool 요청의 opaque Bearer를 활성 `CB_APP_USER`로 해석하고 해당 사용자의 게시된 local DDS principal을 반환한다. OCI IAM client-credentials token을 업무 사용자 token으로 해석하지 않는다.
|
||||||
|
|
||||||
|
## 입력과 출력
|
||||||
|
|
||||||
|
- 입력: `Authorization: Bearer <opaque-token>`
|
||||||
|
- 출력: `applicationUserId`, 표시 사용자명, `DDS_U_<id>`, data role 이름, lookup-key 참조
|
||||||
|
|
||||||
|
## 규칙
|
||||||
|
|
||||||
|
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 응답에 노출하지 않는다.
|
||||||
|
|
||||||
|
## 주의
|
||||||
|
|
||||||
|
현재 opaque Bearer의 사용자 매핑이 DDS 집행 주체를 결정한다. OCI IAM JWT의 사람 claim을 직접 사용하는 기능은 별도 OBO/authorization-code 확장이다.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 함수 명세: `compileDdsPublishPlan` (#617)
|
||||||
|
|
||||||
|
> **상태**: Draft · **분류**: 복잡 — 권한 컴파일·DDL diff·안전 검증
|
||||||
|
|
||||||
|
## 책임
|
||||||
|
|
||||||
|
DDS 백오피스의 보호 객체, 접근 역할, 테이블 세부 권한, 사용자/그룹 할당을 실행 가능한 DDS DDL diff로 바꾼다.
|
||||||
|
|
||||||
|
## 시그니처
|
||||||
|
|
||||||
|
`DdsAuthorizationDraft -> DdsPublishPlan`
|
||||||
|
|
||||||
|
## 규칙
|
||||||
|
|
||||||
|
1. 그룹을 포함해 역할별 유효 사용자를 계산한다.
|
||||||
|
2. `CB_APP_USER.userId`마다 ASCII `DDS_U_<id>` END USER 이름을 결정한다.
|
||||||
|
3. 접근 역할마다 하나의 stable DATA ROLE 이름을 결정한다.
|
||||||
|
4. 사용자·역할·객체·세부 권한의 현재 게시 상태와 목표 상태를 비교한다.
|
||||||
|
5. 생성/교체/회수 DDL과 예상 영향 사용자·객체를 계산한다.
|
||||||
|
6. 행 조건 DSL은 whitelist 기반 compiler만 사용한다. raw SQL은 거부한다.
|
||||||
|
7. `DENY` 또는 지원하지 않는 권한 조합은 plan 생성 단계에서 오류로 끝낸다.
|
||||||
|
|
||||||
|
## 출력
|
||||||
|
|
||||||
|
- END USER 생성/활성화/비활성화 목록
|
||||||
|
- DATA ROLE 생성/역할 부여/회수 목록
|
||||||
|
- DATA GRANT 생성·교체·삭제 목록
|
||||||
|
- 예상 영향 사용자·객체·컬럼 matrix
|
||||||
|
- 이전/목표 fingerprint와 승인 대상 diff
|
||||||
|
|
||||||
|
## 실패
|
||||||
|
|
||||||
|
충돌, 비활성 객체, DSL 오류, DENY 규칙, 이름 충돌은 게시 전에 차단한다.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 함수 명세: `getDatabaseAccessToken` (#617)
|
||||||
|
|
||||||
|
> **상태**: Implemented · 구현: `DdsMcpDatabaseAccessTokenProvider`
|
||||||
|
|
||||||
|
## 책임
|
||||||
|
|
||||||
|
OCI IAM Confidential Application의 client-credentials token을 database-access token으로 발급·캐시한다. 이 token은 local DDS Context attach의 서비스 승인용이며 MCP 업무 사용자를 식별하지 않는다.
|
||||||
|
|
||||||
|
## 규칙
|
||||||
|
|
||||||
|
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에 기록하지 않는다.
|
||||||
|
|
||||||
|
## 운영 검증
|
||||||
|
|
||||||
|
발급 token의 `resource_app_id`/`tenant_iss`/audience/scope가 ADB OCI IAM 설정 및 database resource와 일치해야 한다. ADB의 `OCI_IAM_DOMAIN_DB_CRED$`도 필수다.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 함수 명세: `publishDdsPlan` 및 `verifyDdsIsolation` (#617)
|
||||||
|
|
||||||
|
> **상태**: Draft · **분류**: 복잡 — 보안 DDL 변경·검증·드리프트
|
||||||
|
|
||||||
|
## 책임
|
||||||
|
|
||||||
|
승인된 DDL plan을 멱등 실행하고, dictionary와 representative END USER matrix로 실제 결과를 확인한다.
|
||||||
|
|
||||||
|
## 시그니처
|
||||||
|
|
||||||
|
- `DdsPublishPlan, Reason -> DdsPublishRun`
|
||||||
|
- `DdsPublishRun -> DdsVerificationResult`
|
||||||
|
|
||||||
|
## 게시 규칙
|
||||||
|
|
||||||
|
1. 승인 사유와 diff fingerprint가 없으면 게시하지 않는다.
|
||||||
|
2. Publisher lock으로 같은 보호 객체의 동시 게시를 막는다.
|
||||||
|
3. END USER/Data Role/Data Grant를 dependency 순서로 적용한다.
|
||||||
|
4. 각 DDL 결과와 Oracle 오류를 `DdsPublishRun`에 기록한다.
|
||||||
|
5. 중간 실패 시 성공으로 표시하지 않는다. 부분 적용 상태로 기록하고 drift로 승격한다.
|
||||||
|
|
||||||
|
## 검증 규칙
|
||||||
|
|
||||||
|
1. `DBA_END_USERS`, `DBA_DATA_ROLES`, `DBA_DATA_ROLE_GRANTS`, `DBA_DATA_GRANTS`를 목표 fingerprint와 비교한다.
|
||||||
|
2. 대표 사용자별 Context, 객체 접근, 행, 컬럼 결과를 검증한다.
|
||||||
|
3. 역할 제거와 객체 미권한 경로도 반드시 검증한다.
|
||||||
|
4. Context attach/clear 격리 검증이 실패하면 publish 결과를 정상으로 표시하지 않는다.
|
||||||
|
|
||||||
|
## 실패
|
||||||
|
|
||||||
|
게시·검증 실패는 `FAILED` 또는 `DRIFT` 상태다. MCP 요청 경로는 마지막 성공 게시 상태를 우회 권한으로 사용하지 않는다.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 함수 명세: `withDdsEndUserContext` (#617)
|
||||||
|
|
||||||
|
> **상태**: Implemented · 구현: `DdsMcpContextExecutor`
|
||||||
|
|
||||||
|
## 책임
|
||||||
|
|
||||||
|
한 Tool DB 작업을 정확히 하나의 local DDS END USER Context 안에서 실행하고 항상 해제한다.
|
||||||
|
|
||||||
|
## 알고리즘
|
||||||
|
|
||||||
|
1. 게시된 DDS principal을 확인한다.
|
||||||
|
2. OCI IAM database-access token을 얻고 공용 풀에서 연결을 획득한다.
|
||||||
|
3. `EndUserSecurityContext.createWithName(databaseAccessToken, endUserName, lookupKey)`를 만든다.
|
||||||
|
4. `setEndUserSecurityContext` 후 보호 SQL을 실행한다.
|
||||||
|
5. `finally`에서 `clearEndUserSecurityContext` 후 연결을 반환한다. clear 실패 연결은 폐기한다.
|
||||||
|
|
||||||
|
## 불변식
|
||||||
|
|
||||||
|
- local username+lookup-key Context에는 `withDataRoles(...)`를 전달하지 않는다.
|
||||||
|
- local END USER에 미리 `GRANT DATA ROLE`된 역할과 `DATA GRANT`만 적용한다.
|
||||||
|
- 연결 풀 재사용 시에도 이전 사용자 Context가 남지 않아야 한다.
|
||||||
|
|
||||||
|
## 실증
|
||||||
|
|
||||||
|
실제 ADB에서 local END USER attach, 보호 벡터 query 3행, context clear까지 완료했다. 동시 사용자 isolation regression은 후속 자동화 대상이다.
|
||||||
51
scripts/run-dds-mcp-local-end-user-scenario.sh
Executable file
51
scripts/run-dds-mcp-local-end-user-scenario.sh
Executable 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.'
|
||||||
244
sql/adb/44_dds_mcp_local_end_user_setup.sql
Normal file
244
sql/adb/44_dds_mcp_local_end_user_setup.sql
Normal 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;
|
||||||
59
sql/adb/45_dds_mcp_local_end_user_test.sql
Normal file
59
sql/adb/45_dds_mcp_local_end_user_test.sql
Normal 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;
|
||||||
@@ -23,12 +23,14 @@ public class SecurityConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return http
|
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
|
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
|
||||||
.includeSubDomains(true)
|
.includeSubDomains(true)
|
||||||
.maxAgeInSeconds(31_536_000)))
|
.maxAgeInSeconds(31_536_000)))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/css/**", "/js/**", "/webjars/**").permitAll()
|
.requestMatchers("/css/**", "/js/**", "/webjars/**", "/dds/mcp/sse", "/dds/mcp/messages")
|
||||||
|
.permitAll()
|
||||||
.anyRequest().authenticated())
|
.anyRequest().authenticated())
|
||||||
.httpBasic(basic -> {
|
.httpBasic(basic -> {
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import com.cloudhandson.vpdbackoffice.domain.group.GroupRoleView;
|
|||||||
import com.cloudhandson.vpdbackoffice.domain.group.GroupUserView;
|
import com.cloudhandson.vpdbackoffice.domain.group.GroupUserView;
|
||||||
import com.cloudhandson.vpdbackoffice.mapper.GroupMapper;
|
import com.cloudhandson.vpdbackoffice.mapper.GroupMapper;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -15,10 +16,21 @@ public class GroupService {
|
|||||||
|
|
||||||
private final GroupMapper groupMapper;
|
private final GroupMapper groupMapper;
|
||||||
private final AuditService auditService;
|
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.groupMapper = groupMapper;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
|
this.ddsAuthorizationChangeNotifier = ddsAuthorizationChangeNotifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GroupService(GroupMapper groupMapper, AuditService auditService) {
|
||||||
|
this(groupMapper, auditService, DdsAuthorizationChangeNotifier.noop());
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<AppGroup> findAll() {
|
public List<AppGroup> findAll() {
|
||||||
@@ -38,6 +50,7 @@ public class GroupService {
|
|||||||
long groupId = groupMapper.nextGroupId();
|
long groupId = groupMapper.nextGroupId();
|
||||||
groupMapper.insertGroup(groupId, command);
|
groupMapper.insertGroup(groupId, command);
|
||||||
auditService.record(new AuditEvent("GROUP_CREATED", null, null, "SUCCESS", null, null, command.groupCode()));
|
auditService.record(new AuditEvent("GROUP_CREATED", null, null, "SUCCESS", null, null, command.groupCode()));
|
||||||
|
ddsAuthorizationChangeNotifier.changed("GROUP_CREATED");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -56,6 +69,7 @@ public class GroupService {
|
|||||||
}
|
}
|
||||||
auditService.record(new AuditEvent("GROUP_ACTIVE_CHANGED", null, null, "SUCCESS", null, null,
|
auditService.record(new AuditEvent("GROUP_ACTIVE_CHANGED", null, null, "SUCCESS", null, null,
|
||||||
"groupId=" + groupId + ",active=" + active));
|
"groupId=" + groupId + ",active=" + active));
|
||||||
|
ddsAuthorizationChangeNotifier.changed("GROUP_ACTIVE_CHANGED");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -63,6 +77,7 @@ public class GroupService {
|
|||||||
groupMapper.insertGroupUser(groupId, userId);
|
groupMapper.insertGroupUser(groupId, userId);
|
||||||
auditService.record(new AuditEvent("GROUP_USER_ADDED", null, null, "SUCCESS", null, null,
|
auditService.record(new AuditEvent("GROUP_USER_ADDED", null, null, "SUCCESS", null, null,
|
||||||
"groupId=" + groupId + ",userId=" + userId));
|
"groupId=" + groupId + ",userId=" + userId));
|
||||||
|
ddsAuthorizationChangeNotifier.changed("GROUP_USER_ADDED");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -81,6 +96,7 @@ public class GroupService {
|
|||||||
}
|
}
|
||||||
auditService.record(new AuditEvent("GROUP_USER_REMOVED", null, null, "SUCCESS", null, null,
|
auditService.record(new AuditEvent("GROUP_USER_REMOVED", null, null, "SUCCESS", null, null,
|
||||||
"groupId=" + groupId + ",userId=" + userId));
|
"groupId=" + groupId + ",userId=" + userId));
|
||||||
|
ddsAuthorizationChangeNotifier.changed("GROUP_USER_REMOVED");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -88,6 +104,7 @@ public class GroupService {
|
|||||||
groupMapper.insertGroupRole(groupId, roleId);
|
groupMapper.insertGroupRole(groupId, roleId);
|
||||||
auditService.record(new AuditEvent("GROUP_ROLE_ADDED", null, null, "SUCCESS", null, null,
|
auditService.record(new AuditEvent("GROUP_ROLE_ADDED", null, null, "SUCCESS", null, null,
|
||||||
"groupId=" + groupId + ",roleId=" + roleId));
|
"groupId=" + groupId + ",roleId=" + roleId));
|
||||||
|
ddsAuthorizationChangeNotifier.changed("GROUP_ROLE_ADDED");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -106,5 +123,6 @@ public class GroupService {
|
|||||||
}
|
}
|
||||||
auditService.record(new AuditEvent("GROUP_ROLE_REMOVED", null, null, "SUCCESS", null, null,
|
auditService.record(new AuditEvent("GROUP_ROLE_REMOVED", null, null, "SUCCESS", null, null,
|
||||||
"groupId=" + groupId + ",roleId=" + roleId));
|
"groupId=" + groupId + ",roleId=" + roleId));
|
||||||
|
ddsAuthorizationChangeNotifier.changed("GROUP_ROLE_REMOVED");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import java.util.HashSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -32,15 +33,27 @@ public class PermissionService {
|
|||||||
private final PermissionMapper permissionMapper;
|
private final PermissionMapper permissionMapper;
|
||||||
private final ProtectedObjectService protectedObjectService;
|
private final ProtectedObjectService protectedObjectService;
|
||||||
private final AuditService auditService;
|
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(
|
public PermissionService(
|
||||||
PermissionMapper permissionMapper,
|
PermissionMapper permissionMapper,
|
||||||
ProtectedObjectService protectedObjectService,
|
ProtectedObjectService protectedObjectService,
|
||||||
AuditService auditService
|
AuditService auditService
|
||||||
) {
|
) {
|
||||||
this.permissionMapper = permissionMapper;
|
this(permissionMapper, protectedObjectService, auditService, DdsAuthorizationChangeNotifier.noop());
|
||||||
this.protectedObjectService = protectedObjectService;
|
|
||||||
this.auditService = auditService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<AppRole> findRoles() {
|
public List<AppRole> findRoles() {
|
||||||
@@ -64,6 +77,7 @@ public class PermissionService {
|
|||||||
long roleId = permissionMapper.nextRoleId();
|
long roleId = permissionMapper.nextRoleId();
|
||||||
permissionMapper.insertRole(roleId, roleName.trim(), description, normalizeSensitivityLevel(maxSensitivityLevel));
|
permissionMapper.insertRole(roleId, roleName.trim(), description, normalizeSensitivityLevel(maxSensitivityLevel));
|
||||||
auditService.record(new AuditEvent("ROLE_CREATED", null, null, "SUCCESS", null, null, roleName));
|
auditService.record(new AuditEvent("ROLE_CREATED", null, null, "SUCCESS", null, null, roleName));
|
||||||
|
ddsAuthorizationChangeNotifier.changed("ROLE_CREATED");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -75,6 +89,7 @@ public class PermissionService {
|
|||||||
}
|
}
|
||||||
auditService.record(new AuditEvent("ROLE_MAX_SENSITIVITY_UPDATED", null, null, "SUCCESS", null, null,
|
auditService.record(new AuditEvent("ROLE_MAX_SENSITIVITY_UPDATED", null, null, "SUCCESS", null, null,
|
||||||
"roleId=" + roleId + ", max=" + normalized));
|
"roleId=" + roleId + ", max=" + normalized));
|
||||||
|
ddsAuthorizationChangeNotifier.changed("ROLE_MAX_SENSITIVITY_UPDATED");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -100,6 +115,7 @@ public class PermissionService {
|
|||||||
throw new AppException("삭제할 역할을 찾을 수 없습니다.");
|
throw new AppException("삭제할 역할을 찾을 수 없습니다.");
|
||||||
}
|
}
|
||||||
auditService.record(new AuditEvent("ROLE_DELETED", null, null, "SUCCESS", null, null, "roleId=" + roleId));
|
auditService.record(new AuditEvent("ROLE_DELETED", null, null, "SUCCESS", null, null, "roleId=" + roleId));
|
||||||
|
ddsAuthorizationChangeNotifier.changed("ROLE_DELETED");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -148,6 +164,7 @@ public class PermissionService {
|
|||||||
"PERMISSION_SAVED", null, command.objectId(), "SUCCESS", null, null,
|
"PERMISSION_SAVED", null, command.objectId(), "SUCCESS", null, null,
|
||||||
"roleId=" + command.roleId()
|
"roleId=" + command.roleId()
|
||||||
));
|
));
|
||||||
|
ddsAuthorizationChangeNotifier.changed("PERMISSION_SAVED");
|
||||||
return new PermissionSet(permissionId, command.roleId(), command.objectId(), "SELECT", permissionEffect, List.of(), List.of());
|
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,
|
auditService.record(new AuditEvent("PERMISSION_DELETED", null, null, "SUCCESS", null, null,
|
||||||
"permissionId=" + permissionId));
|
"permissionId=" + permissionId));
|
||||||
|
ddsAuthorizationChangeNotifier.changed("PERMISSION_DELETED");
|
||||||
}
|
}
|
||||||
|
|
||||||
public int countPermissionsByObjectId(long objectId) {
|
public int countPermissionsByObjectId(long objectId) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.cloudhandson.vpdbackoffice.domain.user.UserCreateCommand;
|
|||||||
import com.cloudhandson.vpdbackoffice.domain.user.UserRoleView;
|
import com.cloudhandson.vpdbackoffice.domain.user.UserRoleView;
|
||||||
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -14,10 +15,21 @@ public class UserService {
|
|||||||
|
|
||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
private final AuditService auditService;
|
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.userMapper = userMapper;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
|
this.ddsAuthorizationChangeNotifier = ddsAuthorizationChangeNotifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserService(UserMapper userMapper, AuditService auditService) {
|
||||||
|
this(userMapper, auditService, DdsAuthorizationChangeNotifier.noop());
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<AppUser> findAll() {
|
public List<AppUser> findAll() {
|
||||||
@@ -33,6 +45,7 @@ public class UserService {
|
|||||||
long userId = userMapper.nextUserId();
|
long userId = userMapper.nextUserId();
|
||||||
userMapper.insertUser(userId, command);
|
userMapper.insertUser(userId, command);
|
||||||
auditService.record(new AuditEvent("USER_CREATED", null, null, "SUCCESS", null, null, command.username()));
|
auditService.record(new AuditEvent("USER_CREATED", null, null, "SUCCESS", null, null, command.username()));
|
||||||
|
ddsAuthorizationChangeNotifier.changed("USER_CREATED");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -43,6 +56,7 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
auditService.record(new AuditEvent("USER_ACTIVE_CHANGED", null, null, "SUCCESS", null, null,
|
auditService.record(new AuditEvent("USER_ACTIVE_CHANGED", null, null, "SUCCESS", null, null,
|
||||||
"userId=" + userId + ",active=" + active));
|
"userId=" + userId + ",active=" + active));
|
||||||
|
ddsAuthorizationChangeNotifier.changed("USER_ACTIVE_CHANGED");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -50,6 +64,7 @@ public class UserService {
|
|||||||
userMapper.insertUserRole(userId, roleId);
|
userMapper.insertUserRole(userId, roleId);
|
||||||
auditService.record(new AuditEvent("USER_ROLE_GRANTED", null, null, "SUCCESS", null, null,
|
auditService.record(new AuditEvent("USER_ROLE_GRANTED", null, null, "SUCCESS", null, null,
|
||||||
"userId=" + userId + ",roleId=" + roleId));
|
"userId=" + userId + ",roleId=" + roleId));
|
||||||
|
ddsAuthorizationChangeNotifier.changed("USER_ROLE_GRANTED");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -60,5 +75,6 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
auditService.record(new AuditEvent("USER_ROLE_REVOKED", null, null, "SUCCESS", null, null,
|
auditService.record(new AuditEvent("USER_ROLE_REVOKED", null, null, "SUCCESS", null, null,
|
||||||
"userId=" + userId + ",roleId=" + roleId));
|
"userId=" + userId + ",roleId=" + roleId));
|
||||||
|
ddsAuthorizationChangeNotifier.changed("USER_ROLE_REVOKED");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user