feat: align DDS with common permission and vector flow
This commit is contained in:
@@ -3,26 +3,33 @@
|
|||||||
`dds-backoffice`는 기존 VPD 데모와 코드·프로세스·포트·보호 객체를 분리한 Deep Data Security 전용 데모입니다.
|
`dds-backoffice`는 기존 VPD 데모와 코드·프로세스·포트·보호 객체를 분리한 Deep Data Security 전용 데모입니다.
|
||||||
|
|
||||||
- 기본 포트: `8083`
|
- 기본 포트: `8083`
|
||||||
- 기본 화면: `/` 또는 `/dds`
|
- 기본 화면: `/` (공통 권한 여정), `/dds` (DDS 직접 조회 검증)
|
||||||
- 기본 보호 객체: `ADMIN.V_DDS_CUSTOMERS_PG`, `ADMIN.V_DDS_CUSTOMERS_MY`
|
- 기본 보호 객체: `ADMIN.V_DDS_CUSTOMERS_PG`, `ADMIN.V_DDS_CUSTOMERS_MY`
|
||||||
|
- 지식 검색 보호 객체: `ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS`
|
||||||
- DDS 전용 사용자: `dds_demo_my`, `dds_demo_pg`, `dds_demo_both`, `dds_demo_none`
|
- DDS 전용 사용자: `dds_demo_my`, `dds_demo_pg`, `dds_demo_both`, `dds_demo_none`
|
||||||
- 조회 방식: 선택한 DDS `END USER` 자격으로 직접 Oracle JDBC 연결
|
- 조회 방식: 선택한 DDS `END USER` 자격으로 직접 Oracle JDBC 연결
|
||||||
|
|
||||||
## 두 데모의 관계
|
## 두 데모의 관계
|
||||||
|
|
||||||
VPD와 DDS는 같은 접근 결과를 비교하기 위한 별도 데모입니다. VPD 인스턴스는 공통 DB 계정으로 접속한 뒤 Bearer 사용자와 권한 테이블을 세션 컨텍스트에 저장하고 VPD 함수가 조건을 계산합니다. 이 앱은 그 경로와 VPD 객체를 재사용하지 않습니다.
|
두 인스턴스는 프로세스·포트·실행 경계를 분리하지만, 화면에서 설명하는 관리 순서는 같습니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
사용자·그룹·역할 → 데이터 권한 규칙 → 보호 연결 → 실제 결과 확인
|
||||||
|
```
|
||||||
|
|
||||||
|
8083 DDS 화면의 `/users`, `/groups`, `/roles`, `/permissions`, `/effective-matrix`는 8082 VPD 화면과 같은 관리 기준을 보여줍니다. 다만 보호를 실행하는 DB 계층만 다릅니다. VPD 인스턴스는 공통 DB 계정과 Bearer 컨텍스트로 동적 predicate를 계산하고, DDS 인스턴스는 DDS `END USER`의 DATA ROLE/DATA GRANT로 직접 집행합니다.
|
||||||
|
|
||||||
| 데모 | 사용자 식별 | 정책 표현 | 권한 변경 |
|
| 데모 | 사용자 식별 | 정책 표현 | 권한 변경 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 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는 행·컬럼·작업 제한이라는 결과는 만들 수 있지만, VPD의 Bearer 기반 동적 predicate 실행 모델과 동일한 대체재는 아닙니다.
|
따라서 두 인스턴스의 관리 흐름과 기대 결과는 맞출 수 있지만, Bearer 요청마다 권한을 계산하는 VPD 실행 모델을 DDS가 그대로 대체하는 것은 아닙니다. DDS로 Bearer 기반 호출을 운영하려면 지원 드라이버의 `EndUserSecurityContext` 전파가 별도로 필요합니다.
|
||||||
|
|
||||||
이 앱의 조회 경계는 다음과 같습니다.
|
이 앱의 조회 경계는 다음과 같습니다.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
END USER → DATA ROLE → DATA GRANT → V_DDS_CUSTOMERS_* → 조회 결과
|
END USER → DATA ROLE → DATA GRANT → DDS 보호 VIEW → 조회 결과
|
||||||
```
|
```
|
||||||
|
|
||||||
ORDS Handler가 Bearer 값을 `cb_dds_hr` 같은 문자열로 매핑하는 것만으로는 DDS 보안 컨텍스트가 전달되지 않습니다. 따라서 이 비교 인스턴스는 DDS `END USER` 직접 logon을 검증합니다. Bearer/ORDS 전달이 필요하면 지원 드라이버의 `EndUserSecurityContext`를 별도 설계해야 합니다.
|
ORDS Handler가 Bearer 값을 `cb_dds_hr` 같은 문자열로 매핑하는 것만으로는 DDS 보안 컨텍스트가 전달되지 않습니다. 따라서 이 비교 인스턴스는 DDS `END USER` 직접 logon을 검증합니다. Bearer/ORDS 전달이 필요하면 지원 드라이버의 `EndUserSecurityContext`를 별도 설계해야 합니다.
|
||||||
@@ -33,8 +40,11 @@ ORDS Handler가 Bearer 값을 `cb_dds_hr` 같은 문자열로 매핑하는 것
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/31_dds_standalone_demo_setup.sql
|
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/31_dds_standalone_demo_setup.sql
|
||||||
|
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/32_dds_vector_tag_setup.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`32_dds_vector_tag_setup.sql`은 공통 청크·태그 저장소를 읽는 DDS 전용 VIEW를 만들고, `SPRING_BOOT`, `ORDS`, `ORACLE_VPD`, `MCP` 같은 기술 태그를 DATA GRANT의 `WHERE` 조건으로 연결합니다. 검색 화면에서는 같은 문서를 청킹·임베딩한 뒤 선택한 END USER로 직접 조회하므로, DDS grant가 없는 주체는 보호 VIEW 자체를 볼 수 없습니다.
|
||||||
|
|
||||||
그 다음 DDS 인스턴스를 실행합니다.
|
그 다음 DDS 인스턴스를 실행합니다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -23,6 +23,11 @@
|
|||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.cloudhandson</groupId>
|
||||||
|
<artifactId>vpd-permission-backoffice</artifactId>
|
||||||
|
<version>0.1.0-SNAPSHOT</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
package com.cloudhandson.ddsbackoffice;
|
package com.cloudhandson.ddsbackoffice;
|
||||||
|
|
||||||
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
||||||
|
import com.cloudhandson.vpdbackoffice.VpdBackofficeApplication;
|
||||||
|
import com.cloudhandson.vpdbackoffice.web.DashboardController;
|
||||||
|
import com.cloudhandson.vpdbackoffice.web.LoginController;
|
||||||
|
import com.cloudhandson.vpdbackoffice.web.VectorKnowledgeController;
|
||||||
|
import com.cloudhandson.vpdbackoffice.web.VpdPolicyController;
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
import org.springframework.context.annotation.FilterType;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableConfigurationProperties(DdsProperties.class)
|
@EnableConfigurationProperties(DdsProperties.class)
|
||||||
|
@MapperScan("com.cloudhandson.vpdbackoffice.mapper")
|
||||||
|
@ComponentScan(
|
||||||
|
basePackages = {"com.cloudhandson.ddsbackoffice", "com.cloudhandson.vpdbackoffice"},
|
||||||
|
excludeFilters = @ComponentScan.Filter(
|
||||||
|
type = FilterType.ASSIGNABLE_TYPE,
|
||||||
|
classes = {
|
||||||
|
DdsBackofficeApplication.class,
|
||||||
|
VpdBackofficeApplication.class,
|
||||||
|
com.cloudhandson.ddsbackoffice.config.AppConfig.class,
|
||||||
|
com.cloudhandson.ddsbackoffice.config.SecurityConfig.class,
|
||||||
|
DashboardController.class,
|
||||||
|
LoginController.class,
|
||||||
|
VectorKnowledgeController.class,
|
||||||
|
VpdPolicyController.class
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
public class DdsBackofficeApplication {
|
public class DdsBackofficeApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.boot.context.properties.bind.ConstructorBinding;
|
||||||
|
|
||||||
@ConfigurationProperties(prefix = "dds")
|
@ConfigurationProperties(prefix = "dds")
|
||||||
public record DdsProperties(
|
public record DdsProperties(
|
||||||
@@ -12,6 +13,7 @@ public record DdsProperties(
|
|||||||
Duration queryTimeout,
|
Duration queryTimeout,
|
||||||
String pgObject,
|
String pgObject,
|
||||||
String myObject,
|
String myObject,
|
||||||
|
String vectorObject,
|
||||||
Map<String, User> users
|
Map<String, User> users
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -19,11 +21,13 @@ public record DdsProperties(
|
|||||||
"[A-Za-z][A-Za-z0-9_$#]*(\\.[A-Za-z][A-Za-z0-9_$#]*)*"
|
"[A-Za-z][A-Za-z0-9_$#]*(\\.[A-Za-z][A-Za-z0-9_$#]*)*"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ConstructorBinding
|
||||||
public DdsProperties {
|
public DdsProperties {
|
||||||
dbUrl = dbUrl == null ? "" : dbUrl.trim();
|
dbUrl = dbUrl == null ? "" : dbUrl.trim();
|
||||||
queryTimeout = queryTimeout == null ? Duration.ofSeconds(10) : queryTimeout;
|
queryTimeout = queryTimeout == null ? Duration.ofSeconds(10) : queryTimeout;
|
||||||
pgObject = normalizeObject(pgObject, "ADMIN.V_DDS_CUSTOMERS_PG");
|
pgObject = normalizeObject(pgObject, "ADMIN.V_DDS_CUSTOMERS_PG");
|
||||||
myObject = normalizeObject(myObject, "ADMIN.V_DDS_CUSTOMERS_MY");
|
myObject = normalizeObject(myObject, "ADMIN.V_DDS_CUSTOMERS_MY");
|
||||||
|
vectorObject = normalizeObject(vectorObject, "ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS");
|
||||||
var normalizedUsers = new LinkedHashMap<String, User>();
|
var normalizedUsers = new LinkedHashMap<String, User>();
|
||||||
if (users != null) {
|
if (users != null) {
|
||||||
users.forEach((key, value) -> {
|
users.forEach((key, value) -> {
|
||||||
@@ -35,6 +39,22 @@ public record DdsProperties(
|
|||||||
users = Map.copyOf(normalizedUsers);
|
users = Map.copyOf(normalizedUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compatibility constructor for small unit tests and older local configs.
|
||||||
|
* The DDS vector view has a safe default and can still be overridden by
|
||||||
|
* {@code DDS_BACKOFFICE_VECTOR_OBJECT}.
|
||||||
|
*/
|
||||||
|
public DdsProperties(
|
||||||
|
String dbUrl,
|
||||||
|
Duration queryTimeout,
|
||||||
|
String pgObject,
|
||||||
|
String myObject,
|
||||||
|
Map<String, User> users
|
||||||
|
) {
|
||||||
|
this(dbUrl, queryTimeout, pgObject, myObject,
|
||||||
|
"ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS", users);
|
||||||
|
}
|
||||||
|
|
||||||
public String objectFor(String sourceKey) {
|
public String objectFor(String sourceKey) {
|
||||||
if ("PG".equalsIgnoreCase(sourceKey)) {
|
if ("PG".equalsIgnoreCase(sourceKey)) {
|
||||||
return pgObject;
|
return pgObject;
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Result of a vector search executed through a DDS END USER connection. */
|
||||||
|
public record DdsVectorSearchResult(
|
||||||
|
String userKey,
|
||||||
|
String userLabel,
|
||||||
|
String objectName,
|
||||||
|
String query,
|
||||||
|
String embeddingMode,
|
||||||
|
String embeddingModel,
|
||||||
|
boolean success,
|
||||||
|
String title,
|
||||||
|
String message,
|
||||||
|
String sessionUser,
|
||||||
|
String endUser,
|
||||||
|
Integer oracleCode,
|
||||||
|
List<Map<String, Object>> rows
|
||||||
|
) {
|
||||||
|
|
||||||
|
public DdsVectorSearchResult {
|
||||||
|
rows = rows == null ? List.of() : List.copyOf(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasRows() {
|
||||||
|
return !rows.isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,10 @@ public class DdsQueryService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String vectorObject() {
|
||||||
|
return properties.vectorObject();
|
||||||
|
}
|
||||||
|
|
||||||
public DdsQueryResult query(String userKey, String sourceKey, String searchText, int requestedLimit) {
|
public DdsQueryResult query(String userKey, String sourceKey, String searchText, int requestedLimit) {
|
||||||
var normalizedUserKey = normalizeUserKey(userKey);
|
var normalizedUserKey = normalizeUserKey(userKey);
|
||||||
var normalizedSourceKey = normalizeSourceKey(sourceKey);
|
var normalizedSourceKey = normalizeSourceKey(sourceKey);
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.service;
|
||||||
|
|
||||||
|
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
||||||
|
import com.cloudhandson.ddsbackoffice.domain.DdsVectorSearchResult;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestCommand;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestResult;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.vector.VectorKnowledgeSummary;
|
||||||
|
import com.cloudhandson.vpdbackoffice.service.AppException;
|
||||||
|
import com.cloudhandson.vpdbackoffice.service.OpenAiCompatibleClient;
|
||||||
|
import com.cloudhandson.vpdbackoffice.service.VectorKnowledgeService;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Properties;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DDS version of the common knowledge flow.
|
||||||
|
*
|
||||||
|
* Ingestion still writes the shared chunk/tag store. Search deliberately does
|
||||||
|
* not call the VPD ORDS probe: it opens a DDS END USER connection and lets the
|
||||||
|
* DATA ROLE/DATA GRANT on the DDS-only view decide which chunks are visible.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class DdsVectorKnowledgeService {
|
||||||
|
|
||||||
|
private static final String DEMO_MODE = "DEMO";
|
||||||
|
private static final String AI_MODE = "AI";
|
||||||
|
private static final Pattern USER_KEY = Pattern.compile("[a-z0-9_-]{1,40}");
|
||||||
|
|
||||||
|
private final VectorKnowledgeService commonVectorService;
|
||||||
|
private final OpenAiCompatibleClient embeddingClient;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final DdsProperties properties;
|
||||||
|
|
||||||
|
public DdsVectorKnowledgeService(
|
||||||
|
VectorKnowledgeService commonVectorService,
|
||||||
|
OpenAiCompatibleClient embeddingClient,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
DdsProperties properties
|
||||||
|
) {
|
||||||
|
this.commonVectorService = commonVectorService;
|
||||||
|
this.embeddingClient = embeddingClient;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public VectorKnowledgeSummary summary() {
|
||||||
|
return commonVectorService.summary();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean aiEmbeddingConfigured() {
|
||||||
|
return commonVectorService.aiEmbeddingConfigured();
|
||||||
|
}
|
||||||
|
|
||||||
|
public VectorIngestResult ingest(VectorIngestCommand command) {
|
||||||
|
return commonVectorService.ingest(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DdsVectorSearchResult search(String userKey, String query, int requestedLimit, String embeddingMode) {
|
||||||
|
String normalizedUserKey = normalizeUserKey(userKey);
|
||||||
|
var user = properties.users().get(normalizedUserKey);
|
||||||
|
String userLabel = user == null || user.label() == null || user.label().isBlank()
|
||||||
|
? normalizedUserKey : user.label();
|
||||||
|
String normalizedQuery = required(query, "검색 질문");
|
||||||
|
String mode = normalizeMode(embeddingMode);
|
||||||
|
|
||||||
|
if (user == null) {
|
||||||
|
return failure(normalizedUserKey, userLabel, normalizedQuery, mode,
|
||||||
|
"DDS END USER를 찾을 수 없습니다.", "설정에 등록된 DDS 보안 주체만 선택할 수 있습니다.", null);
|
||||||
|
}
|
||||||
|
if (properties.dbUrl().isBlank()) {
|
||||||
|
return failure(normalizedUserKey, userLabel, normalizedQuery, mode,
|
||||||
|
"DDS 데이터베이스 연결 정보가 없습니다.",
|
||||||
|
"DDS_BACKOFFICE_DB_URL 또는 BACKOFFICE_DB_URL을 설정하세요.", null);
|
||||||
|
}
|
||||||
|
if (!user.configured()) {
|
||||||
|
return failure(normalizedUserKey, userLabel, normalizedQuery, mode,
|
||||||
|
"DDS END USER 자격증명이 설정되지 않았습니다.",
|
||||||
|
"이 인스턴스의 환경 변수에 해당 END USER 비밀번호를 설정하세요.", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
int limit = Math.max(1, Math.min(requestedLimit, 100));
|
||||||
|
int timeoutSeconds = timeoutSeconds(properties.queryTimeout());
|
||||||
|
String vectorJson = json(embed(normalizedQuery, mode));
|
||||||
|
Properties connectionProperties = new Properties();
|
||||||
|
connectionProperties.setProperty("user", jdbcUsername(user.username()));
|
||||||
|
connectionProperties.setProperty("password", user.password());
|
||||||
|
connectionProperties.setProperty("oracle.net.CONNECT_TIMEOUT", String.valueOf(timeoutSeconds * 1000));
|
||||||
|
connectionProperties.setProperty("oracle.jdbc.ReadTimeout", String.valueOf(timeoutSeconds * 1000));
|
||||||
|
|
||||||
|
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 " + properties.vectorObject() + " d "
|
||||||
|
+ "WHERE d.embedding IS NOT NULL ORDER BY score) ranked_chunks WHERE ROWNUM <= ?";
|
||||||
|
|
||||||
|
try (Connection connection = java.sql.DriverManager.getConnection(properties.dbUrl(), connectionProperties)) {
|
||||||
|
connection.setReadOnly(true);
|
||||||
|
String sessionUser = readSingleValue(connection,
|
||||||
|
"SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM dual", timeoutSeconds);
|
||||||
|
String endUser = readSingleValue(connection,
|
||||||
|
"SELECT ORA_END_USER_CONTEXT.username FROM dual", timeoutSeconds);
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
|
statement.setQueryTimeout(timeoutSeconds);
|
||||||
|
statement.setString(1, vectorJson);
|
||||||
|
statement.setInt(2, limit);
|
||||||
|
List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
|
try (ResultSet resultSet = statement.executeQuery()) {
|
||||||
|
while (resultSet.next()) {
|
||||||
|
Map<String, Object> row = new LinkedHashMap<>();
|
||||||
|
row.put("CHUNK_ID", resultSet.getLong("chunk_id"));
|
||||||
|
row.put("DOCUMENT_ID", resultSet.getString("document_id"));
|
||||||
|
row.put("CHUNK_NO", resultSet.getInt("chunk_no"));
|
||||||
|
row.put("TITLE", resultSet.getString("title"));
|
||||||
|
row.put("CHUNK_TEXT", resultSet.getString("chunk_text"));
|
||||||
|
row.put("SOURCE_URI", resultSet.getString("source_uri"));
|
||||||
|
row.put("TECH_TAG", resultSet.getString("tech_tag"));
|
||||||
|
row.put("SCORE", resultSet.getObject("score"));
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String message = rows.isEmpty()
|
||||||
|
? "DDS DATA GRANT를 통과한 검색 단위가 없습니다."
|
||||||
|
: rows.size() + "개 검색 단위가 DDS DATA GRANT를 통과했습니다.";
|
||||||
|
return new DdsVectorSearchResult(
|
||||||
|
normalizedUserKey, userLabel, properties.vectorObject(), normalizedQuery, mode,
|
||||||
|
embeddingModel(mode), true, "DDS 권한으로 검색했습니다.", message,
|
||||||
|
sessionUser, endUser, null, rows);
|
||||||
|
}
|
||||||
|
} catch (SQLException exception) {
|
||||||
|
return failure(normalizedUserKey, userLabel, normalizedQuery, mode,
|
||||||
|
classifyTitle(exception), classifyMessage(exception), oracleCode(exception));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DdsVectorSearchResult failure(
|
||||||
|
String userKey,
|
||||||
|
String userLabel,
|
||||||
|
String query,
|
||||||
|
String mode,
|
||||||
|
String title,
|
||||||
|
String message,
|
||||||
|
Integer oracleCode
|
||||||
|
) {
|
||||||
|
return new DdsVectorSearchResult(
|
||||||
|
userKey, userLabel, properties.vectorObject(), query, mode, embeddingModel(mode),
|
||||||
|
false, title, message, null, null, oracleCode, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String readSingleValue(Connection connection, String sql, int timeoutSeconds)
|
||||||
|
throws SQLException {
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
|
statement.setQueryTimeout(timeoutSeconds);
|
||||||
|
try (ResultSet resultSet = statement.executeQuery()) {
|
||||||
|
return resultSet.next() ? resultSet.getString(1) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Double> embed(String input, String mode) {
|
||||||
|
if (AI_MODE.equals(mode)) {
|
||||||
|
return embeddingClient.embedding(input);
|
||||||
|
}
|
||||||
|
return demoEmbedding(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Double> demoEmbedding(String input) {
|
||||||
|
double[] vector = new double[4];
|
||||||
|
String normalized = input.toLowerCase(Locale.ROOT);
|
||||||
|
addIfContains(vector, normalized, 0, "spring", "boot", "java", "security");
|
||||||
|
addIfContains(vector, normalized, 1, "oracle", "vpd", "policy", "database", "db");
|
||||||
|
addIfContains(vector, normalized, 2, "ords", "rest", "handler", "http", "api");
|
||||||
|
addIfContains(vector, normalized, 3, "mcp", "tool", "권한", "tag", "태그");
|
||||||
|
for (String token : normalized.split("[^a-z0-9가-힣]+")) {
|
||||||
|
if (token.length() >= 3) {
|
||||||
|
vector[Math.floorMod(token.hashCode(), vector.length)] += 0.03;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
double length = 0;
|
||||||
|
for (double value : vector) {
|
||||||
|
length += value * value;
|
||||||
|
}
|
||||||
|
if (length == 0) {
|
||||||
|
vector[0] = 1;
|
||||||
|
length = 1;
|
||||||
|
}
|
||||||
|
double scale = Math.sqrt(length);
|
||||||
|
List<Double> result = new ArrayList<>(vector.length);
|
||||||
|
for (double value : vector) {
|
||||||
|
result.add(value / scale);
|
||||||
|
}
|
||||||
|
return List.copyOf(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addIfContains(double[] vector, String input, int index, String... terms) {
|
||||||
|
for (String term : terms) {
|
||||||
|
if (input.contains(term)) {
|
||||||
|
vector[index] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeUserKey(String value) {
|
||||||
|
String normalized = value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||||
|
if (!USER_KEY.matcher(normalized).matches()) {
|
||||||
|
throw new IllegalArgumentException("지원하지 않는 DDS END USER 선택값입니다.");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeMode(String value) {
|
||||||
|
String normalized = value == null ? DEMO_MODE : value.trim().toUpperCase(Locale.ROOT);
|
||||||
|
if (!DEMO_MODE.equals(normalized) && !AI_MODE.equals(normalized)) {
|
||||||
|
throw new AppException("임베딩 방식은 로컬 임베딩 또는 AI 임베딩만 사용할 수 있습니다.");
|
||||||
|
}
|
||||||
|
if (AI_MODE.equals(normalized) && !embeddingClient.embeddingConfigured()) {
|
||||||
|
throw new AppException("AI 임베딩이 설정되지 않았습니다. 설정에서 임베딩 모델/API key를 추가하세요.");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String embeddingModel(String mode) {
|
||||||
|
return AI_MODE.equals(mode) ? embeddingClient.embeddingModelName() : "로컬 임베딩(개발용)";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String json(List<Double> values) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(values);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new AppException("임베딩 JSON 생성에 실패했습니다: " + exception.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int timeoutSeconds(Duration duration) {
|
||||||
|
return Math.max(1, (int) Math.ceil(duration.toMillis() / 1000.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String jdbcUsername(String username) {
|
||||||
|
String normalized = username == null ? "" : username.trim();
|
||||||
|
if (normalized.length() >= 2 && normalized.startsWith("\"") && normalized.endsWith("\"")) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
if (!normalized.isEmpty() && normalized.equals(normalized.toLowerCase(Locale.ROOT))) {
|
||||||
|
return "\"" + normalized.replace("\"", "\"\"") + "\"";
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String required(String value, String label) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new AppException(label + "을 입력하세요.");
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer oracleCode(SQLException exception) {
|
||||||
|
SQLException current = exception;
|
||||||
|
while (current != null) {
|
||||||
|
if (current.getErrorCode() != 0) {
|
||||||
|
return Math.abs(current.getErrorCode());
|
||||||
|
}
|
||||||
|
current = current.getNextException();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String flattenedMessage(SQLException exception) {
|
||||||
|
StringBuilder messages = new StringBuilder();
|
||||||
|
SQLException current = exception;
|
||||||
|
while (current != null) {
|
||||||
|
if (messages.length() > 0) {
|
||||||
|
messages.append("; ");
|
||||||
|
}
|
||||||
|
messages.append(Objects.toString(current.getMessage(), ""));
|
||||||
|
current = current.getNextException();
|
||||||
|
}
|
||||||
|
return messages.toString().replaceAll("\\s+", " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String classifyTitle(SQLException exception) {
|
||||||
|
String message = flattenedMessage(exception).toUpperCase(Locale.ROOT);
|
||||||
|
if (message.contains("ORA-00942")) {
|
||||||
|
return "이 DDS END USER에게 지식자료가 허용되지 않았습니다.";
|
||||||
|
}
|
||||||
|
if (message.contains("ORA-01017")) {
|
||||||
|
return "DDS END USER 인증에 실패했습니다.";
|
||||||
|
}
|
||||||
|
if (message.contains("CONNECTION") || message.contains("ORA-12154")
|
||||||
|
|| message.contains("ORA-12514") || message.contains("IO ERROR")) {
|
||||||
|
return "DDS 데이터베이스에 연결할 수 없습니다.";
|
||||||
|
}
|
||||||
|
return "DDS 벡터 검색 중 데이터베이스 오류가 발생했습니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String classifyMessage(SQLException exception) {
|
||||||
|
String message = flattenedMessage(exception);
|
||||||
|
String upper = message.toUpperCase(Locale.ROOT);
|
||||||
|
if (upper.contains("ORA-00942")) {
|
||||||
|
return "END USER의 DATA ROLE에 이 DDS 벡터 VIEW를 대상으로 한 DATA GRANT가 없습니다. default deny 결과입니다.";
|
||||||
|
}
|
||||||
|
if (upper.contains("ORA-01017")) {
|
||||||
|
return "서버에 등록된 DDS END USER 비밀번호를 확인하세요.";
|
||||||
|
}
|
||||||
|
return "DDS 벡터 VIEW, DATA ROLE/GRANT, DB 접속 정보를 확인하세요.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ package com.cloudhandson.ddsbackoffice.web;
|
|||||||
|
|
||||||
import com.cloudhandson.ddsbackoffice.domain.DdsQueryResult;
|
import com.cloudhandson.ddsbackoffice.domain.DdsQueryResult;
|
||||||
import com.cloudhandson.ddsbackoffice.service.DdsQueryService;
|
import com.cloudhandson.ddsbackoffice.service.DdsQueryService;
|
||||||
|
import com.cloudhandson.vpdbackoffice.service.PermissionService;
|
||||||
|
import java.util.List;
|
||||||
|
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;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -12,12 +15,29 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
public class DdsController {
|
public class DdsController {
|
||||||
|
|
||||||
private final DdsQueryService service;
|
private final DdsQueryService service;
|
||||||
|
private final PermissionService permissionService;
|
||||||
|
|
||||||
public DdsController(DdsQueryService service) {
|
public DdsController(DdsQueryService service, PermissionService permissionService) {
|
||||||
this.service = service;
|
this.service = service;
|
||||||
|
this.permissionService = permissionService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping({"/", "/dds"})
|
@GetMapping("/")
|
||||||
|
public String home(Model model) {
|
||||||
|
try {
|
||||||
|
model.addAttribute("roles", permissionService.findRoles());
|
||||||
|
model.addAttribute("permissions", permissionService.findPermissionViews());
|
||||||
|
} catch (DataAccessException exception) {
|
||||||
|
model.addAttribute("roles", List.of());
|
||||||
|
model.addAttribute("permissions", List.of());
|
||||||
|
model.addAttribute("runtimeError", exception.getMessage());
|
||||||
|
}
|
||||||
|
model.addAttribute("vectorObject", service.vectorObject());
|
||||||
|
model.addAttribute("configuredUserCount", service.users().stream().filter(user -> user.configured()).count());
|
||||||
|
return "dds-home";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/dds")
|
||||||
public String page(Model model) {
|
public String page(Model model) {
|
||||||
addOptions(model, "both", "PG", null);
|
addOptions(model, "both", "PG", null);
|
||||||
return "dds";
|
return "dds";
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.web;
|
||||||
|
|
||||||
|
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
||||||
|
import com.cloudhandson.ddsbackoffice.service.DdsQueryService;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionView;
|
||||||
|
import com.cloudhandson.vpdbackoffice.service.PermissionService;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.dao.DataAccessException;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.servlet.view.RedirectView;
|
||||||
|
|
||||||
|
/** DDS equivalent of the VPD protection-connection step. */
|
||||||
|
@Controller
|
||||||
|
public class DdsProtectionController {
|
||||||
|
|
||||||
|
private static final String MANAGEMENT_OBJECT = "CB_VECTOR_SEARCH_DOCUMENTS";
|
||||||
|
|
||||||
|
private final PermissionService permissionService;
|
||||||
|
private final DdsProperties properties;
|
||||||
|
private final DdsQueryService queryService;
|
||||||
|
|
||||||
|
public DdsProtectionController(
|
||||||
|
PermissionService permissionService,
|
||||||
|
DdsProperties properties,
|
||||||
|
DdsQueryService queryService
|
||||||
|
) {
|
||||||
|
this.permissionService = permissionService;
|
||||||
|
this.properties = properties;
|
||||||
|
this.queryService = queryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/vpd-policies")
|
||||||
|
public String page(Model model) {
|
||||||
|
model.addAttribute("ddsVectorObject", properties.vectorObject());
|
||||||
|
model.addAttribute("managementObject", MANAGEMENT_OBJECT);
|
||||||
|
model.addAttribute("ddsUsers", queryService.users());
|
||||||
|
try {
|
||||||
|
List<PermissionView> permissions = permissionService.findPermissionViews().stream()
|
||||||
|
.filter(permission -> MANAGEMENT_OBJECT.equalsIgnoreCase(permission.objectName()))
|
||||||
|
.toList();
|
||||||
|
model.addAttribute("permissions", permissions);
|
||||||
|
} catch (DataAccessException exception) {
|
||||||
|
model.addAttribute("permissions", List.of());
|
||||||
|
model.addAttribute("runtimeError", exception.getMessage());
|
||||||
|
}
|
||||||
|
return "vpd-policies";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/vpd-filter-policies")
|
||||||
|
public RedirectView filterPolicies() {
|
||||||
|
return new RedirectView("/vpd-policies");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.web;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
|
|
||||||
|
@ControllerAdvice
|
||||||
|
public class DdsTrackModelAdvice {
|
||||||
|
|
||||||
|
@ModelAttribute("backofficeTrack")
|
||||||
|
public String backofficeTrack() {
|
||||||
|
return "DDS";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.web;
|
||||||
|
|
||||||
|
import com.cloudhandson.ddsbackoffice.service.DdsQueryService;
|
||||||
|
import com.cloudhandson.ddsbackoffice.service.DdsVectorKnowledgeService;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestCommand;
|
||||||
|
import com.cloudhandson.vpdbackoffice.service.AppException;
|
||||||
|
import org.springframework.dao.DataAccessException;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class DdsVectorKnowledgeController {
|
||||||
|
|
||||||
|
private final DdsVectorKnowledgeService service;
|
||||||
|
private final DdsQueryService ddsQueryService;
|
||||||
|
|
||||||
|
public DdsVectorKnowledgeController(
|
||||||
|
DdsVectorKnowledgeService service,
|
||||||
|
DdsQueryService ddsQueryService
|
||||||
|
) {
|
||||||
|
this.service = service;
|
||||||
|
this.ddsQueryService = ddsQueryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/vector-knowledge")
|
||||||
|
public String page(Model model) {
|
||||||
|
populatePage(model);
|
||||||
|
return "vector-knowledge";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/vector-knowledge/ingest")
|
||||||
|
public String ingest(
|
||||||
|
@RequestParam String documentId,
|
||||||
|
@RequestParam String title,
|
||||||
|
@RequestParam(required = false, defaultValue = "") String sourceUri,
|
||||||
|
@RequestParam String content,
|
||||||
|
@RequestParam String techTags,
|
||||||
|
@RequestParam(defaultValue = "600") int chunkSize,
|
||||||
|
@RequestParam(defaultValue = "DEMO") String embeddingMode,
|
||||||
|
RedirectAttributes redirectAttributes
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
var result = service.ingest(new VectorIngestCommand(
|
||||||
|
documentId, title, sourceUri, content, techTags, chunkSize, embeddingMode));
|
||||||
|
redirectAttributes.addFlashAttribute("ingestResult", result);
|
||||||
|
redirectAttributes.addFlashAttribute("message",
|
||||||
|
result.chunkCount() + "개 청크를 저장했습니다. DDS 권한 평가용 태그 "
|
||||||
|
+ result.tagCount() + "개가 연결되었습니다.");
|
||||||
|
} catch (Exception exception) {
|
||||||
|
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
|
||||||
|
}
|
||||||
|
return "redirect:/vector-knowledge";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/vector-knowledge/search")
|
||||||
|
public String search(
|
||||||
|
@RequestParam String userKey,
|
||||||
|
@RequestParam String query,
|
||||||
|
@RequestParam(defaultValue = "10") int limit,
|
||||||
|
@RequestParam(defaultValue = "DEMO") String embeddingMode,
|
||||||
|
Model model
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
model.addAttribute("searchResult", service.search(userKey, query, limit, embeddingMode));
|
||||||
|
} catch (AppException | IllegalArgumentException exception) {
|
||||||
|
model.addAttribute("errorMessage", exception.getMessage());
|
||||||
|
}
|
||||||
|
return "fragments/dds-vector-search-result :: result";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void populatePage(Model model) {
|
||||||
|
try {
|
||||||
|
model.addAttribute("summary", service.summary());
|
||||||
|
model.addAttribute("aiEmbeddingConfigured", service.aiEmbeddingConfigured());
|
||||||
|
} catch (DataAccessException exception) {
|
||||||
|
model.addAttribute("summary", null);
|
||||||
|
model.addAttribute("runtimeError", exception.getMessage());
|
||||||
|
model.addAttribute("aiEmbeddingConfigured", false);
|
||||||
|
}
|
||||||
|
model.addAttribute("ddsUsers", ddsQueryService.users());
|
||||||
|
model.addAttribute("ddsVectorObject", ddsQueryService.vectorObject());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,26 @@
|
|||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: dds-permission-backoffice
|
name: dds-permission-backoffice
|
||||||
|
datasource:
|
||||||
|
url: ${BACKOFFICE_DB_URL:jdbc:oracle:thin:@localhost:1521/FREEPDB1}
|
||||||
|
username: ${BACKOFFICE_DB_USERNAME:backoffice}
|
||||||
|
password: ${BACKOFFICE_DB_PASSWORD:backoffice}
|
||||||
|
driver-class-name: oracle.jdbc.OracleDriver
|
||||||
|
hikari:
|
||||||
|
pool-name: dds-backoffice-pool
|
||||||
|
maximum-pool-size: ${BACKOFFICE_DB_POOL_MAX:10}
|
||||||
|
minimum-idle: ${BACKOFFICE_DB_POOL_MIN:2}
|
||||||
|
connection-timeout: 10000
|
||||||
|
idle-timeout: ${BACKOFFICE_DB_POOL_IDLE_TIMEOUT_MS:300000}
|
||||||
|
keepalive-time: ${BACKOFFICE_DB_POOL_KEEPALIVE_MS:120000}
|
||||||
thymeleaf:
|
thymeleaf:
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
|
mybatis:
|
||||||
|
mapper-locations: classpath:/mapper/*.xml
|
||||||
|
configuration:
|
||||||
|
map-underscore-to-camel-case: true
|
||||||
|
|
||||||
server:
|
server:
|
||||||
address: ${DDS_BACKOFFICE_BIND_ADDRESS:0.0.0.0}
|
address: ${DDS_BACKOFFICE_BIND_ADDRESS:0.0.0.0}
|
||||||
port: ${DDS_BACKOFFICE_PORT:8083}
|
port: ${DDS_BACKOFFICE_PORT:8083}
|
||||||
@@ -21,12 +38,29 @@ backoffice:
|
|||||||
admin-user: ${DDS_BACKOFFICE_ADMIN_USER:${BACKOFFICE_ADMIN_USER:admin}}
|
admin-user: ${DDS_BACKOFFICE_ADMIN_USER:${BACKOFFICE_ADMIN_USER:admin}}
|
||||||
admin-password: ${DDS_BACKOFFICE_ADMIN_PASSWORD:${BACKOFFICE_ADMIN_PASSWORD:admin}}
|
admin-password: ${DDS_BACKOFFICE_ADMIN_PASSWORD:${BACKOFFICE_ADMIN_PASSWORD:admin}}
|
||||||
require-https: ${DDS_BACKOFFICE_REQUIRE_HTTPS:false}
|
require-https: ${DDS_BACKOFFICE_REQUIRE_HTTPS:false}
|
||||||
|
token:
|
||||||
|
max-days: ${BACKOFFICE_TOKEN_MAX_DAYS:365}
|
||||||
|
ords:
|
||||||
|
base-url: ${BACKOFFICE_ORDS_BASE_URL:}
|
||||||
|
timeout: ${BACKOFFICE_ORDS_TIMEOUT_SECONDS:10}s
|
||||||
|
metadata-db:
|
||||||
|
url: ${BACKOFFICE_ORDS_DB_URL:}
|
||||||
|
username: ${BACKOFFICE_ORDS_DB_USERNAME:}
|
||||||
|
password: ${BACKOFFICE_ORDS_DB_PASSWORD:}
|
||||||
|
ai:
|
||||||
|
enabled: ${BACKOFFICE_AI_ENABLED:false}
|
||||||
|
base-url: ${BACKOFFICE_AI_BASE_URL:}
|
||||||
|
model: ${BACKOFFICE_AI_MODEL:}
|
||||||
|
embedding-model: ${BACKOFFICE_AI_EMBEDDING_MODEL:}
|
||||||
|
api-key: ${BACKOFFICE_AI_API_KEY:}
|
||||||
|
timeout: ${BACKOFFICE_AI_TIMEOUT_SECONDS:30}s
|
||||||
|
|
||||||
dds:
|
dds:
|
||||||
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}
|
||||||
my-object: ${DDS_BACKOFFICE_MY_OBJECT:ADMIN.V_DDS_CUSTOMERS_MY}
|
my-object: ${DDS_BACKOFFICE_MY_OBJECT:ADMIN.V_DDS_CUSTOMERS_MY}
|
||||||
|
vector-object: ${DDS_BACKOFFICE_VECTOR_OBJECT:ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS}
|
||||||
users:
|
users:
|
||||||
my:
|
my:
|
||||||
label: MY 전용 사용자
|
label: MY 전용 사용자
|
||||||
|
|||||||
47
dds-backoffice/src/main/resources/templates/dds-home.html
Normal file
47
dds-backoffice/src/main/resources/templates/dds-home.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head th:replace="~{fragments/layout :: head('DDS 권한 백오피스')}"></head>
|
||||||
|
<body>
|
||||||
|
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||||
|
<main class="container py-4">
|
||||||
|
<header class="page-title guided-hero">
|
||||||
|
<span class="architecture-kicker">DEEP DATA SECURITY · INDEPENDENT TRACK</span>
|
||||||
|
<h1>같은 권한 기준을 DDS 방식으로 적용하고 확인합니다.</h1>
|
||||||
|
<p class="context-summary">권한 설계 → DDS 보호 연결 → END USER 검증 → 지식 검색 결과 확인</p>
|
||||||
|
<details class="explanation-details">
|
||||||
|
<summary>이 인스턴스의 역할 보기</summary>
|
||||||
|
<p>8083 DDS 인스턴스는 8082 VPD 인스턴스와 별도로 실행됩니다. 두 화면은 사용자·그룹·역할·권한 규칙을 같은 관리 흐름으로 보여주지만, DDS는 END USER·DATA ROLE·DATA GRANT를 통해 데이터를 보호합니다.</p>
|
||||||
|
<p class="mb-0">일상적인 권한 변경은 권한 관리에서 하고, DDS 보호 객체와 실제 결과는 이 인스턴스에서 확인합니다.</p>
|
||||||
|
</details>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="alert alert-warning" th:if="${runtimeError}">DDS 관리 데이터를 불러오지 못했습니다. <span th:text="${runtimeError}"></span></div>
|
||||||
|
|
||||||
|
<section class="journey-grid" aria-label="DDS 권한 적용 네 단계">
|
||||||
|
<a class="journey-card" href="/permissions"><span class="journey-number">1</span><div><h2>1. 권한 설계</h2><p>사용자·그룹·역할에 객체와 TAG 규칙을 연결합니다.</p><strong>권한 규칙 만들기 →</strong></div></a>
|
||||||
|
<a class="journey-card" href="/vpd-policies"><span class="journey-number">2</span><div><h2>2. DDS 보호 연결</h2><p>공통 규칙을 DDS 전용 VIEW와 DATA GRANT에 연결합니다.</p><strong>DDS 보호 객체 확인 →</strong></div></a>
|
||||||
|
<a class="journey-card" href="/dds"><span class="journey-number">3</span><div><h2>3. END USER 검증</h2><p>실제 DDS END USER로 접속해 허용·차단 결과를 확인합니다.</p><strong>직접 조회 실행 →</strong></div></a>
|
||||||
|
<a class="journey-card" href="/vector-knowledge"><span class="journey-number">4</span><div><h2>4. 지식 검색</h2><p>청크·임베딩·태그와 DDS 권한을 결합한 검색 시나리오를 확인합니다.</p><strong>권한 기반 검색 →</strong></div></a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-band macro-micro-grid">
|
||||||
|
<div><span class="architecture-kicker">MACRO · 전체 관점</span><h2>관리 기준은 하나입니다.</h2><p>누가 어떤 지식자료를 볼 수 있는지는 사용자·그룹·역할·권한 규칙에서 설명합니다. VPD와 DDS는 같은 기준을 서로 다른 DB 보호 방식으로 집행합니다.</p></div>
|
||||||
|
<div><span class="architecture-kicker">MICRO · 실행 관점</span><h2>DDS가 선언형 권한을 집행합니다.</h2><p><code>END USER → DATA ROLE → DATA GRANT → 보호 VIEW</code>가 연결되지 않으면 객체가 보이지 않습니다. 권한이 없을 때 0건이 아니라 <code>ORA-00942</code>가 될 수 있습니다.</p></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="summary-grid" aria-label="현재 DDS 관리 현황">
|
||||||
|
<a class="summary-tile" href="/permissions"><span class="label">역할</span><strong th:text="${#lists.size(roles)}">0</strong></a>
|
||||||
|
<a class="summary-tile" href="/permissions"><span class="label">권한 규칙</span><strong th:text="${#lists.size(permissions)}">0</strong></a>
|
||||||
|
<a class="summary-tile" href="/dds"><span class="label">설정된 END USER</span><strong th:text="${configuredUserCount}">0</strong></a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-band">
|
||||||
|
<div class="section-heading"><div><h2>현재 DDS 검증 대상</h2><p class="section-subtitle">고객 데이터 원본과 벡터 지식자료를 각각 DDS 전용 객체로 확인합니다.</p></div></div>
|
||||||
|
<div class="table-responsive"><table class="table table-sm align-middle"><thead><tr><th>대상</th><th>보호 방식</th><th>검증</th></tr></thead><tbody>
|
||||||
|
<tr><td><code>ADMIN.V_DDS_CUSTOMERS_PG/MY</code></td><td>DATA ROLE/DATA GRANT</td><td><a href="/dds">원본별 직접 조회</a></td></tr>
|
||||||
|
<tr><td><code th:text="${vectorObject}">ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS</code></td><td>TAG DATA GRANT + VECTOR_DISTANCE</td><td><a href="/vector-knowledge">지식 검색</a></td></tr>
|
||||||
|
</tbody></table></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,23 +1,8 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||||
<head>
|
<head th:replace="~{fragments/layout :: head('DDS 직접 조회 검증')}"></head>
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Deep Data Security 접근 관리</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link href="/css/dds.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body>
|
<body>
|
||||||
<nav class="dds-nav">
|
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||||
<div class="container d-flex align-items-center gap-3">
|
|
||||||
<a class="brand" href="/">DDS Permission Console</a>
|
|
||||||
<span class="track-badge">별도 인스턴스 · :8083</span>
|
|
||||||
<form method="post" action="/logout" class="ms-auto">
|
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
|
||||||
<button class="btn btn-sm btn-outline-light" type="submit">로그아웃</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="container py-4">
|
<main class="container py-4">
|
||||||
<header class="hero">
|
<header class="hero">
|
||||||
@@ -31,6 +16,8 @@
|
|||||||
</details>
|
</details>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<section th:replace="~{fragments/layout :: architectureStrip('token')}"></section>
|
||||||
|
|
||||||
<section class="flow-grid" aria-label="DDS 접근 흐름">
|
<section class="flow-grid" aria-label="DDS 접근 흐름">
|
||||||
<article class="flow-card">
|
<article class="flow-card">
|
||||||
<span class="flow-number">1</span>
|
<span class="flow-number">1</span>
|
||||||
@@ -46,6 +33,22 @@
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">SAME MANAGEMENT FLOW</span>
|
||||||
|
<h2>VPD 데모와 같은 권한 관리 흐름</h2>
|
||||||
|
<p>사용자·그룹·역할·데이터 권한은 같은 순서로 설계하고, 보호 적용 단계만 DDS 선언형 객체로 연결합니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="matrix-grid">
|
||||||
|
<a class="matrix-card" href="/users"><strong>1. 사용자</strong><span>누가 요청하는가</span><small>애플리케이션 사용자와 DDS END USER의 관계를 확인합니다.</small></a>
|
||||||
|
<a class="matrix-card" href="/groups"><strong>2. 그룹·역할</strong><span>어떤 업무 범위인가</span><small>그룹과 역할 상속을 같은 관리 화면에서 설정합니다.</small></a>
|
||||||
|
<a class="matrix-card" href="/permissions"><strong>3. 데이터 권한</strong><span>어떤 행·컬럼인가</span><small>행 규칙과 태그 기준을 관리 정책으로 정의합니다.</small></a>
|
||||||
|
<a class="matrix-card" href="/effective-matrix"><strong>4. 최종 권한</strong><span>DDS 적용 대상 확인</span><small>역할별 결과를 확인한 뒤 DATA ROLE/GRANT로 반영합니다.</small></a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="alert alert-warning" th:if="${configuredUserCount == 0}">
|
<div class="alert alert-warning" th:if="${configuredUserCount == 0}">
|
||||||
DDS 사용자 비밀번호가 설정되지 않았습니다. VM에서는 <code>DDSUSER_*_PASSWORD</code> 또는 <code>DDS_BACKOFFICE_*_PASSWORD</code> 환경 변수를 설정해야 조회할 수 있습니다.
|
DDS 사용자 비밀번호가 설정되지 않았습니다. VM에서는 <code>DDSUSER_*_PASSWORD</code> 또는 <code>DDS_BACKOFFICE_*_PASSWORD</code> 환경 변수를 설정해야 조회할 수 있습니다.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<div th:fragment="result">
|
||||||
|
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
|
||||||
|
<div th:if="${searchResult}">
|
||||||
|
<div class="result-metrics">
|
||||||
|
<div><span>임베딩</span><strong th:text="${searchResult.embeddingModel()}">로컬 임베딩(개발용)</strong></div>
|
||||||
|
<div><span>검색 결과</span><strong th:text="${#lists.size(searchResult.rows()) + '건'}">0건</strong></div>
|
||||||
|
<div><span>END USER</span><strong th:text="${searchResult.endUser() ?: searchResult.userLabel()}">-</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="next-action-card mt-3" th:classappend="${searchResult.success()} ? '' : ' border-warning'">
|
||||||
|
<h3 th:text="${searchResult.title()}">권한 결과</h3>
|
||||||
|
<p th:text="${searchResult.message()}">결과 요약</p>
|
||||||
|
<small th:text="${'질문: ' + searchResult.query() + ' · 객체: ' + searchResult.objectName()}">검색 정보</small>
|
||||||
|
<small class="d-block" th:if="${searchResult.oracleCode() != null}"
|
||||||
|
th:text="${'Oracle code: ' + searchResult.oracleCode()}">Oracle code</small>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive mt-3" th:if="${searchResult.success() and searchResult.hasRows()}">
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead><tr><th>검색 단위</th><th>자료 ID</th><th>제목</th><th>기술 태그</th><th>관련도</th><th>본문</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="row : ${searchResult.rows()}">
|
||||||
|
<td th:text="${row['CHUNK_ID']}">28001</td>
|
||||||
|
<td th:text="${row['DOCUMENT_ID']}">knowledge-001</td>
|
||||||
|
<td th:text="${row['TITLE']}">제목</td>
|
||||||
|
<td><code th:text="${row['TECH_TAG']}">ORDS</code></td>
|
||||||
|
<td th:text="${row['SCORE']}">0.01</td>
|
||||||
|
<td class="matrix-list" th:text="${row['CHUNK_TEXT']}">본문</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="empty-result-guide" th:if="${searchResult.success() and !searchResult.hasRows()}">DATA GRANT를 통과한 검색 단위가 없습니다.</div>
|
||||||
|
<details class="technical-details mt-3">
|
||||||
|
<summary>DDS 연결 정보 보기</summary>
|
||||||
|
<p class="mb-0">SESSION_USER: <code th:text="${searchResult.sessionUser() ?: '-'}">-</code> · ORA_END_USER_CONTEXT: <code th:text="${searchResult.endUser() ?: '-'}">-</code></p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head th:fragment="head(title)">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title th:text="${title}">DDS 백오피스</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="/css/app.css" rel="stylesheet">
|
||||||
|
<link th:if="${backofficeTrack == 'DDS'}" href="/css/dds.css" rel="stylesheet">
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
|
<script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
|
||||||
|
<script defer src="/js/app.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav th:fragment="nav" class="navbar rw-nav navbar-expand-lg">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="/" th:text="${backofficeTrack == 'DDS' ? 'DDS Backoffice' : 'VPD Backoffice'}">DDS Backoffice</a>
|
||||||
|
<span class="badge rounded-pill text-bg-primary ms-2" th:if="${backofficeTrack == 'DDS'}">독립 DDS 트랙 · 8083</span>
|
||||||
|
<div class="rw-menu">
|
||||||
|
<div class="rw-menu-group">
|
||||||
|
<button class="rw-menu-trigger" type="button" aria-expanded="false">1. 권한 설계</button>
|
||||||
|
<div class="rw-menu-panel">
|
||||||
|
<a class="nav-link" href="/users">사용자</a>
|
||||||
|
<a class="nav-link" href="/groups">그룹</a>
|
||||||
|
<a class="nav-link" href="/roles">역할</a>
|
||||||
|
<a class="nav-link" href="/permissions">데이터 권한 규칙</a>
|
||||||
|
<a class="nav-link" href="/effective-matrix">사용자별 최종 권한</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rw-menu-group">
|
||||||
|
<button class="rw-menu-trigger" type="button" aria-expanded="false"
|
||||||
|
th:text="${backofficeTrack == 'DDS' ? '2. DDS 보호·검증' : '2. 보호·검증'}">2. 보호·검증</button>
|
||||||
|
<div class="rw-menu-panel">
|
||||||
|
<a class="nav-link" href="/vpd-policies"
|
||||||
|
th:text="${backofficeTrack == 'DDS' ? 'DDS 보호 연결' : 'DB 보호 연결'}">DB 보호 연결</a>
|
||||||
|
<a class="nav-link" href="/tokens" th:if="${backofficeTrack != 'DDS'}">검증 세션 발급</a>
|
||||||
|
<a class="nav-link" href="/probe" th:if="${backofficeTrack != 'DDS'}">권한 결과 확인</a>
|
||||||
|
<a class="nav-link" href="/dds">DDS 직접 조회</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rw-menu-group">
|
||||||
|
<button class="rw-menu-trigger" type="button" aria-expanded="false">연동 도구</button>
|
||||||
|
<div class="rw-menu-panel">
|
||||||
|
<a class="nav-link" href="/objects" th:if="${backofficeTrack != 'DDS'}">ORDS 조회 대상</a>
|
||||||
|
<a class="nav-link" href="/ords-handlers" th:if="${backofficeTrack != 'DDS'}">ORDS 핸들러</a>
|
||||||
|
<a class="nav-link" href="/vector-knowledge">지식 검색 관리</a>
|
||||||
|
<a class="nav-link" href="/mcp-chatbot" th:if="${backofficeTrack != 'DDS'}">Chatbot</a>
|
||||||
|
<a class="nav-link" href="/mcp-reasoning" th:if="${backofficeTrack != 'DDS'}">Reasoning</a>
|
||||||
|
<a class="nav-link" href="/mcp-sse" th:if="${backofficeTrack != 'DDS'}">SSE 서비스</a>
|
||||||
|
<a class="nav-link" href="/mcp-client-demo" th:if="${backofficeTrack != 'DDS'}">MCP Client</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rw-menu-group">
|
||||||
|
<button class="rw-menu-trigger" type="button" aria-expanded="false">운영·고급</button>
|
||||||
|
<div class="rw-menu-panel">
|
||||||
|
<a class="nav-link" href="/operation-status" th:if="${backofficeTrack != 'DDS'}">운영 상태</a>
|
||||||
|
<a class="nav-link" href="/vpd-filter-policies" th:if="${backofficeTrack != 'DDS'}">별도 Filter 관리</a>
|
||||||
|
<a class="nav-link" href="/settings" th:if="${backofficeTrack != 'DDS'}">설정</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/logout" class="ms-auto">
|
||||||
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="submit">로그아웃</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div th:fragment="trackNotice" th:if="${backofficeTrack == 'DDS'}" class="container pt-3">
|
||||||
|
<div class="alert alert-info mb-0">
|
||||||
|
<strong>DDS 독립 데모</strong> · VPD와 같은 관리 흐름을 사용하지만, 실제 보호 적용은 <code>END USER → DATA ROLE → DATA GRANT</code>로 별도 처리합니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section th:fragment="architectureStrip(activeLayer)" class="architecture-strip" aria-label="권한 설정부터 결과 확인까지의 흐름">
|
||||||
|
<div class="architecture-step" th:classappend="${activeLayer == 'permission'} ? ' active'">
|
||||||
|
<span class="architecture-kicker">1 · WHO / WHAT</span>
|
||||||
|
<strong>권한 설계</strong>
|
||||||
|
<p>누가 어떤 데이터의 어느 행과 컬럼을 볼지 정합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="architecture-arrow">→</div>
|
||||||
|
<div class="architecture-step" th:classappend="${activeLayer == 'vpd'} ? ' active'">
|
||||||
|
<span class="architecture-kicker" th:text="${backofficeTrack == 'DDS' ? '2 · ENFORCE' : '2 · ENFORCE'}">2 · ENFORCE</span>
|
||||||
|
<strong th:text="${backofficeTrack == 'DDS' ? 'DDS 보호 연결' : 'DB 보호 연결'}">DB 보호 연결</strong>
|
||||||
|
<p th:text="${backofficeTrack == 'DDS' ? 'DATA ROLE과 DATA GRANT가 관리된 권한을 보호 객체에 선언합니다.' : 'VPD가 저장된 권한체계를 매번 읽어 DB에서 행을 자동 제한합니다.'}">DB 보호 정책이 권한을 적용합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="architecture-arrow">→</div>
|
||||||
|
<div class="architecture-step" th:classappend="${activeLayer == 'token'} ? ' active'">
|
||||||
|
<span class="architecture-kicker">3 · IDENTITY</span>
|
||||||
|
<strong th:text="${backofficeTrack == 'DDS' ? 'END USER 컨텍스트' : '검증 세션'}">검증 세션</strong>
|
||||||
|
<p th:text="${backofficeTrack == 'DDS' ? 'DDS END USER 또는 지원 드라이버 컨텍스트로 조회 주체를 전달합니다.' : '확인할 사용자를 나타내는 일회성 토큰을 준비합니다.'}">조회 주체를 준비합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="architecture-arrow">→</div>
|
||||||
|
<div class="architecture-step" th:classappend="${activeLayer == 'ords'} ? ' active'">
|
||||||
|
<span class="architecture-kicker">4 · EVIDENCE</span>
|
||||||
|
<strong>결과 확인</strong>
|
||||||
|
<p>보호 정책을 통과한 데이터만 실제 결과로 확인합니다.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head th:replace="~{fragments/layout :: head('DDS 지식 검색 관리')}"></head>
|
||||||
|
<body>
|
||||||
|
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||||
|
<main class="container py-4">
|
||||||
|
<div class="page-title">
|
||||||
|
<span class="architecture-kicker">DDS · KNOWLEDGE ACCESS</span>
|
||||||
|
<h1>지식자료를 등록하고 DDS 권한으로 검색합니다.</h1>
|
||||||
|
<p class="context-summary">문서 → 청크·임베딩 → 기술 태그 → 권한 규칙 → DDS DATA GRANT → 검색 결과</p>
|
||||||
|
<details class="explanation-details">
|
||||||
|
<summary>이 화면의 큰 흐름 보기</summary>
|
||||||
|
<p>문서는 검색 가능한 청크로 나뉘고 각 청크에 기술 태그가 붙습니다. 권한 관리에서 역할별로 허용할 태그를 등록하면 DDS의 DATA GRANT가 해당 태그가 붙은 청크만 검색 대상으로 남깁니다.</p>
|
||||||
|
<p class="mb-0">VPD 화면과 같은 사용자·그룹·역할·권한 규칙을 사용하지만, 실행 시점에는 Bearer 기반 VPD predicate가 아니라 DDS END USER의 DATA ROLE과 DATA GRANT가 적용됩니다.</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section th:replace="~{fragments/layout :: architectureStrip('ords')}"></section>
|
||||||
|
|
||||||
|
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
|
||||||
|
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
|
||||||
|
<div class="alert alert-warning" th:if="${runtimeError}">
|
||||||
|
벡터 저장소를 확인할 수 없습니다. <span th:text="${runtimeError}"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="content-band">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<span class="architecture-kicker">1 · CONTENT</span>
|
||||||
|
<h2>지식자료 등록</h2>
|
||||||
|
<p class="section-subtitle">문서 ID가 같으면 기존 청크를 교체합니다. 저장소는 VPD 트랙과 동일한 지식자료 형식을 사용합니다.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge text-bg-secondary" th:text="${summary == null ? '저장소 확인 필요' : summary.chunkCount() + '개 검색 단위'}">0개 검색 단위</span>
|
||||||
|
</div>
|
||||||
|
<details class="explanation-details">
|
||||||
|
<summary>청킹·임베딩 설정 보기</summary>
|
||||||
|
<ul>
|
||||||
|
<li>문단을 우선 묶고 길이가 길면 문장·공백 기준으로 청크를 나눕니다.</li>
|
||||||
|
<li>기본 로컬 임베딩은 외부 API 없이 흐름을 검증하는 개발용 4차원 벡터입니다.</li>
|
||||||
|
<li>운영에서는 등록과 검색에 같은 모델·차원을 사용해야 합니다.</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
<form method="post" action="/vector-knowledge/ingest" class="form-grid mt-3">
|
||||||
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
|
<label>문서 ID<input class="form-control" name="documentId" placeholder="knowledge-security-001" required></label>
|
||||||
|
<label>문서 제목<input class="form-control" name="title" placeholder="DDS 기술 태그 권한 설계" required></label>
|
||||||
|
<label>원문 주소 (선택)<input class="form-control" name="sourceUri" placeholder="kb://security/dds-vector"></label>
|
||||||
|
<label>기술 태그 (쉼표 또는 공백)
|
||||||
|
<input class="form-control" name="techTags" placeholder="SPRING_BOOT ORACLE_DDS" required>
|
||||||
|
<span class="form-hint">태그는 대문자로 정규화됩니다. 권한 규칙의 TAG 값과 일치해야 합니다.</span>
|
||||||
|
</label>
|
||||||
|
<label>검색 단위 길이<input class="form-control" name="chunkSize" type="number" min="80" max="2000" value="600"></label>
|
||||||
|
<label>임베딩 방식
|
||||||
|
<select class="form-select" name="embeddingMode">
|
||||||
|
<option value="DEMO">로컬 임베딩(개발용)</option>
|
||||||
|
<option value="AI" th:disabled="${!aiEmbeddingConfigured}">AI 임베딩 (설정 필요)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="span-2">문서 본문
|
||||||
|
<textarea class="form-control" name="content" rows="8" placeholder="문서 내용을 붙여 넣으세요." required></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="btn rw-btn-primary" type="submit">지식자료 저장</button>
|
||||||
|
</form>
|
||||||
|
<div class="alert alert-info mt-3 mb-0" th:if="${ingestResult}">
|
||||||
|
<strong th:text="${ingestResult.documentId()}">document</strong>
|
||||||
|
<span th:text="${ingestResult.chunkCount() + '개 검색 단위 / ' + ingestResult.tagCount() + '개 태그 연결 / ' + ingestResult.embeddingModel()}">저장 완료</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-band">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<span class="architecture-kicker">2 · POLICY</span>
|
||||||
|
<h2>권한 규칙은 한 곳에서 관리합니다.</h2>
|
||||||
|
<p class="section-subtitle">사용자·그룹·역할·TAG 규칙은 VPD와 같은 권한 관리 화면에서 작성합니다.</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-sm rw-btn-primary" href="/permissions">권한 관리 열기</a>
|
||||||
|
</div>
|
||||||
|
<div class="macro-micro-grid">
|
||||||
|
<div><h3>관리 관점</h3><p>역할에 <code>ALLOW TAG</code>를 여러 개 등록하면 태그 중 하나라도 맞는 청크를 허용합니다. <code>DENY TAG</code>는 허용 후보에서 제외합니다.</p></div>
|
||||||
|
<div><h3>DDS 실행 관점</h3><p><code th:text="${ddsVectorObject}">ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS</code>에 연결된 DATA GRANT가 END USER의 DATA ROLE을 통해 이 규칙을 집행합니다.</p></div>
|
||||||
|
</div>
|
||||||
|
<details class="explanation-details mt-3">
|
||||||
|
<summary>VPD와 DDS의 차이 보기</summary>
|
||||||
|
<p>VPD는 요청마다 권한 테이블을 읽어 predicate를 계산합니다. DDS는 보호 VIEW에 선언한 DATA GRANT와 END USER 매핑을 DB가 적용합니다. 따라서 태그 규칙을 바꾸면 DDS grant 반영 절차도 함께 확인해야 합니다.</p>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-band">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<span class="architecture-kicker">3 · SEARCH</span>
|
||||||
|
<h2>DDS 권한으로 지식 검색</h2>
|
||||||
|
<p class="section-subtitle">선택한 END USER로 직접 접속해 DATA ROLE/DATA GRANT가 통과시킨 청크만 반환합니다.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge text-bg-primary" th:text="${ddsVectorObject}">DDS view</span>
|
||||||
|
</div>
|
||||||
|
<details class="explanation-details">
|
||||||
|
<summary>검색 처리 방식 보기</summary>
|
||||||
|
<ol>
|
||||||
|
<li>검색어를 등록 때와 같은 방식으로 임베딩합니다.</li>
|
||||||
|
<li>DDS END USER로 보호 VIEW에 연결합니다.</li>
|
||||||
|
<li>DATA ROLE/DATA GRANT가 허용하지 않은 태그 청크는 객체 권한 단계에서 제외합니다.</li>
|
||||||
|
<li>남은 청크를 벡터 관련도 순으로 반환합니다.</li>
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
<form hx-post="/vector-knowledge/search" hx-target="#vector-search-result" hx-swap="innerHTML" class="form-grid mt-3">
|
||||||
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
|
<label>DDS END USER
|
||||||
|
<select class="form-select" name="userKey" required>
|
||||||
|
<option th:each="user : ${ddsUsers}" th:value="${user.key()}" th:selected="${user.key() == 'both'}"
|
||||||
|
th:text="${user.label() + ' · ' + user.username()}"></option>
|
||||||
|
</select>
|
||||||
|
<span class="form-hint">비밀번호는 서버 환경 변수에서만 읽고 화면에 표시하지 않습니다.</span>
|
||||||
|
</label>
|
||||||
|
<label>결과 수<input class="form-control" name="limit" type="number" min="1" max="100" value="10"></label>
|
||||||
|
<label>임베딩 방식
|
||||||
|
<select class="form-select" name="embeddingMode">
|
||||||
|
<option value="DEMO">로컬 임베딩(개발용)</option>
|
||||||
|
<option value="AI" th:disabled="${!aiEmbeddingConfigured}">AI 임베딩</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="span-2">검색 질문
|
||||||
|
<textarea class="form-control" name="query" rows="3" placeholder="예: 기술 태그로 지식자료 검색 권한을 제한하는 방법" required></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="btn rw-btn-primary" type="submit">DDS 권한으로 검색</button>
|
||||||
|
</form>
|
||||||
|
<section id="vector-search-result" class="mt-3" aria-live="polite">
|
||||||
|
<div class="empty-result-guide">END USER와 질문을 선택하면 DDS가 허용한 검색 단위만 표시됩니다.</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-band">
|
||||||
|
<details class="explanation-details">
|
||||||
|
<summary>저장 현황 보기</summary>
|
||||||
|
<div class="summary-grid mt-3">
|
||||||
|
<div class="summary-tile"><span class="label">문서</span><strong th:text="${summary == null ? '-' : summary.documentCount()}">0</strong></div>
|
||||||
|
<div class="summary-tile"><span class="label">검색 단위</span><strong th:text="${summary == null ? '-' : summary.chunkCount()}">0</strong></div>
|
||||||
|
<div class="summary-tile"><span class="label">태그 연결</span><strong th:text="${summary == null ? '-' : summary.tagCount()}">0</strong></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
107
dds-backoffice/src/main/resources/templates/vpd-policies.html
Normal file
107
dds-backoffice/src/main/resources/templates/vpd-policies.html
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head th:replace="~{fragments/layout :: head('DDS 보호 연결')}"></head>
|
||||||
|
<body>
|
||||||
|
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||||
|
<main class="container py-4">
|
||||||
|
<div class="page-title">
|
||||||
|
<span class="architecture-kicker">DDS · ENFORCEMENT</span>
|
||||||
|
<h1>DDS 보호 연결</h1>
|
||||||
|
<p class="context-summary">권한 관리에서 만든 규칙을 DDS DATA ROLE/DATA GRANT로 연결하고 실제 검색 결과로 확인합니다.</p>
|
||||||
|
<details class="explanation-details">
|
||||||
|
<summary>VPD의 Policy/Filter와 무엇이 다른가요?</summary>
|
||||||
|
<p>VPD는 Policy가 Filter function을 호출해 요청마다 predicate를 계산합니다. DDS에는 그 두 단계를 별도 함수로 만들지 않고, 보호 VIEW와 DATA GRANT에 행·컬럼 조건을 선언합니다.</p>
|
||||||
|
<p class="mb-0">따라서 이 화면에서는 VPD Filter를 수정하지 않습니다. 일상적인 변경은 <a href="/permissions">권한 관리</a>에서 하고, DDS grant 반영은 승인된 SQL/배포 절차로 수행합니다.</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section th:replace="~{fragments/layout :: architectureStrip('vpd')}"></section>
|
||||||
|
<div class="alert alert-warning" th:if="${runtimeError}">
|
||||||
|
DDS 권한 규칙을 불러오지 못했습니다. <span th:text="${runtimeError}"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="content-band">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<span class="architecture-kicker">공통 관리 기준</span>
|
||||||
|
<h2>VPD와 동일한 권한 관리 대상을 사용합니다.</h2>
|
||||||
|
<p class="section-subtitle">사용자·그룹·역할·행 규칙·TAG 규칙은 한 권한 화면에서 관리합니다.</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-sm rw-btn-primary" href="/permissions">권한 규칙 열기</a>
|
||||||
|
</div>
|
||||||
|
<div class="policy-apply-flow" aria-label="DDS 적용 계층">
|
||||||
|
<span>사용자·그룹·역할</span><strong>→</strong>
|
||||||
|
<span>객체별 행·TAG·컬럼 권한</span><strong>→</strong>
|
||||||
|
<span>DATA ROLE / DATA GRANT</span><strong>→</strong>
|
||||||
|
<span>END USER 결과</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mb-0">관리 대상 이름: <code th:text="${managementObject}">CB_VECTOR_SEARCH_DOCUMENTS</code></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-band">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>DDS 보호 객체</h2>
|
||||||
|
<p class="section-subtitle">VPD용 객체와 분리된 DDS 전용 VIEW입니다. 같은 청크·태그 저장소를 읽지만 VPD 정책은 붙이지 않습니다.</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-sm rw-btn-secondary" href="/vector-knowledge">지식 검색 열기</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead><tr><th>보호 VIEW</th><th>적용 방식</th><th>기본 거부</th><th>검증</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code th:text="${ddsVectorObject}">ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS</code></td>
|
||||||
|
<td>END USER → DATA ROLE → DATA GRANT</td>
|
||||||
|
<td>DATA GRANT가 없으면 객체가 보이지 않음</td>
|
||||||
|
<td><a class="btn btn-sm btn-outline-primary" href="/vector-knowledge">검색 결과 확인</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<details class="explanation-details mt-3">
|
||||||
|
<summary>추가·수정 가이드</summary>
|
||||||
|
<p>새 보호 대상을 추가할 때는 (1) 권한 관리에 객체와 TAG 규칙을 등록하고 (2) DDS 전용 VIEW를 만들고 (3) 해당 DATA ROLE에 <code>CREATE OR REPLACE DATA GRANT ... WHERE ...</code>를 연결한 뒤 (4) 허용·거부·권한 없음 사용자를 직접 검증합니다.</p>
|
||||||
|
<p class="mb-0">현재 벡터 예제의 기준 SQL은 <code>sql/adb/32_dds_vector_tag_setup.sql</code>입니다. 이 예제는 임의의 한 테이블만 강제하는 유일한 방식이 아니라, 실제 업무 VIEW/Handler로 확장하기 위한 기준 흐름입니다.</p>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-band">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>역할별 관리 규칙</h2>
|
||||||
|
<p class="section-subtitle">아래 내용은 공통 권한 화면의 현재 규칙입니다. DDS에서는 이 규칙을 DATA GRANT 조건으로 반영합니다.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge text-bg-secondary" th:text="${#lists.size(permissions)} + '개 규칙'">0개 규칙</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead><tr><th>역할</th><th>효과</th><th>행·태그 규칙</th><th>원문 허용 컬럼</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="permission : ${permissions}">
|
||||||
|
<td><strong th:text="${permission.roleName()}">ROLE</strong></td>
|
||||||
|
<td><span class="badge" th:classappend="${permission.permissionEffect() == 'ALLOW'} ? ' text-bg-success' : ' text-bg-danger'" th:text="${permission.permissionEffect()}">ALLOW</span></td>
|
||||||
|
<td><code th:text="${permission.rules() ?: '등록된 규칙 없음'}">TAG SPRING_BOOT</code></td>
|
||||||
|
<td th:text="${permission.visibleColumns() ?: '없음'}">없음</td>
|
||||||
|
</tr>
|
||||||
|
<tr th:if="${#lists.isEmpty(permissions)}"><td colspan="4" class="text-muted">아직 이 객체에 연결된 권한 규칙이 없습니다. 권한 관리에서 역할과 TAG 규칙을 먼저 등록하세요.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-band">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div><h2>DDS END USER 검증 주체</h2><p class="section-subtitle">비밀번호는 서버 환경 변수로만 관리하고 화면에는 표시하지 않습니다.</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="summary-tile" th:each="user : ${ddsUsers}">
|
||||||
|
<span class="label" th:text="${user.label()}">사용자</span>
|
||||||
|
<strong th:text="${user.username()}">dds_demo</strong>
|
||||||
|
<small th:text="${user.configured()} ? '연결 설정됨' : '비밀번호 설정 필요'">상태</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -22,6 +22,7 @@ class DdsPropertiesTest {
|
|||||||
|
|
||||||
assertEquals("ADMIN.V_DDS_CUSTOMERS_PG", properties.objectFor("pg"));
|
assertEquals("ADMIN.V_DDS_CUSTOMERS_PG", properties.objectFor("pg"));
|
||||||
assertEquals("ADMIN.V_DDS_CUSTOMERS_MY", properties.objectFor("MY"));
|
assertEquals("ADMIN.V_DDS_CUSTOMERS_MY", properties.objectFor("MY"));
|
||||||
|
assertEquals("ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS", properties.vectorObject());
|
||||||
assertTrue(properties.users().containsKey("my"));
|
assertTrue(properties.users().containsKey("my"));
|
||||||
assertTrue(properties.users().get("my").configured());
|
assertTrue(properties.users().get("my").configured());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|||||||
APP_DIR="${DDS_BACKOFFICE_APP_DIR:-/home/opc/apps/dds-backoffice}"
|
APP_DIR="${DDS_BACKOFFICE_APP_DIR:-/home/opc/apps/dds-backoffice}"
|
||||||
PORT="${DDS_BACKOFFICE_PORT:-8083}"
|
PORT="${DDS_BACKOFFICE_PORT:-8083}"
|
||||||
|
|
||||||
|
if [[ -f /home/opc/apps/vpd-backoffice/.env ]]; then
|
||||||
|
set -a
|
||||||
|
. /home/opc/apps/vpd-backoffice/.env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
mvn -DskipTests package
|
||||||
|
mvn install:install-file \
|
||||||
|
-Dfile="$ROOT/target/vpd-permission-backoffice-0.1.0-SNAPSHOT.jar.original" \
|
||||||
|
-DpomFile="$ROOT/pom.xml"
|
||||||
|
|
||||||
cd "$ROOT/dds-backoffice"
|
cd "$ROOT/dds-backoffice"
|
||||||
mvn -DskipTests package
|
mvn -DskipTests package
|
||||||
JAR="$ROOT/dds-backoffice/target/dds-permission-backoffice-0.1.0-SNAPSHOT.jar"
|
JAR="$ROOT/dds-backoffice/target/dds-permission-backoffice-0.1.0-SNAPSHOT.jar"
|
||||||
@@ -95,11 +107,41 @@ install -o opc -g opc -m 0644 "$JAR" "$APP_DIR/app.jar"
|
|||||||
"$APP_DIR/start.sh"
|
"$APP_DIR/start.sh"
|
||||||
|
|
||||||
for _ in {1..30}; do
|
for _ in {1..30}; do
|
||||||
if "$APP_DIR/status.sh" | grep -q '^running ' && curl -fsS -o /dev/null "http://127.0.0.1:${PORT}/health"; then
|
if "$APP_DIR/status.sh" | grep -q '^running ' && curl -fsS -o /dev/null "http://127.0.0.1:${PORT}/login"; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
"$APP_DIR/status.sh"
|
"$APP_DIR/status.sh"
|
||||||
curl -sS -o /tmp/dds-backoffice-health.json -w 'health=%{http_code}\n' "http://127.0.0.1:${PORT}/health"
|
curl -sS -o /tmp/dds-backoffice-login.html -w 'login=%{http_code}\n' "http://127.0.0.1:${PORT}/login"
|
||||||
|
for path in / /users /groups /roles /permissions /effective-matrix /vpd-policies /vector-knowledge /dds; do
|
||||||
|
curl -sS --connect-timeout 3 --max-time 20 -u "${BACKOFFICE_ADMIN_USER:-}:${BACKOFFICE_ADMIN_PASSWORD:-}" \
|
||||||
|
-o "/tmp/dds-backoffice-check-${path#/}.html" \
|
||||||
|
-w "${path}=%{http_code}\n" "http://127.0.0.1:${PORT}${path}" || true
|
||||||
|
done
|
||||||
|
SMOKE_COOKIE="/tmp/dds-backoffice-smoke.cookies"
|
||||||
|
SMOKE_PAGE="/tmp/dds-backoffice-smoke-page.html"
|
||||||
|
SMOKE_RESULT="/tmp/dds-backoffice-smoke-result.html"
|
||||||
|
if curl -sS --connect-timeout 3 --max-time 20 -c "$SMOKE_COOKIE" \
|
||||||
|
-u "${BACKOFFICE_ADMIN_USER:-}:${BACKOFFICE_ADMIN_PASSWORD:-}" \
|
||||||
|
-o "$SMOKE_PAGE" "http://127.0.0.1:${PORT}/vector-knowledge"; then
|
||||||
|
CSRF_TOKEN="$(sed -n 's/.*name="_csrf" value="\([^"]*\)".*/\1/p' "$SMOKE_PAGE" | head -1)"
|
||||||
|
if [[ -n "$CSRF_TOKEN" ]]; then
|
||||||
|
for smoke_user in both none; do
|
||||||
|
curl -sS --connect-timeout 3 --max-time 30 -b "$SMOKE_COOKIE" -c "$SMOKE_COOKIE" \
|
||||||
|
-u "${BACKOFFICE_ADMIN_USER:-}:${BACKOFFICE_ADMIN_PASSWORD:-}" \
|
||||||
|
-H "X-CSRF-TOKEN: $CSRF_TOKEN" \
|
||||||
|
-H 'HX-Request: true' \
|
||||||
|
--data-urlencode "_csrf=$CSRF_TOKEN" \
|
||||||
|
--data-urlencode "userKey=$smoke_user" \
|
||||||
|
--data-urlencode 'query=Oracle VPD ORDS 권한 태그 검색' \
|
||||||
|
--data-urlencode 'limit=10' \
|
||||||
|
--data-urlencode 'embeddingMode=DEMO' \
|
||||||
|
-o "${SMOKE_RESULT}.${smoke_user}" "http://127.0.0.1:${PORT}/vector-knowledge/search" || true
|
||||||
|
echo "vector-search-user=${smoke_user}"
|
||||||
|
rg -n "DDS 권한으로 검색했습니다|DATA GRANT를 통과|ORA-00942|데이터베이스 오류" \
|
||||||
|
"${SMOKE_RESULT}.${smoke_user}" | head -20 || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
echo "DDS backoffice deployed at ${APP_DIR} (loopback port ${PORT})"
|
echo "DDS backoffice deployed at ${APP_DIR} (loopback port ${PORT})"
|
||||||
|
|||||||
79
sql/adb/32_dds_vector_tag_setup.sql
Normal file
79
sql/adb/32_dds_vector_tag_setup.sql
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 32_dds_vector_tag_setup.sql
|
||||||
|
-- DDS counterpart of the VPD vector/tag search object.
|
||||||
|
--
|
||||||
|
-- The common backoffice stores chunks and TECH_TAG rows in
|
||||||
|
-- CB_VECTOR_DOCUMENT_CHUNK / CB_VECTOR_DOCUMENT_TAG. DDS must not query
|
||||||
|
-- CB_VECTOR_SEARCH_DOCUMENTS because that view carries the VPD policy.
|
||||||
|
-- This script creates a DDS-only view and declares tag access with DATA
|
||||||
|
-- GRANTs. The application uses the same user/role/TAG management journey;
|
||||||
|
-- the final enforcement is END USER -> DATA ROLE -> DATA GRANT.
|
||||||
|
--
|
||||||
|
-- Prerequisite: 28_agent_ords_vector_tag_vpd_setup.sql and 31_dds_standalone_demo_setup.sql
|
||||||
|
-- Run as ADMIN on an Oracle AI Database release with Deep Data Security.
|
||||||
|
-- ============================================================
|
||||||
|
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||||
|
SET ECHO ON
|
||||||
|
SET FEEDBACK ON
|
||||||
|
SET DEFINE OFF
|
||||||
|
|
||||||
|
PROMPT === 1. Creating a DDS-only vector view (no VPD policy) ===
|
||||||
|
CREATE OR REPLACE VIEW cb_dds_vector_search_documents AS
|
||||||
|
SELECT c.chunk_id,
|
||||||
|
c.document_id,
|
||||||
|
c.chunk_no,
|
||||||
|
c.title,
|
||||||
|
c.chunk_text,
|
||||||
|
c.source_uri,
|
||||||
|
c.embedding,
|
||||||
|
(
|
||||||
|
SELECT LISTAGG(t.tech_tag, ',') WITHIN GROUP (ORDER BY t.tech_tag)
|
||||||
|
FROM cb_vector_document_tag t
|
||||||
|
WHERE t.chunk_id = c.chunk_id
|
||||||
|
) AS tech_tag
|
||||||
|
FROM cb_vector_document_chunk c
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM cb_vector_document_tag t
|
||||||
|
WHERE t.chunk_id = c.chunk_id
|
||||||
|
);
|
||||||
|
|
||||||
|
PROMPT === 2. Replacing DATA GRANTs for the four demo identities ===
|
||||||
|
-- The tag expression is deliberately an OR expression. Adding another
|
||||||
|
-- allowed tag means replacing the corresponding grant expression, not
|
||||||
|
-- writing a second conflicting policy.
|
||||||
|
CREATE OR REPLACE DATA GRANT admin.dds_demo_my_vector_grant
|
||||||
|
AS SELECT
|
||||||
|
ON admin.cb_dds_vector_search_documents
|
||||||
|
WHERE REGEXP_LIKE(UPPER(tech_tag), '(^|,)(SPRING_BOOT|ORDS)(,|$)')
|
||||||
|
TO dds_demo_my_role;
|
||||||
|
|
||||||
|
CREATE OR REPLACE DATA GRANT admin.dds_demo_pg_vector_grant
|
||||||
|
AS SELECT
|
||||||
|
ON admin.cb_dds_vector_search_documents
|
||||||
|
WHERE REGEXP_LIKE(UPPER(tech_tag), '(^|,)(ORACLE_VPD|ORACLE_DDS)(,|$)')
|
||||||
|
TO dds_demo_pg_role;
|
||||||
|
|
||||||
|
CREATE OR REPLACE DATA GRANT admin.dds_demo_both_vector_grant
|
||||||
|
AS SELECT
|
||||||
|
ON admin.cb_dds_vector_search_documents
|
||||||
|
WHERE REGEXP_LIKE(UPPER(tech_tag), '(^|,)(SPRING_BOOT|ORDS|ORACLE_VPD|ORACLE_DDS|MCP)(,|$)')
|
||||||
|
TO dds_demo_both_role;
|
||||||
|
|
||||||
|
-- dds_demo_none_role intentionally receives no grant (default deny).
|
||||||
|
|
||||||
|
PROMPT === 3. Vector object inventory ===
|
||||||
|
SELECT 'DDS_VECTOR_OBJECT' AS item,
|
||||||
|
'ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS' AS value
|
||||||
|
FROM dual;
|
||||||
|
SELECT grant_name, object_name, grantee
|
||||||
|
FROM dba_data_grants
|
||||||
|
WHERE grant_name IN (
|
||||||
|
'DDS_DEMO_MY_VECTOR_GRANT',
|
||||||
|
'DDS_DEMO_PG_VECTOR_GRANT',
|
||||||
|
'DDS_DEMO_BOTH_VECTOR_GRANT'
|
||||||
|
)
|
||||||
|
ORDER BY grant_name;
|
||||||
|
|
||||||
|
PROMPT === DDS vector/tag DATA GRANT setup complete ===
|
||||||
|
EXIT;
|
||||||
Reference in New Issue
Block a user