From 2e4bcef44f06c6dbc2f96dec0ac3bf5e9debb030 Mon Sep 17 00:00:00 2001 From: devmrko Date: Mon, 29 Jun 2026 23:50:42 +0900 Subject: [PATCH] feat: align DDS with common permission and vector flow --- dds-backoffice/README.md | 18 +- dds-backoffice/pom.xml | 5 + .../DdsBackofficeApplication.java | 25 ++ .../ddsbackoffice/config/DdsProperties.java | 20 ++ .../domain/DdsVectorSearchResult.java | 30 ++ .../service/DdsQueryService.java | 4 + .../service/DdsVectorKnowledgeService.java | 317 ++++++++++++++++++ .../ddsbackoffice/web/DdsController.java | 24 +- .../web/DdsProtectionController.java | 55 +++ .../web/DdsTrackModelAdvice.java | 13 + .../web/DdsVectorKnowledgeController.java | 87 +++++ .../src/main/resources/application.yml | 34 ++ .../main/resources/templates/dds-home.html | 47 +++ .../src/main/resources/templates/dds.html | 37 +- .../fragments/dds-vector-search-result.html | 37 ++ .../resources/templates/fragments/layout.html | 101 ++++++ .../resources/templates/vector-knowledge.html | 145 ++++++++ .../resources/templates/vpd-policies.html | 107 ++++++ .../config/DdsPropertiesTest.java | 1 + scripts/deploy-dds-backoffice-local.sh | 46 ++- sql/adb/32_dds_vector_tag_setup.sql | 79 +++++ 21 files changed, 1207 insertions(+), 25 deletions(-) create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsVectorSearchResult.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsVectorKnowledgeService.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsTrackModelAdvice.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsVectorKnowledgeController.java create mode 100644 dds-backoffice/src/main/resources/templates/dds-home.html create mode 100644 dds-backoffice/src/main/resources/templates/fragments/dds-vector-search-result.html create mode 100644 dds-backoffice/src/main/resources/templates/fragments/layout.html create mode 100644 dds-backoffice/src/main/resources/templates/vector-knowledge.html create mode 100644 dds-backoffice/src/main/resources/templates/vpd-policies.html create mode 100644 sql/adb/32_dds_vector_tag_setup.sql diff --git a/dds-backoffice/README.md b/dds-backoffice/README.md index 27ebab2..dc43a1b 100644 --- a/dds-backoffice/README.md +++ b/dds-backoffice/README.md @@ -3,26 +3,33 @@ `dds-backoffice`는 기존 VPD 데모와 코드·프로세스·포트·보호 객체를 분리한 Deep Data Security 전용 데모입니다. - 기본 포트: `8083` -- 기본 화면: `/` 또는 `/dds` +- 기본 화면: `/` (공통 권한 여정), `/dds` (DDS 직접 조회 검증) - 기본 보호 객체: `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 `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 함수 | 업무 권한 테이블 | | DDS | DDS `END USER` 또는 보안 컨텍스트 | `DATA ROLE` + `DATA GRANT` | 선언형 DDL/Grant | -따라서 DDS는 행·컬럼·작업 제한이라는 결과는 만들 수 있지만, VPD의 Bearer 기반 동적 predicate 실행 모델과 동일한 대체재는 아닙니다. +따라서 두 인스턴스의 관리 흐름과 기대 결과는 맞출 수 있지만, Bearer 요청마다 권한을 계산하는 VPD 실행 모델을 DDS가 그대로 대체하는 것은 아닙니다. DDS로 Bearer 기반 호출을 운영하려면 지원 드라이버의 `EndUserSecurityContext` 전파가 별도로 필요합니다. 이 앱의 조회 경계는 다음과 같습니다. ```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`를 별도 설계해야 합니다. @@ -33,8 +40,11 @@ ORDS Handler가 Bearer 값을 `cb_dds_hr` 같은 문자열로 매핑하는 것 ```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/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 인스턴스를 실행합니다. ```bash diff --git a/dds-backoffice/pom.xml b/dds-backoffice/pom.xml index 8a6e718..4de7b7d 100644 --- a/dds-backoffice/pom.xml +++ b/dds-backoffice/pom.xml @@ -23,6 +23,11 @@ + + com.cloudhandson + vpd-permission-backoffice + 0.1.0-SNAPSHOT + org.springframework.boot spring-boot-starter-web diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/DdsBackofficeApplication.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/DdsBackofficeApplication.java index faaa1b9..71a03c8 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/DdsBackofficeApplication.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/DdsBackofficeApplication.java @@ -1,12 +1,37 @@ package com.cloudhandson.ddsbackoffice; 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.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; @SpringBootApplication @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 static void main(String[] args) { diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsProperties.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsProperties.java index bcdc110..4a9516a 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsProperties.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsProperties.java @@ -5,6 +5,7 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.regex.Pattern; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; @ConfigurationProperties(prefix = "dds") public record DdsProperties( @@ -12,6 +13,7 @@ public record DdsProperties( Duration queryTimeout, String pgObject, String myObject, + String vectorObject, Map users ) { @@ -19,11 +21,13 @@ public record DdsProperties( "[A-Za-z][A-Za-z0-9_$#]*(\\.[A-Za-z][A-Za-z0-9_$#]*)*" ); + @ConstructorBinding public DdsProperties { dbUrl = dbUrl == null ? "" : dbUrl.trim(); queryTimeout = queryTimeout == null ? Duration.ofSeconds(10) : queryTimeout; pgObject = normalizeObject(pgObject, "ADMIN.V_DDS_CUSTOMERS_PG"); myObject = normalizeObject(myObject, "ADMIN.V_DDS_CUSTOMERS_MY"); + vectorObject = normalizeObject(vectorObject, "ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS"); var normalizedUsers = new LinkedHashMap(); if (users != null) { users.forEach((key, value) -> { @@ -35,6 +39,22 @@ public record DdsProperties( 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 users + ) { + this(dbUrl, queryTimeout, pgObject, myObject, + "ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS", users); + } + public String objectFor(String sourceKey) { if ("PG".equalsIgnoreCase(sourceKey)) { return pgObject; diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsVectorSearchResult.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsVectorSearchResult.java new file mode 100644 index 0000000..480e07e --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsVectorSearchResult.java @@ -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> rows +) { + + public DdsVectorSearchResult { + rows = rows == null ? List.of() : List.copyOf(rows); + } + + public boolean hasRows() { + return !rows.isEmpty(); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsQueryService.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsQueryService.java index 80c7a1d..fdcff55 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsQueryService.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsQueryService.java @@ -57,6 +57,10 @@ public class DdsQueryService { ); } + public String vectorObject() { + return properties.vectorObject(); + } + public DdsQueryResult query(String userKey, String sourceKey, String searchText, int requestedLimit) { var normalizedUserKey = normalizeUserKey(userKey); var normalizedSourceKey = normalizeSourceKey(sourceKey); diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsVectorKnowledgeService.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsVectorKnowledgeService.java new file mode 100644 index 0000000..cbed6c2 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsVectorKnowledgeService.java @@ -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> rows = new ArrayList<>(); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + Map 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 embed(String input, String mode) { + if (AI_MODE.equals(mode)) { + return embeddingClient.embedding(input); + } + return demoEmbedding(input); + } + + private List 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 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 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 접속 정보를 확인하세요."; + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsController.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsController.java index 2545a08..23482d2 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsController.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsController.java @@ -2,6 +2,9 @@ package com.cloudhandson.ddsbackoffice.web; import com.cloudhandson.ddsbackoffice.domain.DdsQueryResult; 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.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -12,12 +15,29 @@ import org.springframework.web.bind.annotation.RequestParam; public class DdsController { private final DdsQueryService service; + private final PermissionService permissionService; - public DdsController(DdsQueryService service) { + public DdsController(DdsQueryService service, PermissionService permissionService) { 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) { addOptions(model, "both", "PG", null); return "dds"; diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java new file mode 100644 index 0000000..e4f7d0b --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java @@ -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 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"); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsTrackModelAdvice.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsTrackModelAdvice.java new file mode 100644 index 0000000..e75a1c3 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsTrackModelAdvice.java @@ -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"; + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsVectorKnowledgeController.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsVectorKnowledgeController.java new file mode 100644 index 0000000..43509fb --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsVectorKnowledgeController.java @@ -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()); + } +} diff --git a/dds-backoffice/src/main/resources/application.yml b/dds-backoffice/src/main/resources/application.yml index 942da7c..3a2227b 100644 --- a/dds-backoffice/src/main/resources/application.yml +++ b/dds-backoffice/src/main/resources/application.yml @@ -1,9 +1,26 @@ spring: application: 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: cache: false +mybatis: + mapper-locations: classpath:/mapper/*.xml + configuration: + map-underscore-to-camel-case: true + server: address: ${DDS_BACKOFFICE_BIND_ADDRESS:0.0.0.0} port: ${DDS_BACKOFFICE_PORT:8083} @@ -21,12 +38,29 @@ backoffice: admin-user: ${DDS_BACKOFFICE_ADMIN_USER:${BACKOFFICE_ADMIN_USER:admin}} admin-password: ${DDS_BACKOFFICE_ADMIN_PASSWORD:${BACKOFFICE_ADMIN_PASSWORD:admin}} 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: db-url: ${DDS_BACKOFFICE_DB_URL:${BACKOFFICE_DB_URL:jdbc:oracle:thin:@localhost:1521/FREEPDB1}} query-timeout: ${DDS_BACKOFFICE_QUERY_TIMEOUT:10s} pg-object: ${DDS_BACKOFFICE_PG_OBJECT:ADMIN.V_DDS_CUSTOMERS_PG} my-object: ${DDS_BACKOFFICE_MY_OBJECT:ADMIN.V_DDS_CUSTOMERS_MY} + vector-object: ${DDS_BACKOFFICE_VECTOR_OBJECT:ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS} users: my: label: MY 전용 사용자 diff --git a/dds-backoffice/src/main/resources/templates/dds-home.html b/dds-backoffice/src/main/resources/templates/dds-home.html new file mode 100644 index 0000000..574edbc --- /dev/null +++ b/dds-backoffice/src/main/resources/templates/dds-home.html @@ -0,0 +1,47 @@ + + + + + +
+
+ DEEP DATA SECURITY · INDEPENDENT TRACK +

같은 권한 기준을 DDS 방식으로 적용하고 확인합니다.

+

권한 설계 → DDS 보호 연결 → END USER 검증 → 지식 검색 결과 확인

+
+ 이 인스턴스의 역할 보기 +

8083 DDS 인스턴스는 8082 VPD 인스턴스와 별도로 실행됩니다. 두 화면은 사용자·그룹·역할·권한 규칙을 같은 관리 흐름으로 보여주지만, DDS는 END USER·DATA ROLE·DATA GRANT를 통해 데이터를 보호합니다.

+

일상적인 권한 변경은 권한 관리에서 하고, DDS 보호 객체와 실제 결과는 이 인스턴스에서 확인합니다.

+
+
+ +
DDS 관리 데이터를 불러오지 못했습니다.
+ +
+ 1

1. 권한 설계

사용자·그룹·역할에 객체와 TAG 규칙을 연결합니다.

권한 규칙 만들기 →
+ 2

2. DDS 보호 연결

공통 규칙을 DDS 전용 VIEW와 DATA GRANT에 연결합니다.

DDS 보호 객체 확인 →
+ 3

3. END USER 검증

실제 DDS END USER로 접속해 허용·차단 결과를 확인합니다.

직접 조회 실행 →
+ 4

4. 지식 검색

청크·임베딩·태그와 DDS 권한을 결합한 검색 시나리오를 확인합니다.

권한 기반 검색 →
+
+ +
+
MACRO · 전체 관점

관리 기준은 하나입니다.

누가 어떤 지식자료를 볼 수 있는지는 사용자·그룹·역할·권한 규칙에서 설명합니다. VPD와 DDS는 같은 기준을 서로 다른 DB 보호 방식으로 집행합니다.

+
MICRO · 실행 관점

DDS가 선언형 권한을 집행합니다.

END USER → DATA ROLE → DATA GRANT → 보호 VIEW가 연결되지 않으면 객체가 보이지 않습니다. 권한이 없을 때 0건이 아니라 ORA-00942가 될 수 있습니다.

+
+ +
+ 역할0 + 권한 규칙0 + 설정된 END USER0 +
+ +
+

현재 DDS 검증 대상

고객 데이터 원본과 벡터 지식자료를 각각 DDS 전용 객체로 확인합니다.

+
+ + +
대상보호 방식검증
ADMIN.V_DDS_CUSTOMERS_PG/MYDATA ROLE/DATA GRANT원본별 직접 조회
ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTSTAG DATA GRANT + VECTOR_DISTANCE지식 검색
+
+
+ + diff --git a/dds-backoffice/src/main/resources/templates/dds.html b/dds-backoffice/src/main/resources/templates/dds.html index 9e14931..5910311 100644 --- a/dds-backoffice/src/main/resources/templates/dds.html +++ b/dds-backoffice/src/main/resources/templates/dds.html @@ -1,23 +1,8 @@ - - - - Deep Data Security 접근 관리 - - - + - +
@@ -31,6 +16,8 @@
+
+
1 @@ -46,6 +33,22 @@
+
+
+
+ SAME MANAGEMENT FLOW +

VPD 데모와 같은 권한 관리 흐름

+

사용자·그룹·역할·데이터 권한은 같은 순서로 설계하고, 보호 적용 단계만 DDS 선언형 객체로 연결합니다.

+
+
+ +
+
DDS 사용자 비밀번호가 설정되지 않았습니다. VM에서는 DDSUSER_*_PASSWORD 또는 DDS_BACKOFFICE_*_PASSWORD 환경 변수를 설정해야 조회할 수 있습니다.
diff --git a/dds-backoffice/src/main/resources/templates/fragments/dds-vector-search-result.html b/dds-backoffice/src/main/resources/templates/fragments/dds-vector-search-result.html new file mode 100644 index 0000000..2dda804 --- /dev/null +++ b/dds-backoffice/src/main/resources/templates/fragments/dds-vector-search-result.html @@ -0,0 +1,37 @@ +
+
+
+
+
임베딩로컬 임베딩(개발용)
+
검색 결과0건
+
END USER-
+
+
+

권한 결과

+

결과 요약

+ 검색 정보 + Oracle code +
+
+ + + + + + + + + + + + +
검색 단위자료 ID제목기술 태그관련도본문
28001knowledge-001제목ORDS0.01본문
+
+
DATA GRANT를 통과한 검색 단위가 없습니다.
+
+ DDS 연결 정보 보기 +

SESSION_USER: - · ORA_END_USER_CONTEXT: -

+
+
+
diff --git a/dds-backoffice/src/main/resources/templates/fragments/layout.html b/dds-backoffice/src/main/resources/templates/fragments/layout.html new file mode 100644 index 0000000..ee09eb0 --- /dev/null +++ b/dds-backoffice/src/main/resources/templates/fragments/layout.html @@ -0,0 +1,101 @@ + + + + + + DDS 백오피스 + + + + + + + + + + +
+
+ DDS 독립 데모 · VPD와 같은 관리 흐름을 사용하지만, 실제 보호 적용은 END USER → DATA ROLE → DATA GRANT로 별도 처리합니다. +
+
+ +
+
+ 1 · WHO / WHAT + 권한 설계 +

누가 어떤 데이터의 어느 행과 컬럼을 볼지 정합니다.

+
+
+
+ 2 · ENFORCE + DB 보호 연결 +

DB 보호 정책이 권한을 적용합니다.

+
+
+
+ 3 · IDENTITY + 검증 세션 +

조회 주체를 준비합니다.

+
+
+
+ 4 · EVIDENCE + 결과 확인 +

보호 정책을 통과한 데이터만 실제 결과로 확인합니다.

+
+
+ + diff --git a/dds-backoffice/src/main/resources/templates/vector-knowledge.html b/dds-backoffice/src/main/resources/templates/vector-knowledge.html new file mode 100644 index 0000000..3475401 --- /dev/null +++ b/dds-backoffice/src/main/resources/templates/vector-knowledge.html @@ -0,0 +1,145 @@ + + + + + +
+
+ DDS · KNOWLEDGE ACCESS +

지식자료를 등록하고 DDS 권한으로 검색합니다.

+

문서 → 청크·임베딩 → 기술 태그 → 권한 규칙 → DDS DATA GRANT → 검색 결과

+
+ 이 화면의 큰 흐름 보기 +

문서는 검색 가능한 청크로 나뉘고 각 청크에 기술 태그가 붙습니다. 권한 관리에서 역할별로 허용할 태그를 등록하면 DDS의 DATA GRANT가 해당 태그가 붙은 청크만 검색 대상으로 남깁니다.

+

VPD 화면과 같은 사용자·그룹·역할·권한 규칙을 사용하지만, 실행 시점에는 Bearer 기반 VPD predicate가 아니라 DDS END USER의 DATA ROLE과 DATA GRANT가 적용됩니다.

+
+
+ +
+ +
+
+
+ 벡터 저장소를 확인할 수 없습니다. +
+ +
+
+
+ 1 · CONTENT +

지식자료 등록

+

문서 ID가 같으면 기존 청크를 교체합니다. 저장소는 VPD 트랙과 동일한 지식자료 형식을 사용합니다.

+
+ 0개 검색 단위 +
+
+ 청킹·임베딩 설정 보기 +
    +
  • 문단을 우선 묶고 길이가 길면 문장·공백 기준으로 청크를 나눕니다.
  • +
  • 기본 로컬 임베딩은 외부 API 없이 흐름을 검증하는 개발용 4차원 벡터입니다.
  • +
  • 운영에서는 등록과 검색에 같은 모델·차원을 사용해야 합니다.
  • +
+
+
+ + + + + + + + + +
+
+ document + 저장 완료 +
+
+ +
+
+
+ 2 · POLICY +

권한 규칙은 한 곳에서 관리합니다.

+

사용자·그룹·역할·TAG 규칙은 VPD와 같은 권한 관리 화면에서 작성합니다.

+
+ 권한 관리 열기 +
+
+

관리 관점

역할에 ALLOW TAG를 여러 개 등록하면 태그 중 하나라도 맞는 청크를 허용합니다. DENY TAG는 허용 후보에서 제외합니다.

+

DDS 실행 관점

ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS에 연결된 DATA GRANT가 END USER의 DATA ROLE을 통해 이 규칙을 집행합니다.

+
+
+ VPD와 DDS의 차이 보기 +

VPD는 요청마다 권한 테이블을 읽어 predicate를 계산합니다. DDS는 보호 VIEW에 선언한 DATA GRANT와 END USER 매핑을 DB가 적용합니다. 따라서 태그 규칙을 바꾸면 DDS grant 반영 절차도 함께 확인해야 합니다.

+
+
+ +
+
+
+ 3 · SEARCH +

DDS 권한으로 지식 검색

+

선택한 END USER로 직접 접속해 DATA ROLE/DATA GRANT가 통과시킨 청크만 반환합니다.

+
+ DDS view +
+
+ 검색 처리 방식 보기 +
    +
  1. 검색어를 등록 때와 같은 방식으로 임베딩합니다.
  2. +
  3. DDS END USER로 보호 VIEW에 연결합니다.
  4. +
  5. DATA ROLE/DATA GRANT가 허용하지 않은 태그 청크는 객체 권한 단계에서 제외합니다.
  6. +
  7. 남은 청크를 벡터 관련도 순으로 반환합니다.
  8. +
+
+
+ + + + + + +
+
+
END USER와 질문을 선택하면 DDS가 허용한 검색 단위만 표시됩니다.
+
+
+ +
+
+ 저장 현황 보기 +
+
문서0
+
검색 단위0
+
태그 연결0
+
+
+
+
+ + diff --git a/dds-backoffice/src/main/resources/templates/vpd-policies.html b/dds-backoffice/src/main/resources/templates/vpd-policies.html new file mode 100644 index 0000000..8103304 --- /dev/null +++ b/dds-backoffice/src/main/resources/templates/vpd-policies.html @@ -0,0 +1,107 @@ + + + + + +
+
+ DDS · ENFORCEMENT +

DDS 보호 연결

+

권한 관리에서 만든 규칙을 DDS DATA ROLE/DATA GRANT로 연결하고 실제 검색 결과로 확인합니다.

+
+ VPD의 Policy/Filter와 무엇이 다른가요? +

VPD는 Policy가 Filter function을 호출해 요청마다 predicate를 계산합니다. DDS에는 그 두 단계를 별도 함수로 만들지 않고, 보호 VIEW와 DATA GRANT에 행·컬럼 조건을 선언합니다.

+

따라서 이 화면에서는 VPD Filter를 수정하지 않습니다. 일상적인 변경은 권한 관리에서 하고, DDS grant 반영은 승인된 SQL/배포 절차로 수행합니다.

+
+
+ +
+
+ DDS 권한 규칙을 불러오지 못했습니다. +
+ +
+
+
+ 공통 관리 기준 +

VPD와 동일한 권한 관리 대상을 사용합니다.

+

사용자·그룹·역할·행 규칙·TAG 규칙은 한 권한 화면에서 관리합니다.

+
+ 권한 규칙 열기 +
+
+ 사용자·그룹·역할 + 객체별 행·TAG·컬럼 권한 + DATA ROLE / DATA GRANT + END USER 결과 +
+

관리 대상 이름: CB_VECTOR_SEARCH_DOCUMENTS

+
+ +
+
+
+

DDS 보호 객체

+

VPD용 객체와 분리된 DDS 전용 VIEW입니다. 같은 청크·태그 저장소를 읽지만 VPD 정책은 붙이지 않습니다.

+
+ 지식 검색 열기 +
+
+ + + + + + + + + + +
보호 VIEW적용 방식기본 거부검증
ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTSEND USER → DATA ROLE → DATA GRANTDATA GRANT가 없으면 객체가 보이지 않음검색 결과 확인
+
+
+ 추가·수정 가이드 +

새 보호 대상을 추가할 때는 (1) 권한 관리에 객체와 TAG 규칙을 등록하고 (2) DDS 전용 VIEW를 만들고 (3) 해당 DATA ROLE에 CREATE OR REPLACE DATA GRANT ... WHERE ...를 연결한 뒤 (4) 허용·거부·권한 없음 사용자를 직접 검증합니다.

+

현재 벡터 예제의 기준 SQL은 sql/adb/32_dds_vector_tag_setup.sql입니다. 이 예제는 임의의 한 테이블만 강제하는 유일한 방식이 아니라, 실제 업무 VIEW/Handler로 확장하기 위한 기준 흐름입니다.

+
+
+ +
+
+
+

역할별 관리 규칙

+

아래 내용은 공통 권한 화면의 현재 규칙입니다. DDS에서는 이 규칙을 DATA GRANT 조건으로 반영합니다.

+
+ 0개 규칙 +
+
+ + + + + + + + + + + +
역할효과행·태그 규칙원문 허용 컬럼
ROLEALLOWTAG SPRING_BOOT없음
아직 이 객체에 연결된 권한 규칙이 없습니다. 권한 관리에서 역할과 TAG 규칙을 먼저 등록하세요.
+
+
+ +
+
+

DDS END USER 검증 주체

비밀번호는 서버 환경 변수로만 관리하고 화면에는 표시하지 않습니다.

+
+
+
+ 사용자 + dds_demo + 상태 +
+
+
+
+ + diff --git a/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/config/DdsPropertiesTest.java b/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/config/DdsPropertiesTest.java index 3e10840..02e501f 100644 --- a/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/config/DdsPropertiesTest.java +++ b/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/config/DdsPropertiesTest.java @@ -22,6 +22,7 @@ class DdsPropertiesTest { assertEquals("ADMIN.V_DDS_CUSTOMERS_PG", properties.objectFor("pg")); 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().get("my").configured()); } diff --git a/scripts/deploy-dds-backoffice-local.sh b/scripts/deploy-dds-backoffice-local.sh index 81409d4..8c2adee 100755 --- a/scripts/deploy-dds-backoffice-local.sh +++ b/scripts/deploy-dds-backoffice-local.sh @@ -6,6 +6,18 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" APP_DIR="${DDS_BACKOFFICE_APP_DIR:-/home/opc/apps/dds-backoffice}" 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" mvn -DskipTests package 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" 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 fi sleep 1 done "$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})" diff --git a/sql/adb/32_dds_vector_tag_setup.sql b/sql/adb/32_dds_vector_tag_setup.sql new file mode 100644 index 0000000..e571f6e --- /dev/null +++ b/sql/adb/32_dds_vector_tag_setup.sql @@ -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;