Compare commits

..

13 Commits

Author SHA1 Message Date
devmrko
5c40a9aa33 docs: align DDS token WHERE flow and vector scenario 2026-06-30 11:21:51 +09:00
devmrko
1e48864bce feat: publish application permissions to DDS grants 2026-06-30 07:51:36 +09:00
devmrko
2e4bcef44f feat: align DDS with common permission and vector flow 2026-06-29 23:50:42 +09:00
devmrko
ad9a2b7dd4 fix: preserve quoted DDS end user identity 2026-06-29 22:33:27 +09:00
devmrko
b943d0e9bc fix: include wallet security library in DDS instance 2026-06-29 22:30:26 +09:00
devmrko
e7ca656a0f feat: make DDS demo data plane independent 2026-06-29 22:21:41 +09:00
devmrko
9eccf98e11 docs: clarify independent VPD and DDS demos 2026-06-29 22:14:12 +09:00
devmrko
1aaffb6247 fix: bound DDS connection timeouts 2026-06-29 21:27:05 +09:00
devmrko
c4968a0313 feat: add dedicated DDS permission backoffice instance 2026-06-29 21:19:48 +09:00
devmrko
6cf75dc42b refactor #565: keep embedding mode in technical details 2026-06-29 20:53:50 +09:00
devmrko
b8e36732a8 docs #565: align knowledge search language 2026-06-29 20:52:35 +09:00
devmrko
5b98c87da1 refactor #565: productize knowledge search language 2026-06-29 20:51:42 +09:00
devmrko
b674d301db fix #565: read ORDS vector body stream once 2026-06-29 20:20:59 +09:00
59 changed files with 3541 additions and 110 deletions

75
dds-backoffice/README.md Normal file
View File

@@ -0,0 +1,75 @@
# DDS Permission Console
`dds-backoffice`는 기존 VPD 데모와 코드·프로세스·포트·보호 객체를 분리한 Deep Data Security 전용 데모입니다.
- 기본 포트: `8083`
- 기본 화면: `/` (공통 권한 여정), `/dds` (DDS 직접 조회 검증)
- 권한 반영: `/dds-provision` (공통 권한을 DDS DATA GRANT로 미리보기·게시)
- 기본 보호 객체: `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 연결
## 두 데모의 관계
두 인스턴스는 프로세스·포트·실행 경계를 분리하지만, 화면에서 설명하는 관리 순서는 같습니다.
```text
사용자·그룹·역할 → 데이터 권한 규칙 → DDS Grant 게시 → 보호 연결 → 실제 결과 확인
```
8083 DDS 화면의 `/users`, `/groups`, `/roles`, `/permissions`, `/effective-matrix`는 8082 VPD 화면과 같은 관리 기준을 보여줍니다. 다만 보호를 실행하는 DB 계층만 다릅니다. VPD 인스턴스는 공통 DB 계정과 Bearer 컨텍스트로 동적 predicate를 계산하고, DDS 인스턴스는 DDS `END USER`의 DATA ROLE/DATA GRANT로 직접 집행합니다.
`/dds-provision`은 이 차이를 연결하는 명시적 배포 단계입니다. 직접 역할과 활성 그룹에서 상속된 역할을 합쳐 사용자별 유효 권한을 계산하고, 애플리케이션 객체 매핑을 거쳐 `CREATE OR REPLACE DATA GRANT` SQL을 미리 보여줍니다. 그룹을 DDS 그룹으로 복사하지 않고, 그룹이 상속한 역할을 해당 DDS DATA ROLE의 최종 predicate로 합칩니다. 매핑되지 않은 객체는 경고로 남기고 게시하지 않습니다.
| 데모 | 사용자 식별 | 정책 표현 | 권한 변경 |
|---|---|---|---|
| VPD | 공통 계정 + Bearer/세션 컨텍스트 | 동적 predicate 함수 | 업무 권한 테이블 |
| DDS | DDS `END USER` 또는 보안 컨텍스트 | `DATA ROLE` + `DATA GRANT` | 선언형 DDL/Grant |
따라서 두 인스턴스의 관리 흐름과 기대 결과는 맞출 수 있지만, Bearer 요청마다 권한을 계산하는 VPD 실행 모델을 DDS가 그대로 대체하는 것은 아닙니다. DDS로 Bearer 기반 호출을 운영하려면 지원 드라이버의 `EndUserSecurityContext` 전파가 별도로 필요합니다.
권한 변경 후에는 다음 순서로 확인합니다.
```text
1. /permissions에서 사용자·그룹·역할·행/TAG·컬럼 기준을 저장
2. /dds-provision에서 사용자별 predicate와 제외 컬럼을 검토
3. 승인된 경우에만 DDS DATA GRANT 게시
4. /dds 또는 /vector-knowledge에서 END USER별 결과를 검증
```
이 앱의 조회 경계는 다음과 같습니다.
```text
END USER → DATA ROLE → DATA GRANT → DDS 보호 VIEW → 조회 결과
```
ORDS Handler가 Bearer 값을 `cb_dds_hr` 같은 문자열로 매핑하는 것만으로는 DDS 보안 컨텍스트가 전달되지 않습니다. 따라서 이 비교 인스턴스는 DDS `END USER` 직접 logon을 검증합니다. Bearer/ORDS 전달이 필요하면 지원 드라이버의 `EndUserSecurityContext`를 별도 설계해야 합니다.
## 실행
먼저 DDS 데모 전용 로컬 데이터셋과 권한 객체를 적용합니다. 이 스크립트는 VPD VIEW나 외부 RDS DB Link를 사용하지 않습니다.
```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를 만들고, 동일한 `CB_PERMISSION`/`CB_PERMISSION_RULE`에 역할별 TAG 규칙을 등록합니다. `SPRING_BOOT`, `ORDS`, `ORACLE_VPD`, `MCP`처럼 한 역할에 여러 TAG가 있으면 `/dds-provision`이 OR 조건으로 합쳐 DDS DATA GRANT를 다시 만듭니다. 벡터 검색에 필요한 `EMBEDDING`은 DDS 엔진이 거리 계산에 사용하므로 Grant에서 제외하지 않고, 검색 응답에서는 애플리케이션이 반환하지 않습니다. 그 외 민감 컬럼은 애플리케이션 권한의 원문 허용 목록에 없으면 `ALL COLUMNS EXCEPT`로 제외됩니다. 검색 화면에서는 같은 문서를 청킹·임베딩한 뒤 선택한 END USER로 직접 조회하므로, DDS Grant가 없는 주체는 보호 VIEW 자체를 볼 수 없습니다.
그 다음 DDS 인스턴스를 실행합니다.
```bash
cd dds-backoffice
export DDS_BACKOFFICE_DB_URL="${BACKOFFICE_DB_URL}"
export DDSUSER_MY_PASSWORD='...'
export DDSUSER_PG_PASSWORD='...'
export DDSUSER_BOTH_PASSWORD='...'
export DDSUSER_NONE_PASSWORD='...'
mvn -DskipTests package
java -jar target/dds-permission-backoffice-0.1.0-SNAPSHOT.jar
```
관리자 로그인은 `DDS_BACKOFFICE_ADMIN_USER` / `DDS_BACKOFFICE_ADMIN_PASSWORD`를 사용합니다. DDS END USER 비밀번호는 서버 환경 변수로만 읽고 화면에 표시하거나 저장하지 않습니다.
VM 배포는 `scripts/deploy-dds-backoffice-vm.sh`를 사용합니다. 스크립트는 기존 `/home/opc/apps/vpd-backoffice`의 wallet과 환경값을 읽어 `/home/opc/apps/dds-backoffice`를 별도 프로세스로 기동합니다. 기본 바인딩은 `127.0.0.1:8083`이며, 인터넷 공개는 HTTPS reverse proxy와 NSG/firewall 승인을 별도로 거쳐야 합니다.

74
dds-backoffice/pom.xml Normal file
View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.8</version>
<relativePath/>
</parent>
<groupId>com.cloudhandson</groupId>
<artifactId>dds-permission-backoffice</artifactId>
<version>0.1.0-SNAPSHOT</version>
<name>dds-permission-backoffice</name>
<description>Dedicated Deep Data Security permission comparison console</description>
<properties>
<java.version>21</java.version>
<oracle.jdbc.version>23.6.0.24.10</oracle.jdbc.version>
</properties>
<dependencies>
<dependency>
<groupId>com.cloudhandson</groupId>
<artifactId>vpd-permission-backoffice</artifactId>
<version>0.1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.oracle.database.jdbc</groupId>
<artifactId>ojdbc11</artifactId>
<version>${oracle.jdbc.version}</version>
</dependency>
<dependency>
<groupId>com.oracle.database.security</groupId>
<artifactId>oraclepki</artifactId>
<version>${oracle.jdbc.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,40 @@
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) {
SpringApplication.run(DdsBackofficeApplication.class, args);
}
}

View File

@@ -0,0 +1,26 @@
package com.cloudhandson.ddsbackoffice.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(BackofficeProperties.class)
public class AppConfig {
@Bean
DdsDatabaseDriver ddsDatabaseDriver() {
return new DdsDatabaseDriver();
}
static final class DdsDatabaseDriver {
DdsDatabaseDriver() {
try {
Class.forName("oracle.jdbc.OracleDriver");
} catch (ClassNotFoundException e) {
throw new IllegalStateException("Oracle JDBC 드라이버를 찾을 수 없습니다.", e);
}
}
}
}

View File

@@ -0,0 +1,10 @@
package com.cloudhandson.ddsbackoffice.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "backoffice")
public record BackofficeProperties(Security security) {
public record Security(String adminUser, String adminPassword, boolean requireHttps) {
}
}

View File

@@ -0,0 +1,131 @@
package com.cloudhandson.ddsbackoffice.config;
import java.time.Duration;
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(
String dbUrl,
Duration queryTimeout,
String pgObject,
String myObject,
String vectorObject,
Map<String, User> users,
Map<String, String> objectMappings
) {
private static final Pattern OBJECT_NAME = Pattern.compile(
"[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<String, User>();
if (users != null) {
users.forEach((key, value) -> {
if (key != null && value != null) {
normalizedUsers.put(key.trim().toLowerCase(), value);
}
});
}
users = Map.copyOf(normalizedUsers);
var normalizedMappings = new LinkedHashMap<String, String>();
if (objectMappings != null) {
objectMappings.forEach((key, value) -> {
if (key != null && value != null && !key.isBlank() && !value.isBlank()) {
normalizedMappings.put(key.trim().toUpperCase(), normalizeObject(value, ""));
}
});
}
objectMappings = Map.copyOf(normalizedMappings);
}
/**
* 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, Map.of(
"CB_VECTOR_SEARCH_DOCUMENTS", "ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS"));
}
public DdsProperties(
String dbUrl,
Duration queryTimeout,
String pgObject,
String myObject,
String vectorObject,
Map<String, User> users
) {
this(dbUrl, queryTimeout, pgObject, myObject, vectorObject, users, Map.of());
}
public String objectFor(String sourceKey) {
if ("PG".equalsIgnoreCase(sourceKey)) {
return pgObject;
}
if ("MY".equalsIgnoreCase(sourceKey)) {
return myObject;
}
throw new IllegalArgumentException("지원하지 않는 DDS 데이터 소스입니다.");
}
private static String normalizeObject(String value, String fallback) {
var candidate = value == null || value.isBlank() ? fallback : value.trim();
if (!OBJECT_NAME.matcher(candidate).matches()) {
throw new IllegalArgumentException("DDS 보호 객체 이름은 스키마.객체 형식이어야 합니다.");
}
return candidate.toUpperCase();
}
public record User(
String label,
String description,
String username,
String password,
long applicationUserId,
String dataRole
) {
/**
* Keep the six-field constructor as the configuration binding target.
* The four-field overload below exists only for the small query-service
* tests and older local callers; without an explicit binding marker,
* Spring Boot can select that overload and silently leave the application
* user/data-role mapping at its default values.
*/
@ConstructorBinding
public User {
}
public User(String label, String description, String username, String password) {
this(label, description, username, password, 0L, "");
}
public boolean configured() {
return username != null && !username.isBlank()
&& password != null && !password.isBlank();
}
public boolean mapped() {
return applicationUserId > 0 && dataRole != null && !dataRole.isBlank();
}
}
}

View File

@@ -0,0 +1,58 @@
package com.cloudhandson.ddsbackoffice.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(
HttpSecurity http,
BackofficeProperties properties
) throws Exception {
if (properties.security().requireHttps()) {
http.requiresChannel(channel -> channel.anyRequest().requiresSecure());
}
return http
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31_536_000)))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/css/**", "/js/**", "/health", "/login").permitAll()
.anyRequest().authenticated())
.httpBasic(basic -> {
})
.formLogin(login -> login
.loginPage("/login")
.permitAll())
.logout(logout -> logout.logoutSuccessUrl("/login?logout"))
.build();
}
@Bean
UserDetailsService userDetailsService(
BackofficeProperties properties,
PasswordEncoder passwordEncoder
) {
var security = properties.security();
var user = User.withUsername(security.adminUser())
.password(passwordEncoder.encode(security.adminPassword()))
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user);
}
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}

View File

@@ -0,0 +1,12 @@
package com.cloudhandson.ddsbackoffice.domain;
import java.time.LocalDateTime;
public record DdsCustomerRow(
long customerId,
String fullName,
String email,
LocalDateTime signupDate,
String region
) {
}

View File

@@ -0,0 +1,18 @@
package com.cloudhandson.ddsbackoffice.domain;
/** One generated DDS DATA GRANT and its management-model origin. */
public record DdsGrantPlan(
String userKey,
String userLabel,
long applicationUserId,
String dataRole,
String applicationObject,
String ddsObject,
String grantName,
String predicate,
String excludedColumns,
String sql,
boolean publishable,
String note
) {
}

View File

@@ -0,0 +1,24 @@
package com.cloudhandson.ddsbackoffice.domain;
import java.util.List;
/** Preview of the effective application permissions compiled to DDS grants. */
public record DdsProvisioningPlan(
List<DdsGrantPlan> grants,
List<String> warnings,
int mappedUserCount
) {
public DdsProvisioningPlan {
grants = grants == null ? List.of() : List.copyOf(grants);
warnings = warnings == null ? List.of() : List.copyOf(warnings);
}
public long publishableCount() {
return grants.stream().filter(DdsGrantPlan::publishable).count();
}
public boolean hasWarnings() {
return !warnings.isEmpty();
}
}

View File

@@ -0,0 +1,27 @@
package com.cloudhandson.ddsbackoffice.domain;
import java.util.List;
public record DdsQueryResult(
String userKey,
String sourceKey,
String userLabel,
String sourceLabel,
String objectName,
boolean success,
String title,
String message,
String sessionUser,
String endUser,
Integer oracleCode,
List<DdsCustomerRow> rows
) {
public DdsQueryResult {
rows = rows == null ? List.of() : List.copyOf(rows);
}
public boolean hasRows() {
return !rows.isEmpty();
}
}

View File

@@ -0,0 +1,9 @@
package com.cloudhandson.ddsbackoffice.domain;
public record DdsSourceOption(
String key,
String label,
String objectName,
String description
) {
}

View File

@@ -0,0 +1,10 @@
package com.cloudhandson.ddsbackoffice.domain;
public record DdsUserOption(
String key,
String label,
String username,
String description,
boolean configured
) {
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,455 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
import com.cloudhandson.ddsbackoffice.domain.DdsGrantPlan;
import com.cloudhandson.ddsbackoffice.domain.DdsProvisioningPlan;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Compiles the application's effective permission model into DDS grants.
*
* DDS does not evaluate CB_PERMISSION at query time. This service is the
* explicit publish boundary: application users/groups/roles are expanded,
* ALLOW/DENY rules are compiled into one predicate per user/object, and the
* resulting DATA GRANT is created or replaced. A publish with no ALLOW rule
* drops the reserved grant, preserving default deny.
*/
@Service
public class DdsGrantPublisher {
private static final Pattern IDENTIFIER = Pattern.compile("[A-Z][A-Z0-9_$#]*");
private static final Pattern OBJECT = Pattern.compile(
"[A-Z][A-Z0-9_$#]*(\\.[A-Z][A-Z0-9_$#]*)*"
);
private final JdbcTemplate jdbcTemplate;
private final DdsProperties properties;
public DdsGrantPublisher(JdbcTemplate jdbcTemplate, DdsProperties properties) {
this.jdbcTemplate = jdbcTemplate;
this.properties = properties;
}
public DdsProvisioningPlan preview() {
List<DdsGrantPlan> grants = new ArrayList<>();
List<String> warnings = new ArrayList<>();
int mappedUsers = 0;
for (Map.Entry<String, DdsProperties.User> entry : properties.users().entrySet()) {
String userKey = entry.getKey();
DdsProperties.User ddsUser = entry.getValue();
if (!ddsUser.mapped()) {
// The demo's "none" identity is deliberately a DDS-only default-deny
// subject. It has no application user mapping by design, so do not
// turn that expected state into a noisy warning.
if (ddsUser.applicationUserId() == 0L && "none".equals(userKey)) {
continue;
}
warnings.add(userKey + ": 애플리케이션 사용자 ID 또는 DDS DATA ROLE 매핑이 없습니다.");
continue;
}
AppIdentity identity = findIdentity(ddsUser.applicationUserId());
if (identity == null) {
warnings.add(userKey + ": CB_APP_USER " + ddsUser.applicationUserId() + "를 찾을 수 없습니다.");
continue;
}
if (!identity.active()) {
warnings.add(userKey + ": 애플리케이션 사용자가 비활성 상태입니다.");
}
mappedUsers++;
Map<Long, PermissionEntry> permissions = findEffectivePermissions(identity.userId());
// Include every approved mapping, not only objects that still have a
// permission row. This lets an explicit publish revoke a previously
// generated grant when an application permission is removed; the DDS
// side otherwise has no way to observe that deletion.
Set<String> targets = new LinkedHashSet<>(properties.objectMappings().keySet());
permissions.values().forEach(permission -> targets.add(permission.targetName()));
for (String target : targets) {
String normalizedTarget = target.toUpperCase(Locale.ROOT);
String ddsObject = properties.objectMappings().get(normalizedTarget);
String grantName = grantName(userKey, normalizedTarget);
if (ddsObject == null || ddsObject.isBlank()) {
warnings.add(userKey + ": " + normalizedTarget + "에 대한 DDS 보호 객체 매핑이 없습니다.");
grants.add(unpublishable(userKey, ddsUser, normalizedTarget, grantName,
"DDS object mapping 필요"));
continue;
}
if (!OBJECT.matcher(ddsObject).matches()) {
warnings.add(userKey + ": 안전하지 않은 DDS 객체 매핑을 건너뛰었습니다: " + ddsObject);
grants.add(unpublishable(userKey, ddsUser, normalizedTarget, grantName,
"DDS object 이름 검증 실패"));
continue;
}
ObjectInfo object = findProtectedObject(normalizedTarget);
if (object == null) {
warnings.add(userKey + ": 보호 객체 메타데이터가 없습니다: " + normalizedTarget);
grants.add(unpublishable(userKey, ddsUser, normalizedTarget, grantName,
"CB_PROTECTED_OBJECT 등록 필요"));
continue;
}
if (!ddsObjectExists(ddsObject)) {
warnings.add(userKey + ": DDS 보호 객체가 DB에 없습니다: " + ddsObject);
grants.add(unpublishable(userKey, ddsUser, normalizedTarget, grantName,
"DDS VIEW/TABLE 생성 필요"));
continue;
}
List<PermissionEntry> targetPermissions = permissions.values().stream()
.filter(permission -> normalizedTarget.equals(permission.targetName()))
.toList();
PredicateBuild predicate = buildPredicate(targetPermissions, identity, object);
String excludedColumns = excludedColumns(normalizedTarget, targetPermissions, object.objectId());
if (predicate.allow().isBlank()) {
grants.add(new DdsGrantPlan(
userKey, valueOrDefault(ddsUser.label(), userKey), identity.userId(),
ddsUser.dataRole(), normalizedTarget, ddsObject, grantName,
"1 = 0", excludedColumns, "", false,
"ALLOW 규칙이 없어 기존 DDS Grant를 회수합니다.")
);
continue;
}
String where = predicate.expression();
String sql = createGrantSql(grantName, ddsObject, where, excludedColumns, ddsUser.dataRole());
grants.add(new DdsGrantPlan(
userKey, valueOrDefault(ddsUser.label(), userKey), identity.userId(),
ddsUser.dataRole(), normalizedTarget, ddsObject, grantName,
where, excludedColumns, sql, true,
"애플리케이션 유효 권한을 DDS DATA GRANT로 게시할 수 있습니다.")
);
warnings.addAll(predicate.warnings());
}
}
return new DdsProvisioningPlan(grants, warnings, mappedUsers);
}
public DdsProvisioningPlan publish() {
DdsProvisioningPlan plan = preview();
for (DdsGrantPlan grant : plan.grants()) {
// Never touch a grant for an application object that has no approved
// DDS object mapping. An operator may still manage that DDS object by a
// separate process; the preview warning must not become an accidental
// revoke. Known mappings are safe to replace or revoke.
if (grant.ddsObject() == null || grant.ddsObject().isBlank()) {
continue;
}
dropGrant(grant.grantName());
if (grant.publishable()) {
jdbcTemplate.execute(grant.sql());
}
}
return plan;
}
private Map<Long, PermissionEntry> findEffectivePermissions(long userId) {
String sql = """
WITH effective_role AS (
SELECT ur.role_id
FROM cb_user_role ur
WHERE ur.user_id = ?
UNION
SELECT gr.role_id
FROM cb_user_group ug
JOIN cb_app_group g ON g.group_id = ug.group_id AND g.active_yn = 'Y'
JOIN cb_group_role gr ON gr.group_id = ug.group_id
WHERE ug.user_id = ?
)
SELECT p.perm_id, p.target_name, p.permission_effect,
r.rule_id, r.rule_column, r.rule_type, r.rule_value
FROM effective_role er
JOIN cb_permission p ON p.role_id = er.role_id
LEFT JOIN cb_permission_rule r ON r.perm_id = p.perm_id
WHERE p.action_name = 'SELECT'
ORDER BY p.perm_id, r.rule_id
""";
Map<Long, PermissionEntry> result = new LinkedHashMap<>();
List<RawPermissionRow> rows = jdbcTemplate.query(sql, (row, ignored) -> new RawPermissionRow(
row.getLong("perm_id"),
row.getString("target_name"),
row.getString("permission_effect"),
row.getString("rule_column"),
row.getString("rule_type"),
row.getString("rule_value")
), userId, userId);
for (RawPermissionRow row : rows) {
long permissionId = row.permissionId();
PermissionEntry permission = result.computeIfAbsent(permissionId, ignored ->
new PermissionEntry(
permissionId,
upper(row.targetName()),
normalizeEffect(row.permissionEffect()),
new ArrayList<>(),
findVisibleColumns(permissionId)
));
if (row.ruleType() != null && !row.ruleType().isBlank()) {
permission.rules().add(new Rule(
upperNullable(row.ruleColumn()),
upper(row.ruleType()),
row.ruleValue()
));
}
}
return result;
}
private List<String> findVisibleColumns(long permissionId) {
return jdbcTemplate.query(
"SELECT column_name FROM cb_permission_column WHERE permission_id = ? ORDER BY column_name",
(row, ignored) -> upper(row.getString("column_name")), permissionId);
}
private AppIdentity findIdentity(long userId) {
List<AppIdentity> rows = jdbcTemplate.query(
"SELECT user_id, employee_no, dept_code, active FROM cb_app_user WHERE user_id = ?",
(row, ignored) -> new AppIdentity(
row.getLong("user_id"),
row.getString("employee_no"),
row.getString("dept_code"),
"Y".equalsIgnoreCase(row.getString("active"))
), userId);
return rows.isEmpty() ? null : rows.get(0);
}
private ObjectInfo findProtectedObject(String targetName) {
List<ObjectInfo> rows = jdbcTemplate.query(
"SELECT object_id, owner, object_name FROM cb_protected_object WHERE object_name = UPPER(?)",
(row, ignored) -> new ObjectInfo(
row.getLong("object_id"), upper(row.getString("owner")), upper(row.getString("object_name"))),
targetName);
return rows.isEmpty() ? null : rows.get(0);
}
private List<String> sensitiveColumns(long objectId) {
return jdbcTemplate.query(
"SELECT column_name FROM cb_protected_column WHERE object_id = ? AND sensitive_yn = 'Y' ORDER BY column_id",
(row, ignored) -> upper(row.getString("column_name")), objectId);
}
private boolean ddsObjectExists(String objectName) {
String[] parts = objectName.split("\\.", 2);
if (parts.length != 2) {
return false;
}
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM all_objects WHERE owner = ? AND object_name = ? AND object_type IN ('TABLE','VIEW')",
Integer.class, parts[0], parts[1]);
return count != null && count > 0;
}
private PredicateBuild buildPredicate(
List<PermissionEntry> permissions,
AppIdentity identity,
ObjectInfo object
) {
List<String> allow = new ArrayList<>();
List<String> deny = new ArrayList<>();
List<String> warnings = new ArrayList<>();
for (PermissionEntry permission : permissions) {
List<String> clauses = new ArrayList<>();
for (Rule rule : permission.rules()) {
String clause = compileRule(rule, identity, object);
if (clause == null || clause.isBlank()) {
warnings.add(permission.targetName() + ": 지원하지 않거나 컬럼을 확인할 수 없는 규칙을 건너뛰었습니다: "
+ rule.ruleType());
continue;
}
clauses.add(clause);
}
if (clauses.isEmpty()) {
continue;
}
String expression = clauses.size() == 1 ? clauses.get(0) : "(" + String.join(" OR ", clauses) + ")";
if ("DENY".equals(permission.effect())) {
deny.add(expression);
} else {
allow.add(expression);
}
}
if (allow.isEmpty()) {
return new PredicateBuild("", "", warnings);
}
String allowExpression = allow.size() == 1 ? allow.get(0) : "(" + String.join(" OR ", allow) + ")";
if (deny.isEmpty()) {
return new PredicateBuild(allowExpression, allowExpression, warnings);
}
String denyExpression = deny.size() == 1 ? deny.get(0) : "(" + String.join(" OR ", deny) + ")";
return new PredicateBuild(allowExpression, "(" + allowExpression + ") AND NOT (" + denyExpression + ")", warnings);
}
private String compileRule(Rule rule, AppIdentity identity, ObjectInfo object) {
String type = rule.ruleType();
if ("ALL".equals(type)) {
return "1 = 1";
}
String column = rule.ruleColumn();
if (column == null || column.isBlank()) {
column = switch (type) {
case "MY_DEPT", "DEPT" -> "DEPT_CODE";
case "SELF", "EMP_NO" -> "OWNER_EMP_NO";
case "TAG" -> "TECH_TAG";
default -> null;
};
}
if (column == null || !IDENTIFIER.matcher(column).matches() || !hasColumn(object, column)) {
return null;
}
return switch (type) {
case "MY_DEPT" -> identity.deptCode() == null ? null : column + " = " + quote(identity.deptCode());
case "SELF" -> identity.employeeNo() == null ? null : column + " = " + quote(identity.employeeNo());
case "DEPT", "EMP_NO", "=" -> rule.ruleValue() == null ? null
: "TO_CHAR(" + column + ") = " + quote(rule.ruleValue());
case "!=", "<>" -> rule.ruleValue() == null ? null
: "TO_CHAR(" + column + ") <> " + quote(rule.ruleValue());
case "TAG" -> rule.ruleValue() == null || !rule.ruleValue().trim().matches("[A-Za-z0-9_-]+") ? null
: "REGEXP_LIKE(UPPER(" + column + "), "
+ quote("(^|,)" + rule.ruleValue().trim().toUpperCase(Locale.ROOT) + "(,|$)") + ")";
default -> null;
};
}
private boolean hasColumn(ObjectInfo object, String column) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM all_tab_columns WHERE owner = ? AND table_name = ? AND column_name = ?",
Integer.class, object.owner(), object.objectName(), column);
return count != null && count > 0;
}
private String excludedColumns(
String applicationObject,
List<PermissionEntry> permissions,
long objectId
) {
Set<String> visible = new LinkedHashSet<>();
permissions.stream()
.filter(permission -> "ALLOW".equals(permission.effect()))
.forEach(permission -> visible.addAll(permission.visibleColumns()));
return sensitiveColumns(objectId).stream()
// VECTOR_DISTANCE needs the VECTOR column while DDS evaluates the
// search. The service deliberately omits EMBEDDING from its response,
// but excluding a VECTOR column from DATA GRANT makes the predicate
// invalid at runtime (ORA-52561). Keep it available to the engine and
// enforce response shaping in DdsVectorKnowledgeService instead.
.filter(column -> !("CB_VECTOR_SEARCH_DOCUMENTS".equals(applicationObject)
&& "EMBEDDING".equals(column)))
.filter(column -> !visible.contains(column))
.reduce((left, right) -> left + ", " + right)
.orElse("");
}
private String createGrantSql(
String grantName,
String ddsObject,
String predicate,
String excludedColumns,
String dataRole
) {
String select = excludedColumns == null || excludedColumns.isBlank()
? "AS SELECT"
: "AS SELECT (ALL COLUMNS EXCEPT " + excludedColumns + ")";
return "CREATE OR REPLACE DATA GRANT ADMIN." + grantName
+ " " + select
+ " ON " + ddsObject
+ " WHERE " + predicate
+ " TO " + safeRole(dataRole);
}
private void dropGrant(String grantName) {
try {
jdbcTemplate.execute("DROP DATA GRANT ADMIN." + grantName);
} catch (DataAccessException ignored) {
// The first publish has no generated grant yet. DDS returns an object
// not found error, which is intentionally harmless here.
}
}
private DdsGrantPlan unpublishable(
String userKey,
DdsProperties.User user,
String target,
String grantName,
String note
) {
return new DdsGrantPlan(
userKey, valueOrDefault(user.label(), userKey), user.applicationUserId(), user.dataRole(),
target, properties.objectMappings().getOrDefault(target, ""), grantName,
"", "", "", false, note);
}
private String grantName(String userKey, String target) {
if ("CB_VECTOR_SEARCH_DOCUMENTS".equals(target)) {
return "DDS_DEMO_" + userKey.toUpperCase(Locale.ROOT) + "_VECTOR_GRANT";
}
String candidate = "DDS_" + userKey.toUpperCase(Locale.ROOT) + "_" + target + "_GRANT";
return candidate.length() <= 120 ? candidate : candidate.substring(0, 120);
}
private String safeRole(String value) {
String normalized = upper(value);
if (normalized == null || !OBJECT.matcher(normalized).matches()) {
throw new IllegalArgumentException("안전하지 않은 DDS DATA ROLE 이름입니다: " + value);
}
return normalized;
}
private String quote(String value) {
return "'" + value.replace("'", "''") + "'";
}
private String normalizeEffect(String value) {
return "DENY".equalsIgnoreCase(value) ? "DENY" : "ALLOW";
}
private String upper(String value) {
return value == null ? null : value.trim().toUpperCase(Locale.ROOT);
}
private String upperNullable(String value) {
return value == null || value.isBlank() ? null : upper(value);
}
private String valueOrDefault(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value;
}
private record AppIdentity(long userId, String employeeNo, String deptCode, boolean active) {
}
private record ObjectInfo(long objectId, String owner, String objectName) {
}
private record Rule(String ruleColumn, String ruleType, String ruleValue) {
}
private record RawPermissionRow(
long permissionId,
String targetName,
String permissionEffect,
String ruleColumn,
String ruleType,
String ruleValue
) {
}
private record PermissionEntry(
long permissionId,
String targetName,
String effect,
List<Rule> rules,
List<String> visibleColumns
) {
}
private record PredicateBuild(String allow, String expression, List<String> warnings) {
}
}

View File

@@ -0,0 +1,249 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
import com.cloudhandson.ddsbackoffice.domain.DdsCustomerRow;
import com.cloudhandson.ddsbackoffice.domain.DdsQueryResult;
import com.cloudhandson.ddsbackoffice.domain.DdsSourceOption;
import com.cloudhandson.ddsbackoffice.domain.DdsUserOption;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Duration;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Properties;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
@Service
public class DdsQueryService {
private static final Pattern SAFE_SOURCE = Pattern.compile("PG|MY");
private final DdsProperties properties;
public DdsQueryService(DdsProperties properties) {
this.properties = properties;
}
public List<DdsUserOption> users() {
return properties.users().entrySet().stream()
.map(entry -> {
var user = entry.getValue();
return new DdsUserOption(
entry.getKey(),
valueOrDefault(user.label(), entry.getKey()),
valueOrDefault(user.username(), "미설정"),
valueOrDefault(user.description(), "DDS 보안 사용자"),
user.configured()
);
})
.toList();
}
public List<DdsSourceOption> sources() {
return List.of(
new DdsSourceOption(
"PG", "PG 데이터셋", properties.pgObject(),
"DDS 전용 로컬 PG 데이터셋에서 허용된 행만 반환합니다."
),
new DdsSourceOption(
"MY", "MY 데이터셋", properties.myObject(),
"DDS 전용 로컬 MY 데이터셋에서 허용된 행만 반환합니다."
)
);
}
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);
var user = properties.users().get(normalizedUserKey);
var source = sources().stream()
.filter(option -> option.key().equals(normalizedSourceKey))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("지원하지 않는 DDS 데이터 소스입니다."));
var userLabel = user == null ? normalizedUserKey : valueOrDefault(user.label(), normalizedUserKey);
if (user == null) {
return failure(normalizedUserKey, normalizedSourceKey, userLabel, source,
"사용자를 찾을 수 없습니다.", "선택한 DDS END USER가 설정에 없습니다.", null);
}
if (properties.dbUrl().isBlank()) {
return failure(normalizedUserKey, normalizedSourceKey, userLabel, source,
"DB 접속 정보가 없습니다.", "DDS_BACKOFFICE_DB_URL 또는 BACKOFFICE_DB_URL을 설정하세요.", null);
}
if (!user.configured()) {
return failure(normalizedUserKey, normalizedSourceKey, userLabel, source,
"DDS 사용자 자격증명이 설정되지 않았습니다.",
"이 인스턴스의 환경 변수에 해당 END USER 비밀번호를 설정하세요.", null);
}
var limit = Math.max(1, Math.min(requestedLimit, 100));
var search = searchText == null ? "" : searchText.trim();
var timeoutSeconds = timeoutSeconds(properties.queryTimeout());
var 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));
try (Connection connection = DriverManager.getConnection(properties.dbUrl(), connectionProperties)) {
connection.setReadOnly(true);
var sessionUser = readSingleValue(connection,
"SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM dual", timeoutSeconds);
var endUser = readSingleValue(connection,
"SELECT ORA_END_USER_CONTEXT.username FROM dual", timeoutSeconds);
var sql = "SELECT customer_id, full_name, email, signup_date, region FROM "
+ source.objectName()
+ (search.isBlank() ? "" : " WHERE UPPER(full_name) LIKE ?")
+ " ORDER BY customer_id FETCH FIRST " + limit + " ROWS ONLY";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setQueryTimeout(timeoutSeconds);
if (!search.isBlank()) {
statement.setString(1, "%" + search.toUpperCase(Locale.ROOT) + "%");
}
try (ResultSet resultSet = statement.executeQuery()) {
var rows = new java.util.ArrayList<DdsCustomerRow>();
while (resultSet.next()) {
Timestamp timestamp = resultSet.getTimestamp("signup_date");
rows.add(new DdsCustomerRow(
resultSet.getLong("customer_id"),
resultSet.getString("full_name"),
resultSet.getString("email"),
timestamp == null ? null : timestamp.toLocalDateTime(),
resultSet.getString("region")
));
}
return new DdsQueryResult(
normalizedUserKey, normalizedSourceKey, userLabel, source.label(), source.objectName(),
true, "DDS 조회가 완료되었습니다.",
rows.isEmpty() ? "조건에 맞는 행이 없습니다." : rows.size() + "개 행이 DDS 정책을 통과했습니다.",
sessionUser, endUser, null, rows
);
}
}
} catch (SQLException e) {
return failure(normalizedUserKey, normalizedSourceKey, userLabel, source,
classifyTitle(e), classifyMessage(e), oracleCode(e));
}
}
private DdsQueryResult failure(
String userKey,
String sourceKey,
String userLabel,
DdsSourceOption source,
String title,
String message,
Integer oracleCode
) {
return new DdsQueryResult(
userKey, sourceKey, userLabel, source.label(), source.objectName(),
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 static String normalizeUserKey(String value) {
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
}
private static String normalizeSourceKey(String value) {
var normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
if (!SAFE_SOURCE.matcher(normalized).matches()) {
throw new IllegalArgumentException("지원하지 않는 DDS 데이터 소스입니다.");
}
return normalized;
}
private static String jdbcUsername(String username) {
var normalized = username == null ? "" : username.trim();
if (normalized.length() >= 2 && normalized.startsWith("\"") && normalized.endsWith("\"")) {
return normalized;
}
// The standalone SQL creates quoted lower-case END USER names. Oracle's
// JDBC driver uppercases an unquoted username, so preserve that identity.
if (!normalized.isEmpty() && normalized.equals(normalized.toLowerCase(Locale.ROOT))) {
return "\"" + normalized.replace("\"", "\"\"") + "\"";
}
return normalized;
}
private static int timeoutSeconds(Duration duration) {
return Math.max(1, (int) Math.ceil(duration.toMillis() / 1000.0));
}
private static String valueOrDefault(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value;
}
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) {
var 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) {
var message = flattenedMessage(exception).toUpperCase(Locale.ROOT);
if (message.contains("ORA-00942")) {
return "이 DDS 사용자에게 데이터가 허용되지 않았습니다.";
}
if (message.contains("ORA-01017")) {
return "DDS 사용자 인증에 실패했습니다.";
}
if (message.contains("ORA-12154") || message.contains("ORA-12514")
|| message.contains("IO ERROR") || message.contains("CONNECTION")) {
return "DDS 데이터베이스에 연결할 수 없습니다.";
}
return "DDS 조회 중 데이터베이스 오류가 발생했습니다.";
}
private static String classifyMessage(SQLException exception) {
var message = flattenedMessage(exception);
var upper = message.toUpperCase(Locale.ROOT);
if (upper.contains("ORA-00942")) {
return "해당 END USER에 연결된 DATA ROLE의 DATA GRANT가 없어 보호 객체가 보이지 않습니다. DDS의 default deny 결과입니다.";
}
if (upper.contains("ORA-01017")) {
return "서버에 등록된 DDS END USER 비밀번호와 일치하지 않습니다.";
}
if (upper.contains("ORA-12154") || upper.contains("ORA-12514")) {
return "TNS 별칭 또는 서비스 이름을 확인하세요.";
}
return "DB 접속 정보와 DDS DATA GRANT 설정을 확인하세요. 상세 코드는 운영 로그에서 확인합니다.";
}
}

View File

@@ -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 접속 정보를 확인하세요.";
}
}

View File

@@ -0,0 +1,76 @@
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;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class DdsController {
private final DdsQueryService service;
private final PermissionService permissionService;
public DdsController(DdsQueryService service, PermissionService permissionService) {
this.service = service;
this.permissionService = permissionService;
}
@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";
}
@PostMapping("/dds/query")
public String query(
@RequestParam String userKey,
@RequestParam String sourceKey,
@RequestParam(required = false) String searchText,
@RequestParam(defaultValue = "20") int limit,
Model model
) {
DdsQueryResult result;
try {
result = service.query(userKey, sourceKey, searchText, limit);
} catch (IllegalArgumentException e) {
result = new DdsQueryResult(
userKey, sourceKey, userKey, sourceKey, "-", false,
"입력값을 확인하세요.", e.getMessage(), null, null, null, java.util.List.of()
);
}
addOptions(model, userKey, sourceKey, result);
return "dds";
}
private void addOptions(Model model, String userKey, String sourceKey, DdsQueryResult result) {
var users = service.users();
model.addAttribute("users", users);
model.addAttribute("configuredUserCount", users.stream().filter(user -> user.configured()).count());
model.addAttribute("sources", service.sources());
model.addAttribute("selectedUser", userKey);
model.addAttribute("selectedSource", sourceKey);
model.addAttribute("queryResult", result);
}
}

View File

@@ -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");
}
}

View File

@@ -0,0 +1,52 @@
package com.cloudhandson.ddsbackoffice.web;
import com.cloudhandson.ddsbackoffice.domain.DdsProvisioningPlan;
import com.cloudhandson.ddsbackoffice.service.DdsGrantPublisher;
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.servlet.mvc.support.RedirectAttributes;
@Controller
public class DdsProvisionController {
private final DdsGrantPublisher publisher;
public DdsProvisionController(DdsGrantPublisher publisher) {
this.publisher = publisher;
}
@GetMapping("/dds-provision")
public String page(Model model) {
loadPlan(model);
return "dds-provision";
}
@PostMapping("/dds-provision/publish")
public String publish(RedirectAttributes redirectAttributes) {
try {
DdsProvisioningPlan plan = publisher.publish();
redirectAttributes.addFlashAttribute(
"successMessage",
plan.publishableCount() + "개 DDS DATA GRANT를 게시했습니다. 권한 없는 대상의 기존 Grant는 회수했습니다."
);
if (plan.hasWarnings()) {
redirectAttributes.addFlashAttribute("warningMessage", String.join(" / ", plan.warnings()));
}
} catch (DataAccessException | IllegalArgumentException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
}
return "redirect:/dds-provision";
}
private void loadPlan(Model model) {
try {
model.addAttribute("plan", publisher.preview());
} catch (DataAccessException | IllegalArgumentException exception) {
model.addAttribute("plan", new DdsProvisioningPlan(java.util.List.of(),
java.util.List.of(exception.getMessage()), 0));
}
}
}

View File

@@ -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";
}
}

View File

@@ -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());
}
}

View File

@@ -0,0 +1,14 @@
package com.cloudhandson.ddsbackoffice.web;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HealthController {
@GetMapping("/health")
public Map<String, String> health() {
return Map.of("status", "UP", "track", "DDS");
}
}

View File

@@ -0,0 +1,13 @@
package com.cloudhandson.ddsbackoffice.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class LoginController {
@GetMapping("/login")
public String login() {
return "login";
}
}

View File

@@ -0,0 +1,95 @@
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}
forward-headers-strategy: ${DDS_BACKOFFICE_FORWARD_HEADERS_STRATEGY:none}
servlet:
session:
cookie:
name: DDS_BACKOFFICE_SESSION
secure: ${DDS_BACKOFFICE_SESSION_COOKIE_SECURE:false}
http-only: true
same-site: lax
backoffice:
security:
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}
object-mappings:
CB_VECTOR_SEARCH_DOCUMENTS: ${DDS_BACKOFFICE_VECTOR_OBJECT:ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS}
CB_V_SEARCH_DOCUMENTS: ${DDS_BACKOFFICE_DOCUMENT_OBJECT:ADMIN.CB_DDS_V_SEARCH_DOCUMENTS}
users:
my:
label: MY 전용 사용자
description: MySQL 원본만 허용하는 DATA ROLE
username: ${DDS_BACKOFFICE_MY_USERNAME:dds_demo_my}
password: ${DDS_BACKOFFICE_MY_PASSWORD:${DDSUSER_MY_PASSWORD:}}
application-user-id: ${DDS_BACKOFFICE_MY_APPLICATION_USER_ID:101}
data-role: ${DDS_BACKOFFICE_MY_DATA_ROLE:dds_demo_my_role}
pg:
label: PG 전용 사용자
description: PostgreSQL 원본만 허용하는 DATA ROLE
username: ${DDS_BACKOFFICE_PG_USERNAME:dds_demo_pg}
password: ${DDS_BACKOFFICE_PG_PASSWORD:${DDSUSER_PG_PASSWORD:}}
application-user-id: ${DDS_BACKOFFICE_PG_APPLICATION_USER_ID:102}
data-role: ${DDS_BACKOFFICE_PG_DATA_ROLE:dds_demo_pg_role}
both:
label: 통합 사용자
description: 두 원본을 모두 허용하는 DATA ROLE
username: ${DDS_BACKOFFICE_BOTH_USERNAME:dds_demo_both}
password: ${DDS_BACKOFFICE_BOTH_PASSWORD:${DDSUSER_BOTH_PASSWORD:}}
application-user-id: ${DDS_BACKOFFICE_BOTH_APPLICATION_USER_ID:103}
data-role: ${DDS_BACKOFFICE_BOTH_DATA_ROLE:dds_demo_both_role}
none:
label: 차단 사용자
description: 접속만 가능하고 DATA GRANT가 없는 사용자
username: ${DDS_BACKOFFICE_NONE_USERNAME:dds_demo_none}
password: ${DDS_BACKOFFICE_NONE_PASSWORD:${DDSUSER_NONE_PASSWORD:}}
application-user-id: ${DDS_BACKOFFICE_NONE_APPLICATION_USER_ID:0}
data-role: ${DDS_BACKOFFICE_NONE_DATA_ROLE:dds_demo_none_role}

View File

@@ -0,0 +1,74 @@
:root {
--dds-ink: #17213a;
--dds-muted: #68738a;
--dds-line: #dfe5ef;
--dds-surface: #ffffff;
--dds-bg: #f4f7fb;
--dds-accent: #3157d5;
--dds-accent-dark: #203a9d;
--dds-success: #11765a;
--dds-warning: #9a6512;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--dds-bg); color: var(--dds-ink); font-family: Inter, "Noto Sans KR", system-ui, -apple-system, sans-serif; }
.dds-nav { background: #101a36; color: #fff; padding: 15px 0; box-shadow: 0 2px 12px rgba(16, 26, 54, .18); }
.brand { color: #fff; text-decoration: none; font-weight: 700; letter-spacing: -.02em; }
.track-badge { border: 1px solid rgba(255,255,255,.28); border-radius: 999px; color: #dbe4ff; font-size: .78rem; padding: 4px 10px; }
.hero { padding: 48px 0 26px; max-width: 920px; }
.hero h1 { font-size: clamp(2rem, 4vw, 3.2rem); letter-spacing: -.05em; line-height: 1.15; margin: 10px 0 15px; }
.hero .lead { color: var(--dds-muted); font-size: 1.1rem; line-height: 1.75; max-width: 820px; }
.eyebrow { color: var(--dds-accent); font-size: .72rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
.dds-nav .track-badge, .dds-nav .eyebrow { color: #dbe4ff; }
.explanation { background: #edf2ff; border: 1px solid #d4defe; border-radius: 12px; color: #34466e; margin-top: 22px; padding: 14px 17px; }
.explanation summary { cursor: pointer; font-weight: 700; }
.explanation p { line-height: 1.65; margin: 12px 0 0; }
.flow-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 5px 0 28px; }
.flow-card { align-items: flex-start; background: var(--dds-surface); border: 1px solid var(--dds-line); border-radius: 14px; display: flex; gap: 14px; min-height: 142px; padding: 20px; }
.flow-number { align-items: center; background: #e8edff; border-radius: 50%; color: var(--dds-accent); display: inline-flex; flex: 0 0 32px; font-weight: 800; height: 32px; justify-content: center; }
.result-number { background: #e2f4ed; color: var(--dds-success); }
.flow-card h2 { font-size: 1rem; margin: 5px 0 7px; }
.flow-card p { color: var(--dds-muted); font-size: .9rem; line-height: 1.55; margin: 0; }
.panel { background: var(--dds-surface); border: 1px solid var(--dds-line); border-radius: 16px; margin: 18px 0; padding: 25px; box-shadow: 0 5px 18px rgba(31, 51, 93, .04); }
.panel-heading { align-items: flex-start; display: flex; justify-content: space-between; gap: 16px; }
.panel-heading h2 { font-size: 1.35rem; letter-spacing: -.03em; margin: 6px 0 6px; }
.panel-heading p { color: var(--dds-muted); margin: 0; }
.query-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; margin-top: 22px; }
.query-grid label { color: #34415c; font-size: .9rem; font-weight: 700; }
.query-grid .form-control, .query-grid .form-select { margin-top: 7px; }
.field-help { color: var(--dds-muted); display: block; font-size: .78rem; font-weight: 400; margin-top: 6px; }
.query-submit { align-items: end; display: flex; }
.query-submit .btn { min-height: 45px; width: 100%; }
.matrix-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 20px; }
.matrix-card { background: #f5f7ff; border: 1px solid #dce3ff; border-radius: 12px; display: flex; flex-direction: column; gap: 6px; min-height: 118px; padding: 16px; }
.matrix-card strong { color: var(--dds-accent-dark); }
.matrix-card span { font-size: .9rem; font-weight: 700; }
.matrix-card small { color: var(--dds-muted); line-height: 1.4; }
.matrix-card.muted { background: #f8f8f8; border-color: #e5e5e5; }
.matrix-card.muted strong { color: #5b6270; }
.result-heading { align-items: center; }
.result-heading p { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; }
.result-status { border-radius: 999px; color: var(--dds-warning); background: #fff4dc; font-size: .8rem; font-weight: 800; padding: 7px 12px; }
.result-status.success { color: var(--dds-success); background: #e3f6ed; }
.result-status.blocked { color: #a34242; background: #fde8e8; }
.technical-code { color: var(--dds-muted); display: block; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .78rem; margin-top: 5px; }
.context-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 18px 0; }
.context-grid > div { background: #f7f9fc; border: 1px solid var(--dds-line); border-radius: 10px; padding: 12px 14px; }
.context-grid span { color: var(--dds-muted); display: block; font-size: .72rem; font-weight: 700; letter-spacing: .05em; }
.context-grid strong { display: block; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; margin-top: 5px; }
.table { margin-top: 8px; }
.table thead th { color: var(--dds-muted); font-size: .75rem; letter-spacing: .05em; text-transform: uppercase; }
.empty-result { background: #f7f9fc; border-radius: 10px; color: var(--dds-muted); padding: 18px; text-align: center; }
.login-page { align-items: center; display: flex; justify-content: center; min-height: 100vh; padding: 22px; }
.login-card { background: #fff; border: 1px solid var(--dds-line); border-radius: 16px; box-shadow: 0 15px 45px rgba(31, 51, 93, .12); max-width: 420px; padding: 35px; width: 100%; }
.login-card h1 { font-size: 1.65rem; margin: 8px 0; }
.login-card p { color: var(--dds-muted); margin-bottom: 26px; }
@media (max-width: 800px) {
.flow-grid, .matrix-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.query-grid, .context-grid { grid-template-columns: 1fr; }
}
@media (max-width: 540px) {
.flow-grid, .matrix-grid { grid-template-columns: 1fr; }
.panel { padding: 19px; }
.hero { padding-top: 30px; }
}

View File

@@ -0,0 +1,48 @@
<!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-provision"><span class="journey-number">3</span><div><h2>3. DDS 권한 반영</h2><p>그룹 상속을 포함한 유효 권한을 DDS Grant로 게시합니다.</p><strong>Grant 미리보기·게시 →</strong></div></a>
<a class="journey-card" href="/dds"><span class="journey-number">4</span><div><h2>4. END USER 검증</h2><p>실제 DDS END USER로 접속해 허용·차단 결과를 확인합니다.</p><strong>직접 조회 실행 →</strong></div></a>
<a class="journey-card" href="/vector-knowledge"><span class="journey-number">5</span><div><h2>5. 지식 검색</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>

View File

@@ -0,0 +1,99 @@
<!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 · PUBLISH</span>
<h1>애플리케이션 권한을 DDS에 반영</h1>
<p class="context-summary">사용자·그룹·역할·테이블 권한을 계산한 뒤 DDS DATA GRANT로 게시합니다.</p>
<details class="explanation-details">
<summary>VPD와 DDS의 반영 차이 보기</summary>
<p>VPD는 요청 때마다 권한 테이블을 읽습니다. DDS는 같은 권한체계를 배포 시점에 DATA GRANT로 컴파일합니다. 그래서 권한 저장 후 이 화면에서 변경 내용을 미리 확인하고 게시해야 합니다.</p>
<p class="mb-0">그룹 자체를 DDS 그룹으로 복사하지는 않습니다. 애플리케이션 그룹의 역할 상속을 계산해 매핑된 DDS DATA ROLE에 하나의 최종 predicate로 합칩니다.</p>
</details>
</div>
<section th:replace="~{fragments/layout :: architectureStrip('vpd')}"></section>
<section class="content-band">
<details class="explanation-details">
<summary>VPD 권한과 DDS 객체의 매핑 기준</summary>
<div class="table-responsive mt-3">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>공통 권한 관리</th><th>DDS에서의 표현</th><th>반영 시점</th></tr></thead>
<tbody>
<tr><td>애플리케이션 사용자</td><td>매핑된 DDS END USER + DATA ROLE</td><td>게시할 때 매핑 확인</td></tr>
<tr><td>그룹과 그룹에 연결된 역할</td><td>그룹을 복사하지 않고 최종 predicate로 합산</td><td>미리보기·게시 때 계산</td></tr>
<tr><td>테이블·VIEW SELECT 권한</td><td>보호 객체별 <code>DATA GRANT ... WHERE ...</code></td><td>권한 변경 후 게시</td></tr>
<tr><td>원문 표시 허용 컬럼</td><td><code>AS SELECT</code> 또는 <code>ALL COLUMNS EXCEPT</code></td><td>권한 변경 후 게시</td></tr>
</tbody>
</table>
</div>
<p class="text-muted mt-3 mb-0">VPD는 요청마다 권한 테이블을 평가하지만, DDS는 게시된 Grant가 바뀔 때까지 이전 선언을 계속 사용합니다. 이 화면의 게시 버튼이 두 모델을 동기화하는 경계입니다.</p>
</details>
</section>
<div class="alert alert-success" th:if="${successMessage}" th:text="${successMessage}"></div>
<div class="alert alert-warning" th:if="${warningMessage}" th:text="${warningMessage}"></div>
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
<section class="content-band">
<div class="section-heading">
<div>
<span class="architecture-kicker">1 · COMPILE</span>
<h2>현재 유효 권한 미리보기</h2>
<p class="section-subtitle">직접 부여된 역할과 활성 그룹에서 상속된 역할을 합쳐 DDS Grant를 만듭니다.</p>
</div>
<span class="badge text-bg-secondary" th:text="${plan.mappedUserCount() + '명 매핑'}">0명 매핑</span>
</div>
<div class="alert alert-info" th:if="${plan.publishableCount() == 0}">
게시할 수 있는 ALLOW 권한이 없습니다. 보호 객체 매핑과 권한 규칙을 먼저 확인하세요.
</div>
<form method="post" action="/dds-provision/publish" class="mb-3" th:if="${plan.publishableCount() > 0}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<button class="btn rw-btn-primary" type="submit"
onclick="return confirm('현재 미리보기대로 DDS DATA GRANT를 교체하고 권한 없는 Grant를 회수할까요?')">미리보기대로 DDS에 게시</button>
</form>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead><tr><th>애플리케이션 주체</th><th>애플리케이션 객체</th><th>DDS 주체/객체</th><th>판정</th><th>predicate</th><th>컬럼 제외</th></tr></thead>
<tbody>
<tr th:each="grant : ${plan.grants()}">
<td><strong th:text="${grant.userLabel()}">사용자</strong><small class="d-block text-muted" th:text="${'userId=' + grant.applicationUserId() + ' · ' + grant.dataRole()}">mapping</small></td>
<td><code th:text="${grant.applicationObject()}">CB_VECTOR_SEARCH_DOCUMENTS</code></td>
<td><code th:text="${grant.ddsObject()}">ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS</code><small class="d-block text-muted" th:text="${grant.grantName()}">GRANT</small></td>
<td>
<span class="badge text-bg-success" th:if="${grant.publishable()}">게시 가능</span>
<span class="badge text-bg-warning" th:unless="${grant.publishable()}">검토 필요</span>
<small class="d-block text-muted" th:text="${grant.note()}">메모</small>
</td>
<td><code th:text="${grant.predicate() ?: '-'}">predicate</code></td>
<td th:text="${grant.excludedColumns() ?: '없음'}">없음</td>
</tr>
<tr th:if="${#lists.isEmpty(plan.grants())}"><td colspan="6" class="text-muted">매핑된 유효 권한에서 DDS Grant 대상을 찾지 못했습니다.</td></tr>
</tbody>
</table>
</div>
</section>
<section class="content-band" th:if="${plan.hasWarnings()}">
<div class="section-heading"><div><h2>반영 전 확인할 항목</h2><p class="section-subtitle">지원되지 않는 객체나 매핑 누락은 게시하지 않고 경고로 남깁니다. DDS 객체 매핑이 없는 기존 Grant도 이 화면에서는 건드리지 않습니다.</p></div></div>
<ul>
<li th:each="warning : ${plan.warnings()}" th:text="${warning}">경고</li>
</ul>
</section>
<section class="content-band" th:if="${plan.publishableCount() > 0}">
<details class="explanation-details">
<summary>게시되는 SQL 보기</summary>
<div th:each="grant : ${plan.grants()}" th:if="${grant.publishable()}" class="mt-3">
<strong th:text="${grant.grantName()}">GRANT</strong>
<pre class="code-block" th:text="${grant.sql()}">CREATE OR REPLACE DATA GRANT ...</pre>
</div>
</details>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,154 @@
<!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="hero">
<span class="eyebrow">DEEP DATA SECURITY · INDEPENDENT DEMO</span>
<h1>같은 접근 결과를 다른 보안 모델로 확인합니다.</h1>
<p class="lead">이 화면은 VPD 데모와 별도로 실행되는 DDS 전용 데모입니다. 선택한 END USER로 직접 접속해 Oracle의 DATA ROLE과 DATA GRANT가 적용된 결과를 보여줍니다.</p>
<details class="explanation">
<summary>이 화면의 보안 경계</summary>
<p>이 인스턴스는 VPD 권한 테이블을 읽어 조건을 계산하지 않습니다. <code>END USER → DATA ROLE → DATA GRANT → 보호 VIEW</code>가 전부 Oracle DDS에 선언되어 있고, 조회 결과는 그 선언의 적용 결과입니다.</p>
<p class="mb-0">두 데모는 같은 접근 매트릭스를 비교하지만 실행 모델은 다릅니다. VPD는 공통 계정·Bearer·동적 predicate를 사용하고, DDS는 직접 END USER와 선언형 DATA GRANT를 사용합니다. Bearer 키를 ORDS Handler에서 사용자 이름으로 바꾸는 것만으로는 DDS 보안 컨텍스트가 만들어지지 않습니다.</p>
</details>
</header>
<section th:replace="~{fragments/layout :: architectureStrip('token')}"></section>
<section class="flow-grid" aria-label="DDS 접근 흐름">
<article class="flow-card">
<span class="flow-number">1</span>
<div><span class="eyebrow">IDENTITY</span><h2>END USER</h2><p>실제 DDS 보안 사용자로 DB에 접속합니다.</p></div>
</article>
<article class="flow-card">
<span class="flow-number">2</span>
<div><span class="eyebrow">POLICY</span><h2>DATA ROLE / GRANT</h2><p>원본별 허용 범위가 선언형으로 연결됩니다.</p></div>
</article>
<article class="flow-card">
<span class="flow-number">3</span>
<div><span class="flow-number result-number">3</span><span class="eyebrow">EVIDENCE</span><h2>조회 결과</h2><p>허용된 VIEW와 행만 반환됩니다.</p></div>
</article>
</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}">
DDS 사용자 비밀번호가 설정되지 않았습니다. VM에서는 <code>DDSUSER_*_PASSWORD</code> 또는 <code>DDS_BACKOFFICE_*_PASSWORD</code> 환경 변수를 설정해야 조회할 수 있습니다.
</div>
<section class="panel">
<div class="panel-heading">
<div>
<span class="eyebrow">1 · SECURITY SUBJECT</span>
<h2>조회 주체와 데이터 원본 선택</h2>
<p>선택한 DDS END USER의 권한으로 직접 연결합니다. 비밀번호는 화면에 표시하거나 저장하지 않습니다.</p>
</div>
</div>
<form method="post" action="/dds/query" class="query-grid">
<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 : ${users}" th:value="${user.key()}" th:selected="${user.key() == selectedUser}"
th:text="${user.label() + ' · ' + user.username()}"></option>
</select>
<span class="field-help" th:each="user : ${users}" th:if="${user.key() == selectedUser}" th:text="${user.description()}"></span>
</label>
<label>
보호 데이터 원본
<select class="form-select" name="sourceKey" required>
<option th:each="source : ${sources}" th:value="${source.key()}" th:selected="${source.key() == selectedSource}"
th:text="${source.label()}"></option>
</select>
<span class="field-help" th:each="source : ${sources}" th:if="${source.key() == selectedSource}" th:text="${source.objectName()}"></span>
</label>
<label>
이름 검색 (선택)
<input class="form-control" name="searchText" placeholder="예: Alice">
<span class="field-help">검색 조건도 보호 VIEW 안에서만 평가됩니다.</span>
</label>
<label>
최대 행 수
<input class="form-control" name="limit" type="number" min="1" max="100" value="20">
</label>
<div class="query-submit"><button class="btn btn-primary" type="submit">DDS 권한으로 조회</button></div>
</form>
</section>
<section class="panel">
<div class="panel-heading">
<div>
<span class="eyebrow">2 · EXPECTED MATRIX</span>
<h2>원본별 권한 매트릭스</h2>
<p>VPD 데모와 같은 결과를 별도 데이터·권한 객체에서 DDS 선언형 모델로 비교합니다.</p>
</div>
</div>
<div class="matrix-grid">
<div class="matrix-card"><strong>dds_demo_my</strong><span>MY 데이터셋 허용</span><small>PG 객체는 ORA-00942</small></div>
<div class="matrix-card"><strong>dds_demo_pg</strong><span>PG 데이터셋 허용</span><small>MY 객체는 ORA-00942</small></div>
<div class="matrix-card"><strong>dds_demo_both</strong><span>PG + MY 허용</span><small>두 DATA GRANT 모두 적용</small></div>
<div class="matrix-card muted"><strong>dds_demo_none</strong><span>접속만 허용</span><small>데이터 권한 없음 · default deny</small></div>
</div>
</section>
<section class="panel" th:if="${queryResult != null}">
<div class="panel-heading result-heading">
<div>
<span class="eyebrow">3 · EVIDENCE</span>
<h2 th:text="${queryResult.userLabel() + ' · ' + queryResult.sourceLabel()}">조회 결과</h2>
<p th:text="${queryResult.objectName()}">ADMIN.V_DDS_CUSTOMERS_PG</p>
</div>
<span class="result-status" th:classappend="${queryResult.success()} ? ' success' : ' blocked'"
th:text="${queryResult.success()} ? '정책 적용됨' : '접근 차단'">상태</span>
</div>
<div class="alert" th:classappend="${queryResult.success()} ? ' alert-success' : ' alert-warning'">
<strong th:text="${queryResult.title()}">결과</strong>
<span th:text="${queryResult.message()}">메시지</span>
<span th:if="${queryResult.oracleCode() != null}" class="technical-code" th:text="${'Oracle code: ' + queryResult.oracleCode()}"></span>
</div>
<div class="context-grid" th:if="${queryResult.success()}">
<div><span>SESSION_USER</span><strong th:text="${queryResult.sessionUser() ?: '-'}">-</strong></div>
<div><span>ORA_END_USER_CONTEXT</span><strong th:text="${queryResult.endUser() ?: '-'}">-</strong></div>
<div><span>반환 행</span><strong th:text="${#lists.size(queryResult.rows())}">0</strong></div>
</div>
<div class="table-responsive" th:if="${queryResult.success() and queryResult.hasRows()}">
<table class="table align-middle">
<thead><tr><th>ID</th><th>이름</th><th>이메일</th><th>가입일</th><th>지역</th></tr></thead>
<tbody>
<tr th:each="row : ${queryResult.rows()}">
<td th:text="${row.customerId()}">1</td>
<td th:text="${row.fullName()}">Alice</td>
<td th:text="${row.email() ?: 'NULL'}">alice@example.com</td>
<td th:text="${row.signupDate() ?: '-'}">2026-01-01</td>
<td th:text="${row.region()}">APAC</td>
</tr>
</tbody>
</table>
</div>
<div class="empty-result" th:if="${queryResult.success() and !queryResult.hasRows()}">조회 조건을 통과한 행이 없습니다.</div>
<details class="explanation">
<summary>판정 근거와 운영 메모</summary>
<p>성공한 경우 연결 시점의 <code>SESSION_USER</code><code>ORA_END_USER_CONTEXT</code>를 함께 확인할 수 있습니다. 차단된 경우 DDS가 보호 객체 자체를 숨겨 <code>ORA-00942</code>를 반환하는 것이 정상입니다.</p>
<p class="mb-0">현재 앱은 결과만 읽습니다. 권한 변경은 <code>CREATE DATA ROLE</code>, <code>GRANT DATA ROLE</code>, <code>CREATE DATA GRANT</code> 승인 절차를 거쳐 SQL로 관리합니다.</p>
</details>
</section>
</main>
</body>
</html>

View File

@@ -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>

View File

@@ -0,0 +1,102 @@
<!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="/dds-provision" th:if="${backofficeTrack == 'DDS'}">DDS 권한 반영</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>

View File

@@ -0,0 +1,27 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DDS Permission Console 로그인</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 class="login-page">
<main class="login-card">
<span class="eyebrow">DEEP DATA SECURITY</span>
<h1>DDS Permission Console</h1>
<p>VPD와 분리된 DDS 전용 인스턴스입니다.</p>
<div class="alert alert-danger" th:if="${param.error}">관리자 인증에 실패했습니다.</div>
<div class="alert alert-success" th:if="${param.logout}">로그아웃되었습니다.</div>
<form method="post" action="/login">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label class="form-label" for="username">관리자 ID</label>
<input class="form-control mb-3" id="username" name="username" autocomplete="username" required autofocus>
<label class="form-label" for="password">비밀번호</label>
<input class="form-control mb-4" id="password" name="password" type="password" autocomplete="current-password" required>
<button class="btn btn-primary w-100" type="submit">로그인</button>
</form>
</main>
</body>
</html>

View File

@@ -0,0 +1,148 @@
<!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>
<div class="d-flex gap-2">
<a class="btn btn-sm rw-btn-secondary" href="/permissions">권한 관리 열기</a>
<a class="btn btn-sm rw-btn-primary" href="/dds-provision">DDS 권한 반영</a>
</div>
</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>

View File

@@ -0,0 +1,110 @@
<!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>
<div class="d-flex gap-2">
<a class="btn btn-sm rw-btn-secondary" href="/permissions">권한 규칙 열기</a>
<a class="btn btn-sm rw-btn-primary" href="/dds-provision">DDS 권한 반영</a>
</div>
</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>

View File

@@ -0,0 +1,59 @@
package com.cloudhandson.ddsbackoffice.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
class DdsPropertiesTest {
@Test
void normalizesObjectsAndUserKeys() {
var properties = new DdsProperties(
"jdbc:test",
Duration.ofSeconds(5),
"admin.v_dds_customers_pg",
"admin.v_dds_customers_my",
Map.of("MY", new DdsProperties.User("MY", "source", "ddsuser_my", "secret"))
);
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());
}
@Test
void rejectsUnsafeObjectNames() {
assertThrows(IllegalArgumentException.class, () -> new DdsProperties(
"jdbc:test", Duration.ofSeconds(5), "ADMIN.VIEW;DROP", "ADMIN.VIEW", Map.of()
));
}
@Test
void bindsApplicationUserAndDataRoleForDdsProvisioning() {
var source = new MapConfigurationPropertySource(Map.of(
"dds.users.my.label", "MY",
"dds.users.my.description", "source",
"dds.users.my.username", "ddsuser_my",
"dds.users.my.password", "secret",
"dds.users.my.application-user-id", "101",
"dds.users.my.data-role", "dds_demo_my_role"
));
var properties = new Binder(source)
.bind("dds", Bindable.of(DdsProperties.class))
.orElseThrow(() -> new AssertionError("dds properties did not bind"));
assertEquals(101L, properties.users().get("my").applicationUserId());
assertEquals("dds_demo_my_role", properties.users().get("my").dataRole());
assertTrue(properties.users().get("my").mapped());
}
}

View File

@@ -0,0 +1,37 @@
package com.cloudhandson.ddsbackoffice.service;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
import java.time.Duration;
import java.util.Map;
import org.junit.jupiter.api.Test;
class DdsQueryServiceTest {
@Test
void reportsMissingDatabaseConfigurationWithoutOpeningAConnection() {
var properties = new DdsProperties(
"", Duration.ofSeconds(5), "ADMIN.V_DDS_CUSTOMERS_PG", "ADMIN.V_DDS_CUSTOMERS_MY",
Map.of("my", new DdsProperties.User("MY", "source", "ddsuser_my", "secret"))
);
var result = new DdsQueryService(properties).query("my", "PG", "", 20);
assertFalse(result.success());
assertTrue(result.title().contains("DB 접속"));
}
@Test
void rejectsUnknownSourceBeforeOpeningAConnection() {
var properties = new DdsProperties(
"jdbc:test", Duration.ofSeconds(5), "ADMIN.V_DDS_CUSTOMERS_PG", "ADMIN.V_DDS_CUSTOMERS_MY",
Map.of("my", new DdsProperties.User("MY", "source", "ddsuser_my", "secret"))
);
assertThrows(IllegalArgumentException.class,
() -> new DdsQueryService(properties).query("my", "RDS", "", 20));
}
}

View File

@@ -1,7 +1,7 @@
# 설계서: 기술 태그 기반 벡터 지식자료 검색과 VPD 연결 # 설계서: 기술 태그 기반 벡터 지식자료 검색과 VPD 연결
> **상태**: 데모 흐름 구현 > **상태**: 제품형 운영 흐름 구현 · DDS 병행 검증 완료 · 운영 확장 항목 별도
> **추적성**: Redmine #565 · 기준 구현: `28_agent_ords_vector_tag_vpd_setup.sql`, `29_agent_ords_vector_search_ords.sql` > **추적성**: Redmine #565, #566 · 기준 구현: `28_agent_ords_vector_tag_vpd_setup.sql`, `29_agent_ords_vector_search_ords.sql`, `32_dds_vector_tag_setup.sql`
## 1. 한 문장으로 이해하기 ## 1. 한 문장으로 이해하기
@@ -70,7 +70,7 @@ AND NOT (거부 태그 C OR 거부 태그 D)
## 4. 예시 시나리오 ## 4. 예시 시나리오
샘플 데이터에는 다음과 같은 청크가 있다. 기본 제공 지식자료에는 다음과 같은 검색 단위가 있다.
| 청크 | 태그 | | 청크 | 태그 |
|---|---| |---|---|
@@ -147,16 +147,16 @@ backoffice의 `/objects` 화면은 이 객체에 일반 Handler를 생성하지
→ CB_VECTOR_DOCUMENT_TAG에 태그 저장 → CB_VECTOR_DOCUMENT_TAG에 태그 저장
``` ```
백오피스의 `/vector-knowledge` 화면에서 이 흐름을 데모로 실행할 수 있다. 백오피스의 `/vector-knowledge` 화면에서 이 흐름을 운영 절차와 같은 순서로 확인할 수 있다.
- 문서 ID·제목·본문·기술 태그를 입력하면 문단/길이 기준으로 청크를 만든다. - 문서 ID·제목·본문·기술 태그를 입력하면 문단/길이 기준으로 청크를 만든다.
- `DEMO-4D`는 외부 API 없이 샘플 데이터와 같은 4차원 벡터를 만드는 재현 모드다. - `DEMO-4D`는 외부 API 없이 기본 제공 자료와 같은 4차원 벡터를 만드는 개발용 로컬 임베딩 모드다.
- `AI`를 선택하면 `BACKOFFICE_AI_BASE_URL/v1/embeddings` - `AI`를 선택하면 `BACKOFFICE_AI_BASE_URL/v1/embeddings`
`BACKOFFICE_AI_EMBEDDING_MODEL`을 사용해 청크마다 실제 임베딩을 생성한다. `BACKOFFICE_AI_EMBEDDING_MODEL`을 사용해 청크마다 실제 임베딩을 생성한다.
- 같은 방식으로 검색어를 임베딩하고, 임시 토큰으로 전용 ORDS Handler를 호출한다. - 같은 방식으로 검색어를 임베딩하고, 임시 토큰으로 전용 ORDS Handler를 호출한다.
- VPD가 `TECH_TAG`를 먼저 필터링한 뒤 남은 청크만 벡터 거리순으로 반환한다. - VPD가 `TECH_TAG`를 먼저 필터링한 뒤 남은 청크만 벡터 거리순으로 반환한다.
`DEMO-4D`는 의미 기반 품질을 보장하는 모델이 아니라 권한 흐름을 재현하기 위한 고정 차원 예제다. 운영 검색은 ingestion과 검색에 같은 임베딩 모델·차원을 사용해야 한다. `DEMO-4D`는 의미 기반 품질을 보장하는 모델이 아니라 권한 흐름을 확인하기 위한 고정 차원 개발 모드다. 운영 검색은 ingestion과 검색에 같은 임베딩 모델·차원을 사용해야 한다.
## 7. 운영 가이드라인 ## 7. 운영 가이드라인
@@ -180,5 +180,29 @@ backoffice의 `/objects` 화면은 이 객체에 일반 Handler를 생성하지
## 9. 아직 별도 운영 설계가 필요한 부분 ## 9. 아직 별도 운영 설계가 필요한 부분
- 대용량 파일 업로드, 비동기 작업 큐, 재시도·실패 격리, 임베딩 모델 버전별 재색인은 운영 파이프라인에서 별도로 설계한다. - 대용량 파일 업로드, 비동기 작업 큐, 재시도·실패 격리, 임베딩 모델 버전별 재색인은 운영 파이프라인에서 별도로 설계한다.
- 태그 사전 승인 워크플로와 문서별 소유자/보존기간 정책은 현재 데모 화면의 범위를 넘어선다. - 태그 사전 승인 워크플로와 문서별 소유자/보존기간 정책은 현재 화면의 범위를 넘어선다.
- 운영 DB에 대한 DDL, 기존 정책 교체, 방화벽/NSG 변경은 별도 승인을 받아 실행한다. - 운영 DB에 대한 DDL, 기존 정책 교체, 방화벽/NSG 변경은 별도 승인을 받아 실행한다.
## 10. DDS 병행 시나리오: 같은 TAG 권한, 단일 기술 사용자
VPD에서 검증한 기술 태그 권한을 DDS에서도 고객별 DDS 계정으로 복제하지 않고 적용할 수 있는지 별도로 확인했다. 업무 권한의 원천은 동일한 `CB_PERMISSION`·`CB_PERMISSION_RULE`이다. DDS 설치 스크립트는 이 규칙을 벡터 검색 뷰와 연결하고, 애플리케이션 함수가 토큰을 받아 같은 규칙을 `WHERE`로 집행한다.
```text
Bearer token
→ CB_AGENT_BEARER_KEY에서 사용자 식별
→ CB_APP_USER의 직접 역할/활성 그룹 역할 계산
→ ALLOW TAG는 OR, DENY TAG는 NOT EXISTS
→ CB_DDS_VECTOR_SEARCH_DOCUMENTS 조회
```
하나의 `dds_demo_both` DDS 기술 사용자 세션에서 임시 `CB_SIMPLE_DDS_TOKEN_SELECT(token)` 함수를 실행한 결과는 다음과 같다.
| 토큰 사용자 | 반환 청크 |
|---|---|
| `agent_hr` | `28001`, `28002` (2건) |
| `agent_fin_self` | `28002` (1건) |
| `agent_all` | `28001`, `28002`, `28003` (3건) |
이 방식의 의미는 “DDS가 토큰을 자동으로 이해한다”가 아니다. `DATA GRANT`에 호출 시점 토큰 인자를 직접 넘기는 것이 아니라, 토큰을 받는 함수/API가 공통 권한 테이블을 조회해 보호 뷰의 `WHERE`를 결정한다. 따라서 현재 제품형 기본 시나리오는 **단일 DDS 기술 사용자 + 토큰 WHERE 어댑터 + 공통 업무 권한**이다. 드라이버가 SQL 실행 전에 `EndUserSecurityContext`를 전달하는 순수 DDS Context 방식은 별도 확장 경계이며, 그 경우에만 `ORA_END_USER_CONTEXT`를 참조하는 일반 `DATA GRANT`를 설계한다.
토큰, 임시 함수, 임시 권한은 검증 뒤 삭제했다. 새 태그나 역할을 추가할 때도 VPD와 DDS가 각각 고객 사용자를 복제하는 대신, 공통 `CB_*` 권한을 먼저 수정하고 두 집행 경계의 회귀 테스트를 함께 실행한다.

View File

@@ -0,0 +1,99 @@
# 독립 VPD·DDS 데모 구성
> **상태**: VPD·DDS 병행 검증 완료, 운영 전환 항목 별도
> **추적성**: Redmine #565, #566 · DDS 구현 커밋 `1e48864`
> **기준 구현**: `sql/adb/32_dds_vector_tag_setup.sql`, `dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsGrantPublisher.java`
## 목적
VPD와 Oracle Deep Data Security(DDS)를 별도 인스턴스로 운영하면서도, 고객의 업무 권한을 정의하는 공통 테이블을 기준으로 같은 검색 시나리오를 비교한다. 고객마다 DDS 계정을 만드는 것이 목표가 아니다. 현재 기본 데모는 하나의 DDS 기술 사용자 세션에 Bearer 토큰을 함수 파라미터로 전달하고, 함수가 공통 권한 테이블을 읽어 `WHERE` 조건으로 결과를 제한한다.
## 인스턴스 경계
| 구분 | VPD Demo | DDS Demo |
|---|---|---|
| 실행 디렉터리 | `/home/opc/apps/vpd-backoffice` | `/home/opc/apps/dds-backoffice` |
| 기본 포트 | `8082` | `8083` |
| 사용자 식별 | 공통 DB 계정 + Bearer/세션 컨텍스트 | 단일 DDS 기술 사용자 + 토큰 파라미터 함수(기본 경로) |
| 권한 기준 | 업무 권한 테이블 + VPD 함수 | 같은 업무 권한 테이블 + 토큰 기반 `WHERE`; DDS `DATA ROLE/GRANT`는 별도 경계 |
| 보호 객체 | `V_CUSTOMERS_*` / VPD 전용 객체 | `V_DDS_CUSTOMERS_*` / DDS 전용 객체 |
| 운영 프로세스 | VPD start/stop/status | DDS start/stop/status |
두 인스턴스는 화면, JAR, PID, 로그, 설정 파일을 공유하지 않는다. 다만 권한의 업무 원천(source of truth)은 `CB_APP_USER`, 그룹·역할, `CB_PERMISSION`, `CB_PERMISSION_RULE` 같은 공통 테이블로 유지하고, 각 보안 엔진이 자기 방식으로 그 결과를 집행한다. DDS 벡터 설치 스크립트는 같은 TAG 권한을 DDS 검색 뷰와 연결한다.
## 동일하게 비교할 수 있는 것
- 사용자별 원본 접근 범위
- 행 단위 허용/차단
- 권한 없는 사용자의 default deny
- 보호 VIEW 우회 시도 차단
## DDS 기본 시나리오: 한 사용자, 토큰 파라미터, 공통 `WHERE`
### 일반 사용자가 이해하는 흐름
```text
Bearer 토큰
→ 토큰 해시로 CB_AGENT_BEARER_KEY 조회
→ CB_APP_USER와 연결
→ 직접 역할 + 활성 그룹 역할 계산
→ CB_PERMISSION_RULE의 TAG 허용/거부 규칙 계산
→ CB_DDS_VECTOR_SEARCH_DOCUMENTS에 함수가 WHERE 적용
→ 허용된 청크만 반환
```
`dds_demo_both` 하나의 DDS 기술 사용자 세션에서 임시 함수 `ADMIN.CB_SIMPLE_DDS_TOKEN_SELECT(token)`로 위 흐름을 검증했다. 함수는 고객용 DDS 계정을 새로 만들지 않고 토큰으로 업무 사용자를 식별한다. 같은 ALLOW 권한의 태그는 OR로 합치고, DENY 태그는 `NOT EXISTS`로 제외한다.
호출 계약은 의도적으로 단순하다.
```sql
SELECT ADMIN.CB_SIMPLE_DDS_TOKEN_SELECT(:bearer_token)
FROM dual;
```
실제 함수 내부의 조회 조건은 다음 의미를 가진다.
```sql
WHERE EXISTS ( TAG TAG와 )
AND NOT EXISTS ( TAG TAG와 )
```
즉, 토큰은 사용자 식별 입력이고 권한 결정은 기존 `CB_*` 테이블이 담당한다. 토큰별로 DDS 계정을 바꾸거나 고객 사용자마다 DDS `END USER`를 만드는 단계는 없다.
### 검증 결과
| 토큰으로 식별된 업무 사용자 | TAG 권한 결과 | 반환 청크 |
|---|---|---|
| `agent_hr` | `SPRING_BOOT` OR `ORACLE_VPD` | `28001`, `28002` (2건) |
| `agent_fin_self` | `ORACLE_VPD` | `28002` (1건) |
| `agent_all` | 전체 허용 역할 | `28001`, `28002`, `28003` (3건) |
토큰 행, 임시 함수, 임시 권한은 검증 직후 삭제했다. 이 결과는 “DDS 사용자 수 = 고객 사용자 수”가 아니라 “DDS 기술 사용자 1개 + 고객 권한 테이블” 모델이 동작함을 보여 준다.
## 두 가지 DDS 집행 경계
| 구분 | 현재 기본 데모 | 순수 DDS Context 확장 |
|---|---|---|
| 토큰 전달 | 함수의 `token` 파라미터 | 지원 드라이버/ORDS가 `EndUserSecurityContext`로 전달 |
| 권한 계산 | 함수가 공통 `CB_*` 테이블을 읽어 `WHERE` 생성 | `DATA GRANT` predicate가 DDS 런타임 컨텍스트를 참조 |
| 사용자 객체 | DDS 기술 사용자 1개 | DDS END USER 또는 데이터 역할 매핑 필요 |
| 현재 상태 | 검증 완료 | 드라이버·ORDS 전달 경로를 별도 검증해야 함 |
중요한 경계는 `DATA GRANT` 자체가 호출 시점의 임의 함수 인자를 받는 구조가 아니라는 점이다. 따라서 현재 시나리오에서는 토큰을 받는 함수/API가 조회 경계가 되고, DDS는 그 함수가 조회하는 보호 뷰와 기술 사용자 세션을 보호한다. 향후 순수 DDS Context를 사용하려면 토큰을 SQL 실행 전에 드라이버가 붙이고, 그때만 `ORA_END_USER_CONTEXT` 기반 `DATA GRANT`를 적용한다. ORDS Handler가 Bearer 값을 DDS 사용자명으로 바꾸는 것만으로는 DDS Context가 자동 생성되지 않는다.
## 실행 확인
```bash
/home/opc/apps/vpd-backoffice/status.sh
/home/opc/apps/dds-backoffice/status.sh
```
외부 공개 포트는 애플리케이션 분리와 별개의 운영 변경이다. DDS 기본 바인딩은 `127.0.0.1:8083`이며, 직접 공개하려면 HTTPS, NSG, VM firewalld 정책을 별도로 승인한다.
## 변경·확장 가이드
- 업무 사용자·역할·태그 권한은 기존 `CB_*` 테이블에서 관리한다. DDS 전용 고객 계정을 추가해 권한을 복제하지 않는다.
- 토큰 조회 함수는 해시된 Bearer 키만 사용하고 원문 토큰을 저장하거나 로그에 남기지 않는다.
- 새로운 TAG를 추가할 때는 태그 사전, `CB_PERMISSION_RULE`, 샘플 청크, 허용·거부 테스트를 함께 갱신한다.
- 함수 경계를 벗어나 직접 DDS 객체를 노출하는 Handler를 만들지 않는다. 벡터 검색은 임베딩을 반환하지 않는 전용 뷰/Handler를 사용한다.
- 운영에서 드라이버 기반 DDS Context로 전환할 때는 별도 설계·검증 이슈를 만들고, 현재의 토큰 WHERE 결과와 동일한 회귀 테스트를 통과시킨다.

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env bash
# Deploy the dedicated DDS backoffice beside the existing VPD instance.
set -Eeuo pipefail
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"
[[ -f "$JAR" ]] || { echo "missing jar: $JAR" >&2; exit 1; }
install -d -o opc -g opc "$APP_DIR"
cat > "$APP_DIR/start.sh" <<'START'
#!/usr/bin/env bash
set -Eeuo pipefail
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$APP_DIR"
set -a
if [[ -f /home/opc/apps/vpd-backoffice/.env ]]; then
. /home/opc/apps/vpd-backoffice/.env
fi
if [[ -f "$APP_DIR/.env" ]]; then
. "$APP_DIR/.env"
fi
set +a
export DDS_BACKOFFICE_PORT="${DDS_BACKOFFICE_PORT:-8083}"
export DDS_BACKOFFICE_BIND_ADDRESS="${DDS_BACKOFFICE_BIND_ADDRESS:-127.0.0.1}"
export DDS_BACKOFFICE_DB_URL="${DDS_BACKOFFICE_DB_URL:-${BACKOFFICE_DB_URL:-}}"
export DDS_BACKOFFICE_PG_PASSWORD="${DDS_BACKOFFICE_PG_PASSWORD:-${DDSUSER_PG_PASSWORD:-}}"
export DDS_BACKOFFICE_MY_PASSWORD="${DDS_BACKOFFICE_MY_PASSWORD:-${DDSUSER_MY_PASSWORD:-}}"
export DDS_BACKOFFICE_BOTH_PASSWORD="${DDS_BACKOFFICE_BOTH_PASSWORD:-${DDSUSER_BOTH_PASSWORD:-}}"
export DDS_BACKOFFICE_NONE_PASSWORD="${DDS_BACKOFFICE_NONE_PASSWORD:-${DDSUSER_NONE_PASSWORD:-}}"
PID_FILE="$APP_DIR/app.pid"
LOG_FILE="$APP_DIR/app.log"
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" >/dev/null 2>&1; then
echo "already running pid=$(cat "$PID_FILE")"
exit 0
fi
setsid nohup java -jar "$APP_DIR/app.jar" > "$LOG_FILE" 2>&1 < /dev/null &
echo $! > "$PID_FILE"
echo "started pid=$(cat "$PID_FILE") log=$LOG_FILE port=$DDS_BACKOFFICE_PORT"
START
cat > "$APP_DIR/stop.sh" <<'STOP'
#!/usr/bin/env bash
set -Eeuo pipefail
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PID_FILE="$APP_DIR/app.pid"
if [[ ! -f "$PID_FILE" ]]; then
echo "not running: pid file not found"
exit 0
fi
PID="$(cat "$PID_FILE")"
if kill -0 "$PID" >/dev/null 2>&1; then
kill "$PID"
for _ in {1..20}; do
kill -0 "$PID" >/dev/null 2>&1 || break
sleep 1
done
fi
rm -f "$PID_FILE"
echo "stopped"
STOP
cat > "$APP_DIR/status.sh" <<'STATUS'
#!/usr/bin/env bash
set -Eeuo pipefail
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PID_FILE="$APP_DIR/app.pid"
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" >/dev/null 2>&1; then
echo "running pid=$(cat "$PID_FILE")"
else
echo "stopped"
fi
STATUS
chmod +x "$APP_DIR/start.sh" "$APP_DIR/stop.sh" "$APP_DIR/status.sh"
if [[ ! -f "$APP_DIR/.env" ]]; then
cat > "$APP_DIR/.env" <<ENV
# DDS instance overrides. Secrets stay in the existing VPD env file or in this file.
export DDS_BACKOFFICE_PORT="$PORT"
export DDS_BACKOFFICE_BIND_ADDRESS="127.0.0.1"
ENV
chmod 600 "$APP_DIR/.env"
fi
"$APP_DIR/stop.sh" || true
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}/login"; then
break
fi
sleep 1
done
"$APP_DIR/status.sh"
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 /dds-provision /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})"

View File

@@ -48,19 +48,24 @@ BEGIN
p_source => q'! p_source => q'!
DECLARE DECLARE
v_rows SYS_REFCURSOR; v_rows SYS_REFCURSOR;
v_body_text CLOB;
v_embedding_text VARCHAR2(32767); v_embedding_text VARCHAR2(32767);
BEGIN BEGIN
cb_ords_handler_pkg.set_vpd_context(:auth_header); cb_ords_handler_pkg.set_vpd_context(:auth_header);
-- ORDS exposes :body_text as a stream bind. Read it exactly once; referring
-- to the stream bind twice causes ORA-17270 (Duplicate stream parameter).
v_body_text := :body_text;
-- JSON_QUERY accepts a numeric JSON array. The JSON_VALUE fallback also -- JSON_QUERY accepts a numeric JSON array. The JSON_VALUE fallback also
-- permits clients that send the vector as the text "[0.1,0.2,...]". -- permits clients that send the vector as the text "[0.1,0.2,...]".
v_embedding_text := JSON_QUERY( v_embedding_text := JSON_QUERY(
:body_text, v_body_text,
'$.embedding' RETURNING VARCHAR2(32767) '$.embedding' RETURNING VARCHAR2(32767)
); );
IF v_embedding_text IS NULL THEN IF v_embedding_text IS NULL THEN
v_embedding_text := JSON_VALUE( v_embedding_text := JSON_VALUE(
:body_text, v_body_text,
'$.embedding' RETURNING VARCHAR2(32767) '$.embedding' RETURNING VARCHAR2(32767)
); );
END IF; END IF;

View File

@@ -0,0 +1,119 @@
-- ============================================================
-- 31_dds_standalone_demo_setup.sql
-- Independent local Deep Data Security demo.
--
-- This script deliberately does not use VPD views, application
-- permission tables, or external database links. It creates a
-- local PG/MY-shaped matrix so the DDS demo can run independently
-- when the VPD demo's RDS links are unavailable.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
SET DEFINE ON
SET VERIFY OFF
PROMPT === 1. Creating DDS-only local source tables ===
BEGIN
EXECUTE IMMEDIATE 'DROP VIEW v_dds_customers_pg';
EXCEPTION WHEN OTHERS THEN NULL;
END;
/
BEGIN
EXECUTE IMMEDIATE 'DROP VIEW v_dds_customers_my';
EXCEPTION WHEN OTHERS THEN NULL;
END;
/
BEGIN
EXECUTE IMMEDIATE 'DROP TABLE cb_dds_demo_customers_pg PURGE';
EXCEPTION WHEN OTHERS THEN NULL;
END;
/
BEGIN
EXECUTE IMMEDIATE 'DROP TABLE cb_dds_demo_customers_my PURGE';
EXCEPTION WHEN OTHERS THEN NULL;
END;
/
CREATE TABLE cb_dds_demo_customers_pg (
customer_id NUMBER PRIMARY KEY,
full_name VARCHAR2(100) NOT NULL,
email VARCHAR2(200),
signup_date DATE DEFAULT SYSDATE NOT NULL,
region VARCHAR2(30) NOT NULL
);
CREATE TABLE cb_dds_demo_customers_my (
customer_id NUMBER PRIMARY KEY,
full_name VARCHAR2(100) NOT NULL,
email VARCHAR2(200),
signup_date DATE DEFAULT SYSDATE NOT NULL,
region VARCHAR2(30) NOT NULL
);
INSERT INTO cb_dds_demo_customers_pg VALUES (101, 'PG Alice', 'pg.alice@example.test', DATE '2026-01-10', 'APAC');
INSERT INTO cb_dds_demo_customers_pg VALUES (102, 'PG Bob', 'pg.bob@example.test', DATE '2026-01-11', 'EMEA');
INSERT INTO cb_dds_demo_customers_pg VALUES (103, 'PG Carol', 'pg.carol@example.test', DATE '2026-01-12', 'AMER');
INSERT INTO cb_dds_demo_customers_my VALUES (201, 'MY Alice', 'my.alice@example.test', DATE '2026-02-10', 'APAC');
INSERT INTO cb_dds_demo_customers_my VALUES (202, 'MY Bob', 'my.bob@example.test', DATE '2026-02-11', 'EMEA');
INSERT INTO cb_dds_demo_customers_my VALUES (203, 'MY Carol', 'my.carol@example.test', DATE '2026-02-12', 'AMER');
CREATE OR REPLACE VIEW v_dds_customers_pg AS
SELECT customer_id, full_name, email, signup_date, region
FROM cb_dds_demo_customers_pg;
CREATE OR REPLACE VIEW v_dds_customers_my AS
SELECT customer_id, full_name, email, signup_date, region
FROM cb_dds_demo_customers_my;
COMMIT;
PROMPT === 2. Creating independent DDS END USERs ===
CREATE END USER "dds_demo_my" IDENTIFIED BY "&DDSUSER_MY_PASSWORD";
CREATE END USER "dds_demo_pg" IDENTIFIED BY "&DDSUSER_PG_PASSWORD";
CREATE END USER "dds_demo_both" IDENTIFIED BY "&DDSUSER_BOTH_PASSWORD";
CREATE END USER "dds_demo_none" IDENTIFIED BY "&DDSUSER_NONE_PASSWORD";
PROMPT === 3. Creating independent DATA ROLEs ===
CREATE ROLE dds_demo_connect_role;
GRANT CREATE SESSION TO dds_demo_connect_role;
CREATE DATA ROLE dds_demo_my_role;
CREATE DATA ROLE dds_demo_pg_role;
CREATE DATA ROLE dds_demo_both_role;
CREATE DATA ROLE dds_demo_none_role;
GRANT dds_demo_connect_role TO dds_demo_my_role;
GRANT dds_demo_connect_role TO dds_demo_pg_role;
GRANT dds_demo_connect_role TO dds_demo_both_role;
GRANT dds_demo_connect_role TO dds_demo_none_role;
GRANT DATA ROLE dds_demo_my_role TO "dds_demo_my";
GRANT DATA ROLE dds_demo_pg_role TO "dds_demo_pg";
GRANT DATA ROLE dds_demo_both_role TO "dds_demo_both";
GRANT DATA ROLE dds_demo_none_role TO "dds_demo_none";
PROMPT === 4. Creating independent DATA GRANTs ===
CREATE DATA GRANT admin.dds_demo_my_grant
AS SELECT
ON admin.v_dds_customers_my
TO dds_demo_my_role;
CREATE DATA GRANT admin.dds_demo_pg_grant
AS SELECT
ON admin.v_dds_customers_pg
TO dds_demo_pg_role;
CREATE DATA GRANT admin.dds_demo_both_pg_grant
AS SELECT
ON admin.v_dds_customers_pg
TO dds_demo_both_role;
CREATE DATA GRANT admin.dds_demo_both_my_grant
AS SELECT
ON admin.v_dds_customers_my
TO dds_demo_both_role;
PROMPT === Independent DDS demo setup complete ===
EXIT;

View File

@@ -0,0 +1,158 @@
-- ============================================================
-- 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.
BEGIN
EXECUTE IMMEDIATE 'DROP DATA GRANT admin.dds_demo_my_vector_grant';
EXCEPTION WHEN OTHERS THEN NULL;
END;
/
BEGIN
EXECUTE IMMEDIATE 'DROP DATA GRANT admin.dds_demo_pg_vector_grant';
EXCEPTION WHEN OTHERS THEN NULL;
END;
/
BEGIN
EXECUTE IMMEDIATE 'DROP DATA GRANT admin.dds_demo_both_vector_grant';
EXCEPTION WHEN OTHERS THEN NULL;
END;
/
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. Registering the vector object in the common application permission model ===
-- The application permission tables remain the source of truth. These rows
-- make the standalone DDS example use the same role/TAG scenario as VPD:
-- one ALLOW permission per role and multiple TAG rules combined with OR.
-- The backoffice's DDS publish screen compiles these rows into the grants
-- above (and excludes the protected EMBEDDING column by default).
DECLARE
PROCEDURE ensure_vector_permission(
p_role_id IN NUMBER,
p_tag_list IN SYS.ODCIVARCHAR2LIST
) IS
v_perm_id NUMBER;
v_rule_id NUMBER;
v_tag VARCHAR2(100);
BEGIN
BEGIN
SELECT perm_id
INTO v_perm_id
FROM cb_permission
WHERE role_id = p_role_id
AND target_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
AND action_name = 'SELECT'
AND permission_effect = 'ALLOW'
AND ROWNUM = 1;
EXCEPTION
WHEN NO_DATA_FOUND THEN
SELECT NVL(MAX(perm_id), 0) + 1 INTO v_perm_id FROM cb_permission;
INSERT INTO cb_permission (
perm_id, role_id, target_name, action_name, permission_effect
) VALUES (
v_perm_id, p_role_id, 'CB_VECTOR_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW'
);
END;
FOR i IN 1 .. p_tag_list.COUNT LOOP
v_tag := UPPER(TRIM(p_tag_list(i)));
BEGIN
SELECT rule_id
INTO v_rule_id
FROM cb_permission_rule
WHERE perm_id = v_perm_id
AND rule_column = 'TECH_TAG'
AND rule_type = 'TAG'
AND rule_value = v_tag
AND ROWNUM = 1;
EXCEPTION
WHEN NO_DATA_FOUND THEN
SELECT NVL(MAX(rule_id), 0) + 1 INTO v_rule_id FROM cb_permission_rule;
INSERT INTO cb_permission_rule (
rule_id, perm_id, rule_column, rule_type, rule_value
) VALUES (
v_rule_id, v_perm_id, 'TECH_TAG', 'TAG', v_tag
);
END;
END LOOP;
END;
BEGIN
ensure_vector_permission(10, SYS.ODCIVARCHAR2LIST('SPRING_BOOT', 'ORDS'));
ensure_vector_permission(20, SYS.ODCIVARCHAR2LIST('ORACLE_VPD', 'ORACLE_DDS'));
ensure_vector_permission(30, SYS.ODCIVARCHAR2LIST(
'SPRING_BOOT', 'ORDS', 'ORACLE_VPD', 'ORACLE_DDS', 'MCP'));
END;
/
PROMPT === 4. 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;

View File

@@ -103,7 +103,7 @@ public class McpSseService {
if (isVectorTool(tool)) { if (isVectorTool(tool)) {
ObjectNode embedding = objectMapper.createObjectNode(); ObjectNode embedding = objectMapper.createObjectNode();
embedding.put("type", "array"); embedding.put("type", "array");
embedding.put("description", "외부 임베딩 모델이 만든 검색 벡터. 이 예제 fixture는 4차원입니다."); embedding.put("description", "외부 임베딩 모델이 만든 검색 벡터. 개발 환경에서는 4차원 벡터를 사용합니다.");
ObjectNode items = objectMapper.createObjectNode(); ObjectNode items = objectMapper.createObjectNode();
items.put("type", "number"); items.put("type", "number");
embedding.set("items", items); embedding.set("items", items);

View File

@@ -64,7 +64,7 @@ public class OpenAiCompatibleClient {
public List<Double> embedding(String input) { public List<Double> embedding(String input) {
if (!embeddingConfigured()) { if (!embeddingConfigured()) {
throw new AppException("AI 임베딩 설정이 없습니다. BACKOFFICE_AI_EMBEDDING_MODEL을 설정하거나 데모 임베딩을 선택하세요."); throw new AppException("AI 임베딩 설정이 없습니다. BACKOFFICE_AI_EMBEDDING_MODEL을 설정하거나 로컬 임베딩(개발용)을 선택하세요.");
} }
BackofficeProperties.Ai ai = properties.ai(); BackofficeProperties.Ai ai = properties.ai();

View File

@@ -138,7 +138,7 @@ public class ProtectedObjectService {
defaultOrdsPath(normalizedOwner, normalizedObjectName), defaultOrdsPath(normalizedOwner, normalizedObjectName),
String.join(",", columns), String.join(",", columns),
"", "",
"DB 객체를 한 개의 ORDS 조회 Handler로 노출하는 예제 대상" "DB 객체를 한 개의 ORDS 조회 Handler로 노출하는 기본 구성"
); );
long objectId = mapper.nextObjectId(); long objectId = mapper.nextObjectId();
mapper.insertObject(objectId, command); mapper.insertObject(objectId, command);

View File

@@ -65,7 +65,7 @@ public class VectorKnowledgeService {
chunks, chunks,
tags, tags,
registered, registered,
embeddingClient.embeddingConfigured() ? embeddingClient.embeddingModelName() : "DEMO-4D" embeddingClient.embeddingConfigured() ? embeddingClient.embeddingModelName() : "로컬 임베딩(개발용)"
); );
} }
@@ -120,7 +120,7 @@ public class VectorKnowledgeService {
"CB_VECTOR_SEARCH_DOCUMENTS 보호 객체가 없습니다. 28_agent_ords_vector_tag_vpd_setup.sql을 먼저 실행하세요.")); "CB_VECTOR_SEARCH_DOCUMENTS 보호 객체가 없습니다. 28_agent_ords_vector_tag_vpd_setup.sql을 먼저 실행하세요."));
String mode = normalizeMode(embeddingMode); String mode = normalizeMode(embeddingMode);
String requestBody = "{\"embedding\":" + json(embed(normalizedQuery, mode)) + "}"; String requestBody = "{\"embedding\":" + json(embed(normalizedQuery, mode)) + "}";
IssuedToken temporary = tokenService.issueTemporaryToken(userId, "벡터 지식자료 검색 임시 실행"); IssuedToken temporary = tokenService.issueTemporaryToken(userId, "지식 검색 검증 세션");
try { try {
ProbeResult probe = ordsProbeService.runProbe(new ProbeCommand( ProbeResult probe = ordsProbeService.runProbe(new ProbeCommand(
temporary.keyId(), vectorObject.objectId(), temporary.plainToken(), normalizeLimit(limit), requestBody)); temporary.keyId(), vectorObject.objectId(), temporary.plainToken(), normalizeLimit(limit), requestBody));
@@ -223,16 +223,16 @@ public class VectorKnowledgeService {
private String normalizeMode(String mode) { private String normalizeMode(String mode) {
String normalized = mode == null ? DEMO_MODE : mode.trim().toUpperCase(Locale.ROOT); String normalized = mode == null ? DEMO_MODE : mode.trim().toUpperCase(Locale.ROOT);
if (!DEMO_MODE.equals(normalized) && !AI_MODE.equals(normalized)) { if (!DEMO_MODE.equals(normalized) && !AI_MODE.equals(normalized)) {
throw new AppException("임베딩 방식은 DEMO 또는 AI만 사용할 수 있습니다."); throw new AppException("임베딩 방식은 로컬 임베딩 또는 AI 임베딩만 사용할 수 있습니다.");
} }
if (AI_MODE.equals(normalized) && !embeddingClient.embeddingConfigured()) { if (AI_MODE.equals(normalized) && !embeddingClient.embeddingConfigured()) {
throw new AppException("AI 임베딩이 설정되지 않았습니다. 설정에서 embedding model/API key를 추가하거나 DEMO 임베딩을 선택하세요."); throw new AppException("AI 임베딩이 설정되지 않았습니다. 설정에서 embedding model/API key를 추가하거나 로컬 임베딩(개발용)을 선택하세요.");
} }
return normalized; return normalized;
} }
private String embeddingModel(String mode) { private String embeddingModel(String mode) {
return AI_MODE.equals(mode) ? embeddingClient.embeddingModelName() : "DEMO-4D"; return AI_MODE.equals(mode) ? embeddingClient.embeddingModelName() : "로컬 임베딩(개발용)";
} }
private String json(List<Double> values) { private String json(List<Double> values) {

View File

@@ -7,7 +7,7 @@
<header class="page-title guided-hero"> <header class="page-title guided-hero">
<span class="architecture-kicker">권한을 정하면 DB가 그대로 지킵니다</span> <span class="architecture-kicker">권한을 정하면 DB가 그대로 지킵니다</span>
<h1>누가 어떤 데이터를 볼 수 있는지 설계하고, 실제 결과까지 확인하세요.</h1> <h1>누가 어떤 데이터를 볼 수 있는지 설계하고, 실제 결과까지 확인하세요.</h1>
<p class="context-summary">권한 설계 → DB 보호 연결 → 토큰 발급 → 실제 결과 확인</p> <p class="context-summary">권한 설계 → DB 보호 연결 → 검증 세션 → 실제 결과 확인</p>
<details class="explanation-details"> <details class="explanation-details">
<summary>전체 흐름 설명 보기</summary> <summary>전체 흐름 설명 보기</summary>
<p>사용자·그룹·역할에 권한을 연결하면 Oracle VPD가 요청할 때마다 그 규칙을 읽어 허용된 행만 반환합니다. 별도 SQL 필터를 만드는 일은 예외적인 고급 작업입니다.</p> <p>사용자·그룹·역할에 권한을 연결하면 Oracle VPD가 요청할 때마다 그 규칙을 읽어 허용된 행만 반환합니다. 별도 SQL 필터를 만드는 일은 예외적인 고급 작업입니다.</p>
@@ -40,9 +40,9 @@
<a class="journey-card" href="/tokens"> <a class="journey-card" href="/tokens">
<span class="journey-number">3</span> <span class="journey-number">3</span>
<div> <div>
<h2>3. 토큰 발급</h2> <h2>3. 검증 세션</h2>
<p>검증할 사용자를 선택해 토큰을 발급하고, 한 번만 보이는 원문을 복사합니다.</p> <p>검증할 사용자를 선택해 토큰을 발급하고, 한 번만 보이는 원문을 복사합니다.</p>
<strong>테스트 토큰 발급하기 →</strong> <strong>검증 세션 발급하기 →</strong>
</div> </div>
</a> </a>
<a class="journey-card" href="/probe"> <a class="journey-card" href="/probe">

View File

@@ -29,7 +29,7 @@
<button class="rw-menu-trigger" type="button" aria-expanded="false">2. 보호·검증</button> <button class="rw-menu-trigger" type="button" aria-expanded="false">2. 보호·검증</button>
<div class="rw-menu-panel"> <div class="rw-menu-panel">
<a class="nav-link" href="/vpd-policies">DB 보호 연결</a> <a class="nav-link" href="/vpd-policies">DB 보호 연결</a>
<a class="nav-link" href="/tokens">테스트 토큰 발급</a> <a class="nav-link" href="/tokens">검증 세션 발급</a>
<a class="nav-link" href="/probe">권한 결과 확인</a> <a class="nav-link" href="/probe">권한 결과 확인</a>
</div> </div>
</div> </div>
@@ -38,11 +38,11 @@
<div class="rw-menu-panel"> <div class="rw-menu-panel">
<a class="nav-link" href="/objects">ORDS 조회 대상</a> <a class="nav-link" href="/objects">ORDS 조회 대상</a>
<a class="nav-link" href="/ords-handlers">ORDS 핸들러</a> <a class="nav-link" href="/ords-handlers">ORDS 핸들러</a>
<a class="nav-link" href="/vector-knowledge">벡터 지식자료 데모</a> <a class="nav-link" href="/vector-knowledge">지식 검색 관리</a>
<a class="nav-link" href="/mcp-chatbot">Chatbot</a> <a class="nav-link" href="/mcp-chatbot">Chatbot</a>
<a class="nav-link" href="/mcp-reasoning">Reasoning</a> <a class="nav-link" href="/mcp-reasoning">Reasoning</a>
<a class="nav-link" href="/mcp-sse">SSE 서비스</a> <a class="nav-link" href="/mcp-sse">SSE 서비스</a>
<a class="nav-link" href="/mcp-client-demo">Client Demo</a> <a class="nav-link" href="/mcp-client-demo">MCP Client</a>
</div> </div>
</div> </div>
<div class="rw-menu-group"> <div class="rw-menu-group">
@@ -76,8 +76,8 @@
<div class="architecture-arrow"></div> <div class="architecture-arrow"></div>
<div class="architecture-step" th:classappend="${activeLayer == 'token'} ? ' active'"> <div class="architecture-step" th:classappend="${activeLayer == 'token'} ? ' active'">
<span class="architecture-kicker">3 · IDENTITY</span> <span class="architecture-kicker">3 · IDENTITY</span>
<strong>토큰 발급</strong> <strong>검증 세션</strong>
<p>검증할 사용자를 나타내는 일회성 원문 토큰을 준비합니다.</p> <p>확인할 사용자를 나타내는 일회성 토큰을 준비합니다.</p>
</div> </div>
<div class="architecture-arrow"></div> <div class="architecture-arrow"></div>
<div class="architecture-step" th:classappend="${activeLayer == 'ords'} ? ' active'"> <div class="architecture-step" th:classappend="${activeLayer == 'ords'} ? ' active'">

View File

@@ -8,7 +8,7 @@
<span th:text="${object.owner() + '.' + object.objectName()}">ADMIN.TABLE_NAME</span> <span th:text="${object.owner() + '.' + object.objectName()}">ADMIN.TABLE_NAME</span>
<span class="text-muted"> / 기본 ORDS Handler PL/SQL</span> <span class="text-muted"> / 기본 ORDS Handler PL/SQL</span>
</h3> </h3>
<p class="form-hint">이 소스는 VPD context를 설정한 뒤 한 테이블을 SELECT하는 시작 예제입니다. 실제 수정·저장은 <a href="/ords-handlers">ORDS 핸들러</a> 화면에서 parsing schema 권한으로 진행하세요.</p> <p class="form-hint">이 소스는 VPD context를 설정한 뒤 한 테이블을 SELECT하는 기본 구성입니다. 실제 수정·저장은 <a href="/ords-handlers">ORDS 핸들러</a> 화면에서 parsing schema 권한으로 진행하세요.</p>
<pre th:text="${source}">source</pre> <pre th:text="${source}">source</pre>
</section> </section>
</div> </div>

View File

@@ -2,7 +2,7 @@
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div> <div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
<div th:if="${searchResult}"> <div th:if="${searchResult}">
<div class="result-metrics"> <div class="result-metrics">
<div><span>임베딩</span><strong th:text="${searchResult.embeddingModel()}">DEMO-4D</strong></div> <div><span>임베딩</span><strong th:text="${searchResult.embeddingModel() == 'DEMO-4D' ? '로컬 임베딩(개발용)' : searchResult.embeddingModel()}">로컬 임베딩(개발용)</strong></div>
<div><span>검색 결과</span><strong th:text="${searchResult.probe().rowCount() + '건 · ' + searchResult.probe().status()}">0건</strong></div> <div><span>검색 결과</span><strong th:text="${searchResult.probe().rowCount() + '건 · ' + searchResult.probe().status()}">0건</strong></div>
</div> </div>
<div class="next-action-card mt-3"> <div class="next-action-card mt-3">
@@ -14,11 +14,11 @@
<table class="table table-sm align-middle"> <table class="table table-sm align-middle">
<thead> <thead>
<tr> <tr>
<th>Chunk</th> <th>검색 단위 ID</th>
<th>Document</th> <th>자료 ID</th>
<th>Title</th> <th>제목</th>
<th>TECH_TAG</th> <th>기술 태그</th>
<th>Score</th> <th>관련도</th>
<th>본문</th> <th>본문</th>
</tr> </tr>
</thead> </thead>

View File

@@ -21,10 +21,10 @@
<section class="content-band chat-shell"> <section class="content-band chat-shell">
<div class="chat-message assistant-message"> <div class="chat-message assistant-message">
<strong>질문 실행</strong> <strong>질문 실행</strong>
<p>예시를 선택하거나 질문을 직접 입력하세요.</p> <p>추천 질문을 선택하거나 질문을 직접 입력하세요.</p>
<details class="explanation-details"> <details class="explanation-details">
<summary>토큰과 조회 결과 설명 보기</summary> <summary>세션과 조회 결과 설명 보기</summary>
<p>Bearer Token 원문을 붙여 넣거나 사용자를 선택해 10분 임시 토큰으로 실제 ORDS/VPD 결과를 조회할 수 있습니다.</p> <p>Bearer Token 원문을 붙여 넣거나 사용자를 선택해 10분 검증 세션으로 실제 ORDS/VPD 결과를 조회할 수 있습니다.</p>
</details> </details>
</div> </div>
@@ -47,12 +47,12 @@
placeholder="비우면 라우팅만 확인합니다."> placeholder="비우면 라우팅만 확인합니다.">
</label> </label>
<label> <label>
임시 토큰 사용자 (선택) 검증 세션 사용자 (선택)
<select class="form-select" name="tempUserId"> <select class="form-select" name="tempUserId">
<option value="">원문 토큰 사용</option> <option value="">원문 토큰 사용</option>
<option th:each="user : ${users}" th:value="${user.userId()}" th:text="${user.username()}"></option> <option th:each="user : ${users}" th:value="${user.userId()}" th:text="${user.username()}"></option>
</select> </select>
<span class="form-hint">선택하면 질문 처리 중에만 임시 토큰을 발하고 완료 후 즉시 회수합니다.</span> <span class="form-hint">선택하면 질문 처리 중에만 검증 세션을 발하고 완료 후 즉시 폐기합니다.</span>
</label> </label>
<label> <label>
질문 질문
@@ -69,7 +69,7 @@
민감 컬럼 확인 민감 컬럼 확인
</button> </button>
<button class="btn rw-btn-secondary question-preset" type="button" <button class="btn rw-btn-secondary question-preset" type="button"
data-question="CB_VECTOR_SEARCH_DOCUMENTS에서 SPRING_BOOT 또는 ORACLE_VPD 태그 권한으로 검색 가능한 청크와 VPD 결과를 요약해줘."> data-question="CB_VECTOR_SEARCH_DOCUMENTS에서 SPRING_BOOT 또는 ORACLE_VPD 태그 권한으로 검색 가능한 검색 단위와 VPD 결과를 요약해줘.">
태그 벡터 검색 태그 벡터 검색
</button> </button>
<button class="btn rw-btn-secondary question-preset" type="button" <button class="btn rw-btn-secondary question-preset" type="button"

View File

@@ -1,14 +1,14 @@
<!doctype html> <!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"> <html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('MCP Client Demo')}"></head> <head th:replace="~{fragments/layout :: head('MCP Client')}"></head>
<body> <body>
<nav th:replace="~{fragments/layout :: nav}"></nav> <nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container page-shell"> <main class="container page-shell">
<section class="page-heading"> <section class="page-heading">
<h1>MCP Client Demo</h1> <h1>MCP Client</h1>
<p class="context-summary">Java client가 MCP 연결과 도구 목록 계약 확인합니다.</p> <p class="context-summary">Java client가 MCP 연결과 사용 가능한 도구 계약 확인합니다.</p>
<details class="explanation-details"> <details class="explanation-details">
<summary>Client Demo의 범위 보기</summary> <summary>Client 연동 범위 보기</summary>
<p>백오피스 Java client가 MCP message endpoint의 initialize/tools/list 계약을 확인합니다. 실제 tool 선택과 호출은 reasoning 결과를 받은 MCP client가 담당합니다.</p> <p>백오피스 Java client가 MCP message endpoint의 initialize/tools/list 계약을 확인합니다. 실제 tool 선택과 호출은 reasoning 결과를 받은 MCP client가 담당합니다.</p>
</details> </details>
</section> </section>
@@ -23,7 +23,7 @@
<form hx-post="/mcp-client-demo" hx-target="#mcp-client-demo-result" hx-swap="innerHTML" class="form-grid"> <form hx-post="/mcp-client-demo" hx-target="#mcp-client-demo-result" hx-swap="innerHTML" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<details class="explanation-details span-2"> <details class="explanation-details span-2">
<summary>이번 테스트에서 확인하는 계약 보기</summary> <summary>확인하는 계약 보기</summary>
<p>이 화면은 고정된 기본 MCP endpoint에서 initialize와 tools/list만 확인합니다. 질문 기반 tool 선택·Bearer Token 전달·tools/call은 MCP Reasoning 또는 외부 MCP client 흐름에서 수행합니다.</p> <p>이 화면은 고정된 기본 MCP endpoint에서 initialize와 tools/list만 확인합니다. 질문 기반 tool 선택·Bearer Token 전달·tools/call은 MCP Reasoning 또는 외부 MCP client 흐름에서 수행합니다.</p>
</details> </details>
<button class="btn rw-btn-primary" type="submit">MCP 계약 확인</button> <button class="btn rw-btn-primary" type="submit">MCP 계약 확인</button>
@@ -55,7 +55,7 @@
</section> </section>
<section id="mcp-client-demo-result" class="content-band"> <section id="mcp-client-demo-result" class="content-band">
<div class="text-muted">Java MCP client 실행 결과가 여기에 표시됩니다.</div> <div class="text-muted">Java MCP client 확인 결과가 여기에 표시됩니다.</div>
</section> </section>
</main> </main>
</body> </body>

View File

@@ -31,22 +31,22 @@
<label> <label>
Bearer Token 원문 Bearer Token 원문
<input class="form-control" name="bearerToken" type="password" autocomplete="off"> <input class="form-control" name="bearerToken" type="password" autocomplete="off">
<span class="form-hint">원문을 붙여 넣거나 임시 토큰 사용자를 선택하세요.</span> <span class="form-hint">원문을 붙여 넣거나 검증 세션 사용자를 선택하세요.</span>
<details class="explanation-details"> <details class="explanation-details">
<summary>토큰 처리 방식 보기</summary> <summary>토큰 처리 방식 보기</summary>
<p>등록 토큰을 목록에서 선택하지 않습니다. 원문은 저장하지 않고 요청에만 사용합니다.</p> <p>등록 토큰을 목록에서 선택하지 않습니다. 원문은 저장하지 않고 요청에만 사용합니다.</p>
</details> </details>
</label> </label>
<label> <label>
임시 토큰 사용자 (선택) 검증 세션 사용자 (선택)
<select class="form-select" name="tempUserId"> <select class="form-select" name="tempUserId">
<option value="">원문 토큰 사용</option> <option value="">원문 토큰 사용</option>
<option th:each="user : ${users}" th:value="${user.userId()}" th:text="${user.username()}"></option> <option th:each="user : ${users}" th:value="${user.userId()}" th:text="${user.username()}"></option>
</select> </select>
<span class="form-hint">실행 중 발하고 완료 즉시 회수합니다.</span> <span class="form-hint">실행 중 발하고 완료 즉시 폐기합니다.</span>
<details class="explanation-details"> <details class="explanation-details">
<summary>임시 토큰 상세 보기</summary> <summary>검증 세션 상세 보기</summary>
<p>선택하면 reasoning 실행 중 10분 토큰을 발하고 완료 즉시 회수합니다.</p> <p>선택하면 reasoning 실행 중 10분 세션을 발하고 완료 즉시 폐기합니다.</p>
</details> </details>
</label> </label>
<label> <label>
@@ -60,7 +60,7 @@
</label> </label>
<div class="question-presets span-2" aria-label="질문 예시"> <div class="question-presets span-2" aria-label="질문 예시">
<button class="btn rw-btn-secondary question-preset" type="button" <button class="btn rw-btn-secondary question-preset" type="button"
data-question="CB_VECTOR_SEARCH_DOCUMENTS에서 이 토큰이 볼 수 있는 기술 태그 청크 수와 VPD가 제외한 범위를 요약해줘."> data-question="CB_VECTOR_SEARCH_DOCUMENTS에서 이 토큰이 볼 수 있는 기술 태그 검색 단위 수와 VPD가 제외한 범위를 요약해줘.">
기본 분석 기본 분석
</button> </button>
<button class="btn rw-btn-secondary question-preset" type="button" <button class="btn rw-btn-secondary question-preset" type="button"
@@ -68,7 +68,7 @@
민감 컬럼 점검 민감 컬럼 점검
</button> </button>
<button class="btn rw-btn-secondary question-preset" type="button" <button class="btn rw-btn-secondary question-preset" type="button"
data-question="반환된 벡터 청크의 기술 태그와 권한 규칙을 근거로 어떤 지식 범위를 조회할 수 있는지 판단해줘. 추정이면 추정이라고 표시해줘."> data-question="반환된 검색 단위의 기술 태그와 권한 규칙을 근거로 어떤 지식 범위를 조회할 수 있는지 판단해줘. 추정이면 추정이라고 표시해줘.">
태그 권한 범위 판단 태그 권한 범위 판단
</button> </button>
<button class="btn rw-btn-secondary question-preset" type="button" <button class="btn rw-btn-secondary question-preset" type="button"

View File

@@ -14,10 +14,10 @@
</div> </div>
<div class="alert alert-info"> <div class="alert alert-info">
<strong>벡터 검색 예제:</strong> 문서 청크와 기술 태그를 등록한 뒤 전용 ORDS 검색을 연결합니다. <strong>지식 검색 연결:</strong> 문서 검색 단위와 기술 태그를 등록한 뒤 전용 ORDS 검색을 연결합니다.
<details class="explanation-details mt-2"> <details class="explanation-details mt-2">
<summary>벡터 지식자료 검색 흐름 설명 보기</summary> <summary>지식 검색 연결 흐름 보기</summary>
<p>문서를 청크로 나누고 기술 태그를 붙인 뒤 전용 ORDS 검색을 등록합니다. 그 다음 <a href="/permissions">권한 관리</a>에서 <code>특정 기술 태그</code>를 여러 개 추가하면 태그 중 하나라도 맞는 청크만 검색됩니다.</p> <p>문서를 검색 단위로 나누고 기술 태그를 붙인 뒤 전용 ORDS 검색을 등록합니다. 그 다음 <a href="/permissions">권한 관리</a>에서 <code>특정 기술 태그</code>를 여러 개 추가하면 태그 중 하나라도 맞는 검색 단위만 검색됩니다.</p>
</details> </details>
</div> </div>
@@ -27,8 +27,8 @@
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div> <div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
<section class="content-band"> <section class="content-band">
<h2>조회 대상 추가 (예제 Handler)</h2> <h2>조회 대상 추가</h2>
<p class="section-subtitle">DB 객체 하나를 하나의 조회 Handler로 연결하는 시작 예제입니다.</p> <p class="section-subtitle">DB 객체 하나를 하나의 조회 Handler로 연결하는 기본 구성입니다.</p>
<details class="explanation-details"> <details class="explanation-details">
<summary>대상 추가 후 생성되는 내용 보기</summary> <summary>대상 추가 후 생성되는 내용 보기</summary>
<p>대상 추가 후 Handler를 생성하면 VPD context 설정과 기본 SELECT가 포함된 PL/SQL이 만들어집니다. 소스 보기에서 실제 업무에 맞게 수정할 수 있습니다.</p> <p>대상 추가 후 Handler를 생성하면 VPD context 설정과 기본 SELECT가 포함된 PL/SQL이 만들어집니다. 소스 보기에서 실제 업무에 맞게 수정할 수 있습니다.</p>
@@ -53,7 +53,7 @@
<input class="form-control" name="ordsPath" data-ords-path-input <input class="form-control" name="ordsPath" data-ords-path-input
placeholder="DB 객체 선택 시 자동 입력" readonly required> placeholder="DB 객체 선택 시 자동 입력" readonly required>
<span class="form-hint"> <span class="form-hint">
객체를 선택하면 예제용 module/template 경로가 자동으로 입력됩니다. 비워 제출해도 서버가 같은 규칙으로 채웁니다. 객체를 선택하면 기본 module/template 경로가 자동으로 입력됩니다. 비워 제출해도 서버가 같은 규칙으로 채웁니다.
<label class="form-check form-check-inline ms-2"> <label class="form-check form-check-inline ms-2">
<input class="form-check-input" type="checkbox" data-ords-path-edit> <input class="form-check-input" type="checkbox" data-ords-path-edit>
<span class="form-check-label">고급: 직접 수정</span> <span class="form-check-label">고급: 직접 수정</span>
@@ -68,7 +68,7 @@
<button class="btn btn-primary" type="submit">대상 추가</button> <button class="btn btn-primary" type="submit">대상 추가</button>
</form> </form>
<details class="explanation-details explanation-warning mt-3"> <details class="explanation-details explanation-warning mt-3">
<summary>기본 Handler 예제와 수정 가이드 보기</summary> <summary>기본 Handler 구성과 수정 가이드 보기</summary>
<p><code>cb_ords_handler_pkg.set_vpd_context(:auth_header)</code>로 토큰의 사용자·역할 컨텍스트를 넣고, 선택한 한 테이블에 <code>SELECT ... FROM OWNER.TABLE</code>을 실행한 뒤 JSON으로 반환합니다. 이것은 유일한 사용 방식이 아니라 시작점이며, Handler 소스와 ORDS 메타데이터에서 수정할 수 있습니다. 컬럼 민감도·마스킹은 이 화면에서 다루지 않고 <a href="/permissions">권한 관리의 원문 표시 허용 컬럼</a>에서 별도로 설정합니다.</p> <p><code>cb_ords_handler_pkg.set_vpd_context(:auth_header)</code>로 토큰의 사용자·역할 컨텍스트를 넣고, 선택한 한 테이블에 <code>SELECT ... FROM OWNER.TABLE</code>을 실행한 뒤 JSON으로 반환합니다. 이것은 유일한 사용 방식이 아니라 시작점이며, Handler 소스와 ORDS 메타데이터에서 수정할 수 있습니다. 컬럼 민감도·마스킹은 이 화면에서 다루지 않고 <a href="/permissions">권한 관리의 원문 표시 허용 컬럼</a>에서 별도로 설정합니다.</p>
</details> </details>
</section> </section>

View File

@@ -25,7 +25,7 @@
<p>토큰은 사용자를 찾는 열쇠입니다. 서버가 직접 역할과 그룹 상속 역할을 합치고, VPD가 저장된 행·열 규칙을 적용한 결과를 보여줍니다.</p> <p>토큰은 사용자를 찾는 열쇠입니다. 서버가 직접 역할과 그룹 상속 역할을 합치고, VPD가 저장된 행·열 규칙을 적용한 결과를 보여줍니다.</p>
</details> </details>
</div> </div>
<a class="btn rw-btn-secondary" href="/tokens">테스트 토큰이 없나요? 먼저 발급하기</a> <a class="btn rw-btn-secondary" href="/tokens">검증 세션이 없나요? 먼저 발급하기</a>
</section> </section>
<section class="content-band"> <section class="content-band">
@@ -41,15 +41,15 @@
1. 발급받은 토큰 원문 1. 발급받은 토큰 원문
<input class="form-control" name="bearerToken" type="password" autocomplete="off" <input class="form-control" name="bearerToken" type="password" autocomplete="off"
placeholder="토큰 발급 직후 복사한 값을 붙여 넣으세요"> placeholder="토큰 발급 직후 복사한 값을 붙여 넣으세요">
<span class="form-hint">원문은 DB에 저장되지 않습니다. 아래 임시 토큰 사용자를 선택하면 이 입력은 무시되고 실행 중 발급·완료 즉시 회수됩니다. 둘 다 비우면 invalid token 결과를 확인할 수 있습니다.</span> <span class="form-hint">원문은 DB에 저장되지 않습니다. 아래 검증 세션 사용자를 선택하면 이 입력은 무시되고 실행 중 발급·완료 즉시 폐기됩니다. 둘 다 비우면 invalid token 결과를 확인할 수 있습니다.</span>
</label> </label>
<label> <label>
임시 토큰 사용자 (선택) 검증 세션 사용자 (선택)
<select class="form-select" name="tempUserId"> <select class="form-select" name="tempUserId">
<option value="">원문 토큰 사용</option> <option value="">원문 토큰 사용</option>
<option th:each="user : ${users}" th:value="${user.userId()}" th:text="${user.username()}"></option> <option th:each="user : ${users}" th:value="${user.userId()}" th:text="${user.username()}"></option>
</select> </select>
<span class="form-hint">ORDS 호출에 사용할 10분 토큰을 만들고 테스트가 끝나면 바로 회수합니다.</span> <span class="form-hint">ORDS 호출에 사용할 10분 검증 세션을 만들고 완료 후 바로 폐기합니다.</span>
</label> </label>
<label> <label>
2. 확인할 데이터 2. 확인할 데이터

View File

@@ -1,12 +1,12 @@
<!doctype html> <!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"> <html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('테스트 토큰 발급')}"></head> <head th:replace="~{fragments/layout :: head('검증 세션 발급')}"></head>
<body> <body>
<nav th:replace="~{fragments/layout :: nav}"></nav> <nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4"> <main class="container py-4">
<div class="page-title"> <div class="page-title">
<h1>테스트 토큰 발급</h1> <h1>검증 세션 발급</h1>
<p class="context-summary">검증할 사용자의 일회성 Bearer Token을 발급합니다.</p> <p class="context-summary">선택한 사용자의 접근 권한을 확인할 일회성 Bearer Token을 발급합니다.</p>
<details class="explanation-details"> <details class="explanation-details">
<summary>토큰 보관 방식 설명 보기</summary> <summary>토큰 보관 방식 설명 보기</summary>
<p>검증할 사용자를 나타내는 토큰을 만들고, 한 번만 표시되는 원문을 다음 단계에서 사용합니다. 원문은 저장하지 않고 해시와 식별용 prefix만 보관합니다.</p> <p>검증할 사용자를 나타내는 토큰을 만들고, 한 번만 표시되는 원문을 다음 단계에서 사용합니다. 원문은 저장하지 않고 해시와 식별용 prefix만 보관합니다.</p>
@@ -50,7 +50,7 @@
용도 메모 용도 메모
<input class="form-control" name="description" maxlength="200" placeholder="예: HR 권한 확인"> <input class="form-control" name="description" maxlength="200" placeholder="예: HR 권한 확인">
</label> </label>
<button class="btn rw-btn-primary" type="submit">2. 토큰 발급</button> <button class="btn rw-btn-primary" type="submit">2. 검증 세션 발급</button>
</form> </form>
</section> </section>

View File

@@ -1,15 +1,15 @@
<!doctype html> <!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"> <html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('벡터 지식자료 데모')}"></head> <head th:replace="~{fragments/layout :: head('지식 검색 관리')}"></head>
<body> <body>
<nav th:replace="~{fragments/layout :: nav}"></nav> <nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4"> <main class="container py-4">
<div class="page-title"> <div class="page-title">
<h1>벡터 지식자료 데모</h1> <h1>지식 검색 관리</h1>
<p class="context-summary">문서를 청크로 나누고 임베딩·기술 태그를 저장한 뒤, 사용자 권한에 맞는 청크만 검색합니다.</p> <p class="context-summary">지식자료를 등록하고 접근 정책에 따라 검색 결과를 제공합니다.</p>
<details class="explanation-details"> <details class="explanation-details">
<summary>전체 시나리오 설명 보기</summary> <summary>검색 정책과 처리 방식</summary>
<p>문서 본문을 청크로 나누고 각 청크를 벡터로 변환합니다. 청크마다 기술 태그를 붙이면 권한 규칙<code>TAG</code> 조건이 같은 태그가 붙은 행만 허용합니다. 마지막 검색은 Bearer Token으로 VPD를 통과한 청크만 벡터 거리순으로 반환합니다.</p> <p>문서는 검색 단위로 나누고 각 단위를 임베딩으로 변환합니다. 기술 태그는 접근 정책<code>TAG</code> 조건으로 사용되며, 검색 시 DB 보호 정책이 허용된 지식자료만 남깁니다. <code>Bearer Token</code>은 검증 세션에만 사용되고 검색이 끝나면 폐기됩니다.</p>
</details> </details>
</div> </div>
@@ -25,19 +25,19 @@
<section class="content-band"> <section class="content-band">
<div class="section-heading"> <div class="section-heading">
<div> <div>
<span class="architecture-kicker">1 · INGEST</span> <span class="architecture-kicker">1 · CONTENT</span>
<h2>문서 등록 → 청킹 → 임베딩 → 태그 저장</h2> <h2>지식자료 등록</h2>
<p class="section-subtitle">같은 문서 ID로 다시 저장하면 기존 청크를 교체합니다.</p> <p class="section-subtitle">같은 문서 ID로 저장하면 최신 내용으로 갱신됩니다.</p>
</div> </div>
<span class="badge text-bg-secondary" th:text="${summary == null ? 'DB 확인 필요' : summary.chunkCount() + ' chunks'}">0 chunks</span> <span class="badge text-bg-secondary" th:text="${summary == null ? '저장소 확인 필요' : summary.chunkCount() + '개 검색 단위'}">0개 검색 단위</span>
</div> </div>
<details class="explanation-details"> <details class="explanation-details">
<summary>청킹과 임베딩 방식 보기</summary> <summary>분할·임베딩 설정 보기</summary>
<ul> <ul>
<li>문단을 우선 묶고 길이가 길면 문장·공백 기준으로 나눕니다.</li> <li>문단을 우선 묶고 길이가 길면 문장·공백 기준으로 나눕니다.</li>
<li><strong>DEMO-4D</strong> 외부 API 없이 동일한 4차원 예제를 재현하는 모드입니다. 의미 검색 품질을 제공하는 실제 임베딩은 아닙니다.</li> <li><strong>로컬 임베딩(개발용)</strong>(내부 식별자 <code>DEMO-4D</code>)은 외부 API 없이 검색 흐름을 확인하는 4차원 처리 방식입니다. 운영 검색 품질을 위한 모델은 아닙니다.</li>
<li><strong>AI</strong><code>BACKOFFICE_AI_EMBEDDING_MODEL</code>과 API key가 설정된 OpenAI 호환 <code>/v1/embeddings</code>를 사용합니다.</li> <li><strong>AI</strong><code>BACKOFFICE_AI_EMBEDDING_MODEL</code>과 API key가 설정된 OpenAI 호환 <code>/v1/embeddings</code>를 사용합니다.</li>
<li>실제 운영에서는 문서 내용과 같은 임베딩 모델로 검색어도 임베딩해야 합니다.</li> <li>운영 환경에서는 자료 등록과 검색에 같은 임베딩 모델과 차원을 사용해야 합니다.</li>
</ul> </ul>
</details> </details>
<form method="post" action="/vector-knowledge/ingest" class="form-grid mt-3"> <form method="post" action="/vector-knowledge/ingest" class="form-grid mt-3">
@@ -60,13 +60,13 @@
<span class="form-hint">태그는 대문자로 정규화됩니다. 예: <code>SPRING_BOOT</code>, <code>ORACLE_VPD</code></span> <span class="form-hint">태그는 대문자로 정규화됩니다. 예: <code>SPRING_BOOT</code>, <code>ORACLE_VPD</code></span>
</label> </label>
<label> <label>
청크 길이 검색 단위 길이
<input class="form-control" name="chunkSize" type="number" min="80" max="2000" value="600"> <input class="form-control" name="chunkSize" type="number" min="80" max="2000" value="600">
</label> </label>
<label> <label>
임베딩 방식 임베딩 방식
<select class="form-select" name="embeddingMode"> <select class="form-select" name="embeddingMode">
<option value="DEMO">DEMO-4D (로컬 재현)</option> <option value="DEMO">로컬 임베딩(개발용)</option>
<option value="AI" th:disabled="${!aiEmbeddingConfigured}">AI 임베딩 (설정 필요)</option> <option value="AI" th:disabled="${!aiEmbeddingConfigured}">AI 임베딩 (설정 필요)</option>
</select> </select>
</label> </label>
@@ -75,37 +75,37 @@
<textarea class="form-control" name="content" rows="8" <textarea class="form-control" name="content" rows="8"
placeholder="문서 내용을 붙여 넣으세요. 빈 줄은 문단 경계로 사용됩니다." required></textarea> placeholder="문서 내용을 붙여 넣으세요. 빈 줄은 문단 경계로 사용됩니다." required></textarea>
</label> </label>
<button class="btn rw-btn-primary" type="submit">청크·임베딩·태그 저장</button> <button class="btn rw-btn-primary" type="submit">지식자료 저장</button>
</form> </form>
<div class="alert alert-info mt-3 mb-0" th:if="${ingestResult}"> <div class="alert alert-info mt-3 mb-0" th:if="${ingestResult}">
<strong th:text="${ingestResult.documentId()}">document</strong> <strong th:text="${ingestResult.documentId()}">document</strong>
<span th:text="${ingestResult.chunkCount() + '개 청크 / ' + ingestResult.tagCount() + '개 태그 연결 / ' + ingestResult.embeddingModel()}">저장 완료</span> <span th:text="${ingestResult.chunkCount() + '개 검색 단위 / ' + ingestResult.tagCount() + '개 태그 연결 / ' + ingestResult.embeddingModel()}">저장 완료</span>
</div> </div>
</section> </section>
<section class="content-band"> <section class="content-band">
<div class="section-heading"> <div class="section-heading">
<div> <div>
<span class="architecture-kicker">2 · AUTHORIZE</span> <span class="architecture-kicker">2 · ACCESS POLICY</span>
<h2>태그 권한 설정</h2> <h2>접근 정책 설정</h2>
<p class="section-subtitle">태그 권한은 기존 권한 관리에서 역할별 행 규칙으로 저장합니다.</p> <p class="section-subtitle">역할별 기술 태그 접근 규칙은 권한 관리에서 관리합니다.</p>
</div> </div>
<a class="btn btn-sm rw-btn-primary" href="/permissions">권한 관리 열기</a> <a class="btn btn-sm rw-btn-primary" href="/permissions">권한 관리 열기</a>
</div> </div>
<div class="macro-micro-grid"> <div class="macro-micro-grid">
<div> <div>
<h3>예: 백엔드 역할</h3> <h3>백엔드 지식 접근 정책</h3>
<p><code>ALLOW TAG SPRING_BOOT</code><br><code>ALLOW TAG ORACLE_VPD</code></p> <p><code>ALLOW TAG SPRING_BOOT</code><br><code>ALLOW TAG ORACLE_VPD</code></p>
<small class="text-muted">두 태그 중 하나가 붙은 청크를 허용합니다.</small> <small class="text-muted">두 태그 중 하나가 붙은 검색 단위를 허용합니다.</small>
</div> </div>
<div> <div>
<h3>예: ORDS 제외 역할</h3> <h3>ORDS 제외 정책</h3>
<p><code>ALLOW TAG SPRING_BOOT</code><br><code>DENY TAG ORDS</code></p> <p><code>ALLOW TAG SPRING_BOOT</code><br><code>DENY TAG ORDS</code></p>
<small class="text-muted">허용 후보 중 ORDS 태그 청크를 다시 제외합니다.</small> <small class="text-muted">허용 후보 중 ORDS 태그 검색 단위를 다시 제외합니다.</small>
</div> </div>
</div> </div>
<details class="explanation-details mt-3"> <details class="explanation-details mt-3">
<summary>VPD predicate가 계산하는 식 보기</summary> <summary>접근 정책이 적용되는 방식</summary>
<pre class="code-block">(ALLOW TAG A OR ALLOW TAG B) <pre class="code-block">(ALLOW TAG A OR ALLOW TAG B)
AND NOT (DENY TAG C OR DENY TAG D)</pre> AND NOT (DENY TAG C OR DENY TAG D)</pre>
<p>허용 태그가 없거나 토큰 컨텍스트가 없으면 <code>1=0</code>으로 닫힙니다. 태그 권한은 행 접근 기준이고, 본문·임베딩 표시 보호는 컬럼 정책으로 별도 관리합니다.</p> <p>허용 태그가 없거나 토큰 컨텍스트가 없으면 <code>1=0</code>으로 닫힙니다. 태그 권한은 행 접근 기준이고, 본문·임베딩 표시 보호는 컬럼 정책으로 별도 관리합니다.</p>
@@ -116,25 +116,25 @@ AND NOT (DENY TAG C OR DENY TAG D)</pre>
<div class="section-heading"> <div class="section-heading">
<div> <div>
<span class="architecture-kicker">3 · SEARCH</span> <span class="architecture-kicker">3 · SEARCH</span>
<h2>권한 적용 벡터 검색</h2> <h2>권한 기반 지식 검색</h2>
<p class="section-subtitle">선택한 사용자에게 임시 토큰을 발급하고 검색 완료 즉시 회수합니다.</p> <p class="section-subtitle">검색 권한 주체에 대한 검증용 세션을 사용하고 완료 후 폐기합니다.</p>
</div> </div>
<span class="badge" th:classappend="${summary != null && summary.vectorObjectRegistered()} ? ' text-bg-success' : ' text-bg-warning'" <span class="badge" th:classappend="${summary != null && summary.vectorObjectRegistered()} ? ' text-bg-success' : ' text-bg-warning'"
th:text="${summary != null && summary.vectorObjectRegistered()} ? 'Vector ORDS 연결됨' : 'Vector ORDS 필요'">Vector ORDS</span> th:text="${summary != null && summary.vectorObjectRegistered()} ? '검색 API 연결됨' : '검색 API 필요'">검색 API</span>
</div> </div>
<details class="explanation-details"> <details class="explanation-details">
<summary>검색 실행 순서 보기</summary> <summary>검색 처리 방식 보기</summary>
<ol> <ol>
<li>검색어를 같은 임베딩 방식으로 벡터화합니다.</li> <li>검색어를 같은 임베딩 방식으로 벡터화합니다.</li>
<li>임시 Bearer Token으로 전용 ORDS Handler를 호출합니다.</li> <li>임시 Bearer Token으로 전용 ORDS Handler를 호출합니다.</li>
<li>VPD가 TECH_TAG 권한에 맞지 않는 청크를 먼저 제거합니다.</li> <li>VPD가 TECH_TAG 권한에 맞지 않는 검색 단위를 먼저 제거합니다.</li>
<li>남은 청크를 벡터 거리순으로 반환합니다.</li> <li>남은 검색 단위를 관련도 순으로 반환합니다.</li>
</ol> </ol>
</details> </details>
<form hx-post="/vector-knowledge/search" hx-target="#vector-search-result" hx-swap="innerHTML" class="form-grid mt-3"> <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}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label> <label>
테스트 사용자 검색 권한 주체
<select class="form-select" name="userId" required> <select class="form-select" name="userId" required>
<option th:each="user : ${users}" th:value="${user.userId()}" th:text="${user.username() + ' / ' + user.deptCode()}"></option> <option th:each="user : ${users}" th:value="${user.userId()}" th:text="${user.username() + ' / ' + user.deptCode()}"></option>
</select> </select>
@@ -146,7 +146,7 @@ AND NOT (DENY TAG C OR DENY TAG D)</pre>
<label> <label>
임베딩 방식 임베딩 방식
<select class="form-select" name="embeddingMode"> <select class="form-select" name="embeddingMode">
<option value="DEMO">DEMO-4D (샘플과 동일)</option> <option value="DEMO">로컬 임베딩(개발용)</option>
<option value="AI" th:disabled="${!aiEmbeddingConfigured}">AI 임베딩</option> <option value="AI" th:disabled="${!aiEmbeddingConfigured}">AI 임베딩</option>
</select> </select>
</label> </label>
@@ -154,19 +154,19 @@ AND NOT (DENY TAG C OR DENY TAG D)</pre>
검색 질문 검색 질문
<textarea class="form-control" name="query" rows="3" placeholder="예: Oracle VPD에서 ORDS 권한을 적용하는 방법" required></textarea> <textarea class="form-control" name="query" rows="3" placeholder="예: Oracle VPD에서 ORDS 권한을 적용하는 방법" required></textarea>
</label> </label>
<button class="btn rw-btn-primary" type="submit">권한 적용 검색</button> <button class="btn rw-btn-primary" type="submit">검색 실행</button>
</form> </form>
<section id="vector-search-result" class="mt-3" aria-live="polite"> <section id="vector-search-result" class="mt-3" aria-live="polite">
<div class="empty-result-guide">검색하면 선택한 사용자에게 허용된 청크만 여기에 표시됩니다.</div> <div class="empty-result-guide">검색 권한에 따라 허용된 지식자료만 여기에 표시됩니다.</div>
</section> </section>
</section> </section>
<section class="content-band"> <section class="content-band">
<details class="explanation-details"> <details class="explanation-details">
<summary>현재 저장 현황 보기</summary> <summary>저장 현황 보기</summary>
<div class="summary-grid mt-3"> <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.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.chunkCount()}">0</strong></div>
<div class="summary-tile"><span class="label">태그 연결</span><strong th:text="${summary == null ? '-' : summary.tagCount()}">0</strong></div> <div class="summary-tile"><span class="label">태그 연결</span><strong th:text="${summary == null ? '-' : summary.tagCount()}">0</strong></div>
</div> </div>
</details> </details>

View File

@@ -88,7 +88,7 @@
<label class="span-2"> <label class="span-2">
이 Filter가 보호하는 내용 이 Filter가 보호하는 내용
<input class="form-control" name="description" maxlength="500" <input class="form-control" name="description" maxlength="500"
placeholder="예: 지식자료 TECH_TAG가 권한에 포함된 청크만 허용" required> placeholder="예: 지식자료 TECH_TAG가 권한에 포함된 검색 단위만 허용" required>
</label> </label>
<div class="question-presets span-2" aria-label="Filter predicate 안전 예시"> <div class="question-presets span-2" aria-label="Filter predicate 안전 예시">
<button class="btn rw-btn-secondary question-preset" type="button" data-target="filter-only-predicate" data-question="1=0">안전한 기본 차단</button> <button class="btn rw-btn-secondary question-preset" type="button" data-target="filter-only-predicate" data-question="1=0">안전한 기본 차단</button>

View File

@@ -16,7 +16,7 @@ class GuidedFlowTemplateTest {
assertThat(html) assertThat(html)
.contains("1. 권한 설계") .contains("1. 권한 설계")
.contains("2. DB 보호 연결") .contains("2. DB 보호 연결")
.contains("3. 토큰 발급") .contains("3. 검증 세션")
.contains("4. 결과 확인"); .contains("4. 결과 확인");
} }
@@ -27,7 +27,7 @@ class GuidedFlowTemplateTest {
assertThat(probe) assertThat(probe)
.contains("name=\"bearerToken\"") .contains("name=\"bearerToken\"")
.contains("임시 토큰 사용자") .contains("검증 세션 사용자")
.doesNotContain("name=\"tokenKeyId\""); .doesNotContain("name=\"tokenKeyId\"");
assertThat(result) assertThat(result)
.contains("적용된 사용자와 권한") .contains("적용된 사용자와 권한")
@@ -58,7 +58,7 @@ class GuidedFlowTemplateTest {
assertThat(html) assertThat(html)
.contains("누가 어떤 행을 볼 수 있는지는 권한 규칙과 VPD에서 결정") .contains("누가 어떤 행을 볼 수 있는지는 권한 규칙과 VPD에서 결정")
.contains("벡터 지식자료 검색 흐름") .contains("지식 검색 연결 흐름")
.contains("VPD context 설정과 기본 SELECT") .contains("VPD context 설정과 기본 SELECT")
.contains("전용 벡터 Handler 사용") .contains("전용 벡터 Handler 사용")
.contains("원문 표시 허용 컬럼") .contains("원문 표시 허용 컬럼")
@@ -103,8 +103,8 @@ class GuidedFlowTemplateTest {
String client = template("mcp-client-demo.html"); String client = template("mcp-client-demo.html");
String sse = template("mcp-sse.html"); String sse = template("mcp-sse.html");
assertThat(chatbot).contains("임시 토큰 사용자").contains("태그 벡터 검색"); assertThat(chatbot).contains("검증 세션 사용자").contains("태그 벡터 검색");
assertThat(reasoning).contains("MCP tool을 고르고").contains("임시 토큰 사용자").contains("MCP Tool"); assertThat(reasoning).contains("MCP tool을 고르고").contains("검증 세션 사용자").contains("MCP Tool");
assertThat(client).contains("tool 선택과 호출은 reasoning 결과").doesNotContain("Context Path"); assertThat(client).contains("tool 선택과 호출은 reasoning 결과").doesNotContain("Context Path");
assertThat(sse).contains("Instruction / parameter mapping").contains("bearerToken"); assertThat(sse).contains("Instruction / parameter mapping").contains("bearerToken");
} }
@@ -115,10 +115,11 @@ class GuidedFlowTemplateTest {
String result = template("fragments/vector-search-result.html"); String result = template("fragments/vector-search-result.html");
assertThat(vector) assertThat(vector)
.contains("문서 등록 → 청킹 → 임베딩 → 태그 저장") .contains("지식자료 등록")
.contains("ALLOW TAG SPRING_BOOT") .contains("ALLOW TAG SPRING_BOOT")
.contains("DENY TAG ORDS") .contains("DENY TAG ORDS")
.contains("권한 적용 벡터 검색") .contains("권한 기반 지식 검색")
.contains("로컬 임베딩(개발용)")
.contains("/vector-knowledge/ingest") .contains("/vector-knowledge/ingest")
.contains("/vector-knowledge/search"); .contains("/vector-knowledge/search");
assertThat(result) assertThat(result)
@@ -126,6 +127,19 @@ class GuidedFlowTemplateTest {
.contains("검색 기술 상세 보기"); .contains("검색 기술 상세 보기");
} }
@Test
void vectorOrdsHandlerReadsBodyStreamOnlyOnce() throws IOException {
String sql = Files.readString(Path.of("sql/adb/29_agent_ords_vector_search_ords.sql"));
assertThat(sql)
.contains("v_body_text CLOB")
.contains("v_body_text := :body_text")
.contains("Duplicate stream parameter")
.doesNotContain("JSON_QUERY(\n :body_text")
.doesNotContain("JSON_VALUE(\n :body_text");
assertThat(sql.split("v_body_text := :body_text", -1)).hasSize(2);
}
private String template(String relativePath) throws IOException { private String template(String relativePath) throws IOException {
return Files.readString(Path.of("src/main/resources/templates").resolve(relativePath)); return Files.readString(Path.of("src/main/resources/templates").resolve(relativePath));
} }