Compare commits

...

2 Commits

Author SHA1 Message Date
devmrko
7be7dacd5f docs: trace DDS token grant implementation 2026-06-30 13:20:27 +09:00
devmrko
a5e70bcff1 feat: enforce DDS token access through common grants 2026-06-30 13:20:00 +09:00
19 changed files with 510 additions and 69 deletions

View File

@@ -69,6 +69,7 @@ export DDSUSER_MY_PASSWORD="DdsGrant#My2026"
export DDSUSER_PG_PASSWORD="DdsGrant#Pg2026"
export DDSUSER_BOTH_PASSWORD="DdsGrant#Both26"
export DDSUSER_NONE_PASSWORD="DdsGrant#None26"
export DDSUSER_TOKEN_PASSWORD="DdsGrant#Token26"
# --- (4) 원격 Postgres (AWS RDS, Cloud SQL, ...) ---
# sql/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성.

View File

@@ -7,8 +7,9 @@
- 권한 반영: `/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 연결
- DDS 직접 비교 사용자: `dds_demo_my`, `dds_demo_pg`, `dds_demo_both`, `dds_demo_none`
- 토큰 경로 기술 사용자: `dds_demo_token` (고객별 DDS 계정이 아님)
- 조회 방식: 직접 DDS END USER 비교 경로 + 단일 기술 사용자·Bearer 토큰 경로
## 두 데모의 관계
@@ -18,16 +19,17 @@
사용자·그룹·역할 → 데이터 권한 규칙 → 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로 직접 집행합니다.
8083 DDS 화면의 `/users`, `/groups`, `/roles`, `/permissions`, `/effective-matrix`는 8082 VPD 화면과 같은 관리 기준을 보여줍니다. 보호 객체별 DDS `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 |
| DDS 직접 비교 | DDS `END USER` 직접 로그인 | `DATA ROLE` + `DATA GRANT` | 선언형 DDL/Grant |
| DDS 토큰 경로 | 단일 DDS 기술 사용자 + Bearer → `CB_AGENT_CTX` | 객체별 `DATA GRANT` predicate + 공통 `CB_*` | 요청 시 공통 권한 재평가 |
따라서 두 인스턴스의 관리 흐름과 기대 결과 맞출 수 있지만, Bearer 요청마다 권한을 계산하는 VPD 실행 모델을 DDS가 그대로 대체하는 것은 아닙니다. DDS로 Bearer 기반 호출을 운영하려면 지원 드라이버의 `EndUserSecurityContext`파가 별도로 필요합니다.
따라서 두 인스턴스의 관리 흐름과 기대 결과 맞출 수 있습니다. 토큰 경로는 `DATA GRANT``ON` 대상과 `WHERE` predicate를 사용하면서, 토큰 자체는 먼저 신뢰된 Context로 해석합니다. IAM 토큰을 DDS의 순수 `EndUserSecurityContext`달하는 방식은 별도 확장 경계입니다.
권한 변경 후에는 다음 순서로 확인합니다.
@@ -35,16 +37,18 @@
1. /permissions에서 사용자·그룹·역할·행/TAG·컬럼 기준을 저장
2. /dds-provision에서 사용자별 predicate와 제외 컬럼을 검토
3. 승인된 경우에만 DDS DATA GRANT 게시
4. /dds 또는 /vector-knowledge에서 END USER별 결과를 검증
4. /vector-knowledge에서 토큰 기반 공통 권한 결과를 검증
5. /dds에서 직접 END USER별 저수준 결과를 비교
```
이 앱의 조회 경계는 다음과 같습니다.
```text
END USER → DATA ROLE → DATA GRANT → DDS 보호 VIEW → 조회 결과
Bearer → CB_AGENT_CTX → DATA GRANT predicate → DDS 보호 VIEW → 조회 결과
직접 비교: END USER → DATA ROLE → DATA GRANT → DDS 보호 VIEW
```
ORDS Handler가 Bearer 값을 `cb_dds_hr` 같은 문자열로 매핑하는 것만으로는 DDS 보안 컨텍스트가 전달되지 않습니다. 따라서 이 비교 인스턴스는 DDS `END USER` 직접 logon을 검증합니다. Bearer/ORDS 전달이 필요하면 지원 드라이버의 `EndUserSecurityContext`를 별도 설계해야 합니다.
ORDS Handler가 Bearer 값을 `cb_dds_hr` 같은 문자열로 바꾸는 것만으로는 DDS Context가 자동 생성되지 않습니다. 토큰 경로에서는 `CB_AGENT_CTX_PKG.SET_USER_BY_BEARER`가 해시·만료·회수·업무 사용자 매핑을 확인하고, 객체별 Data Grant predicate가 공통 권한 테이블을 조회합니다. 지원 드라이버의 `EndUserSecurityContext` 사용하는 순수 DDS 경로는 별도 설계니다.
## 실행
@@ -53,10 +57,15 @@ ORDS Handler가 Bearer 값을 `cb_dds_hr` 같은 문자열로 매핑하는 것
```bash
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/31_dds_standalone_demo_setup.sql
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/32_dds_vector_tag_setup.sql
bash scripts/setup-dds-token-data-grant.sh
```
`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 자체를 볼 수 없습니다.
`34_dds_token_data_grant_common_auth.sql``dds_demo_token` 하나와 `cb_dds_token_role` 하나를 만들고, `ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS`에 객체별 Data Grant를 연결합니다. Data Grant의 predicate 함수는 직접 역할과 활성 그룹 역할을 합친 뒤 `CB_PERMISSION`·`CB_PERMISSION_RULE`의 TAG 허용/거부 규칙을 요청 시 평가합니다. 토큰은 Data Grant 문법의 인자가 아니라 `CB_AGENT_CTX`를 초기화하는 입력입니다.
토큰 경로를 제거할 때는 사용 중인 애플리케이션이 없는지 확인한 뒤 `sql/adb/35_dds_token_data_grant_cleanup.sql`을 별도로 승인해 실행합니다.
그 다음 DDS 인스턴스를 실행합니다.
```bash
@@ -66,10 +75,11 @@ export DDSUSER_MY_PASSWORD='...'
export DDSUSER_PG_PASSWORD='...'
export DDSUSER_BOTH_PASSWORD='...'
export DDSUSER_NONE_PASSWORD='...'
export DDSUSER_TOKEN_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 비밀번호는 서버 환경 변수로만 읽고 화면에 표시하거나 저장하지 않습니다.
관리자 로그인은 `DDS_BACKOFFICE_ADMIN_USER` / `DDS_BACKOFFICE_ADMIN_PASSWORD`를 사용합니다. 직접 비교용 DDS END USER와 토큰 경로의 기술 사용자 비밀번호는 서버 환경 변수로만 읽고 화면에 표시하거나 저장하지 않습니다. 토큰은 `/vector-knowledge` 요청 본문에서만 처리하고 저장하지 않습니다.
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 승인을 별도로 거쳐야 합니다.

View File

@@ -15,7 +15,8 @@ public record DdsProperties(
String myObject,
String vectorObject,
Map<String, User> users,
Map<String, String> objectMappings
Map<String, String> objectMappings,
Token token
) {
private static final Pattern OBJECT_NAME = Pattern.compile(
@@ -47,6 +48,7 @@ public record DdsProperties(
});
}
objectMappings = Map.copyOf(normalizedMappings);
token = token == null ? new Token("dds_demo_token", "") : token;
}
/**
@@ -63,7 +65,8 @@ public record DdsProperties(
) {
this(dbUrl, queryTimeout, pgObject, myObject,
"ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS", users, Map.of(
"CB_VECTOR_SEARCH_DOCUMENTS", "ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS"));
"CB_VECTOR_SEARCH_DOCUMENTS", "ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS"),
new Token("dds_demo_token", ""));
}
public DdsProperties(
@@ -74,7 +77,15 @@ public record DdsProperties(
String vectorObject,
Map<String, User> users
) {
this(dbUrl, queryTimeout, pgObject, myObject, vectorObject, users, Map.of());
this(dbUrl, queryTimeout, pgObject, myObject, vectorObject, users, Map.of(),
new Token("dds_demo_token", ""));
}
public record Token(String username, String password) {
public boolean configured() {
return username != null && !username.isBlank()
&& password != null && !password.isBlank();
}
}
public String objectFor(String sourceKey) {

View File

@@ -18,11 +18,16 @@ 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.
* This service is the explicit publish boundary for the direct END USER
* comparison path: 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.
*
* The token-driven vector path is intentionally separate. Its object-level
* DATA GRANT calls a definer-rights predicate function that evaluates the
* common CB_* tables at query time after CB_AGENT_CTX has been initialized by
* the bearer token.
*/
@Service
public class DdsGrantPublisher {

View File

@@ -27,9 +27,11 @@ 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.
* Ingestion still writes the shared chunk/tag store. Direct comparison search
* opens a DDS END USER connection and lets DATA ROLE/DATA GRANT decide which
* chunks are visible. Token search uses one configured technical DDS user,
* initializes CB_AGENT_CTX from the bearer token, and relies on the
* object-level DATA GRANT predicate to evaluate the common CB_* permissions.
*/
@Service
public class DdsVectorKnowledgeService {
@@ -144,6 +146,100 @@ public class DdsVectorKnowledgeService {
}
}
/**
* Search through the token-driven DDS path.
*
* The technical DDS end user is fixed in configuration. The bearer token is
* resolved by the shared CB_AGENT_CTX package on that same connection; the
* object-level DATA GRANT then evaluates the common CB_* permission model.
*/
public DdsVectorSearchResult searchByToken(
String bearerToken, String query, int requestedLimit, String embeddingMode) {
String normalizedToken = required(bearerToken, "Bearer 토큰");
if (normalizedToken.length() > 4096) {
throw new AppException("Bearer 토큰 길이가 허용 범위를 초과했습니다.");
}
String normalizedQuery = required(query, "검색 질문");
String mode = normalizeMode(embeddingMode);
var tokenUser = properties.token();
String userLabel = "토큰 기반 업무 사용자";
if (tokenUser == null || !tokenUser.configured()) {
return failure("token", userLabel, normalizedQuery, mode,
"DDS 토큰 기술 사용자가 설정되지 않았습니다.",
"DDS_BACKOFFICE_TOKEN_USERNAME/PASSWORD와 34번 DDS 설정을 확인하세요.", null);
}
if (properties.dbUrl().isBlank()) {
return failure("token", userLabel, normalizedQuery, mode,
"DDS 데이터베이스 연결 정보가 없습니다.",
"DDS_BACKOFFICE_DB_URL 또는 BACKOFFICE_DB_URL을 설정하세요.", 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(tokenUser.username()));
connectionProperties.setProperty("password", tokenUser.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);
try (PreparedStatement context = connection.prepareStatement(
"BEGIN ADMIN.CB_AGENT_CTX_PKG.SET_USER_BY_BEARER(?); END;")) {
context.setString(1, normalizedToken);
context.execute();
}
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);
String resolvedAppUser = readSingleValue(connection,
"SELECT user_name FROM ADMIN.CB_APP_USER "
+ "WHERE user_id = TO_NUMBER(SYS_CONTEXT('CB_AGENT_CTX', 'USER_ID'))",
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()
? "토큰으로 식별된 업무 사용자의 권한을 통과한 검색 단위가 없습니다."
: rows.size() + "개 검색 단위가 토큰으로 식별된 업무 사용자("
+ (resolvedAppUser == null ? "확인 불가" : resolvedAppUser)
+ ")의 DDS DATA GRANT를 통과했습니다.";
return new DdsVectorSearchResult(
"token", userLabel, properties.vectorObject(), normalizedQuery, mode,
embeddingModel(mode), true, "토큰 기반 DDS 권한으로 검색했습니다.", message,
sessionUser, endUser, null, rows);
}
} catch (SQLException exception) {
return failure("token", userLabel, normalizedQuery, mode,
classifyTitle(exception), classifyMessage(exception), oracleCode(exception));
}
}
private DdsVectorSearchResult failure(
String userKey,
String userLabel,
@@ -290,6 +386,9 @@ public class DdsVectorKnowledgeService {
private static String classifyTitle(SQLException exception) {
String message = flattenedMessage(exception).toUpperCase(Locale.ROOT);
if (message.contains("ORA-20002")) {
return "Bearer 토큰이 유효하지 않습니다.";
}
if (message.contains("ORA-00942")) {
return "이 DDS END USER에게 지식자료가 허용되지 않았습니다.";
}
@@ -306,6 +405,9 @@ public class DdsVectorKnowledgeService {
private static String classifyMessage(SQLException exception) {
String message = flattenedMessage(exception);
String upper = message.toUpperCase(Locale.ROOT);
if (upper.contains("ORA-20002")) {
return "토큰이 없거나 만료·회수되었거나 공통 사용자 매핑을 찾지 못했습니다.";
}
if (upper.contains("ORA-00942")) {
return "END USER의 DATA ROLE에 이 DDS 벡터 VIEW를 대상으로 한 DATA GRANT가 없습니다. default deny 결과입니다.";
}

View File

@@ -72,6 +72,23 @@ public class DdsVectorKnowledgeController {
return "fragments/dds-vector-search-result :: result";
}
@PostMapping("/vector-knowledge/token-search")
public String tokenSearch(
@RequestParam String bearerToken,
@RequestParam String query,
@RequestParam(defaultValue = "10") int limit,
@RequestParam(defaultValue = "DEMO") String embeddingMode,
Model model
) {
try {
model.addAttribute("searchResult",
service.searchByToken(bearerToken, 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());

View File

@@ -64,6 +64,9 @@ dds:
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}
token:
username: ${DDS_BACKOFFICE_TOKEN_USERNAME:dds_demo_token}
password: ${DDS_BACKOFFICE_TOKEN_PASSWORD:${DDSUSER_TOKEN_PASSWORD:}}
users:
my:
label: MY 전용 사용자

View File

@@ -7,10 +7,10 @@
<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>
<p class="context-summary">권한 설계 → 객체별 DATA GRANT → 토큰 Context 또는 END USER → 보호 결과 확인</p>
<details class="explanation-details">
<summary>이 인스턴스의 역할 보기</summary>
<p>8083 DDS 인스턴스는 8082 VPD 인스턴스와 별도로 실행됩니다. 두 화면은 사용자·그룹·역할·권한 규칙을 같은 관리 흐름으로 보여주지만, DDS는 END USER·DATA ROLE·DATA GRANT를 통해 데이터를 보호합니다.</p>
<p>8083 DDS 인스턴스는 8082 VPD 인스턴스와 별도로 실행됩니다. 두 화면은 사용자·그룹·역할·권한 규칙을 같은 관리 흐름으로 보여주, DDS는 보호 객체별 DATA GRANT로 그 규칙을 집행합니다.</p>
<p class="mb-0">일상적인 권한 변경은 권한 관리에서 하고, DDS 보호 객체와 실제 결과는 이 인스턴스에서 확인합니다.</p>
</details>
</header>
@@ -20,14 +20,14 @@
<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>
<a class="journey-card" href="/dds-provision"><span class="journey-number">3</span><div><h2>3. DDS 권한 연결</h2><p>보호 VIEW별 DATA GRANT와 공통 권한 predicate를 확인합니다.</p><strong>Grant 미리보기·게시 →</strong></div></a>
<a class="journey-card" href="/vector-knowledge"><span class="journey-number">4</span><div><h2>4. 토큰 권한</h2><p>하나의 기술 사용자에 Bearer 토큰을 연결해 공통 권한을 적용합니다.</p><strong>토큰 기반 검색</strong></div></a>
<a class="journey-card" href="/dds"><span class="journey-number">5</span><div><h2>5. 직접 END USER 비교</h2><p>저수준 DDS END USER·DATA ROLE 결과를 별도로 확인합니다.</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>
<div><span class="architecture-kicker">MICRO · 실행 관점</span><h2>객체별 DATA GRANT가 최종 집행합니다.</h2><p>토큰 경로는 <code>Bearer → CB_AGENT_CTX → DATA GRANT predicate → 보호 VIEW</code>, 직접 비교는 <code>END USER → DATA ROLE → DATA GRANT → 보호 VIEW</code>입니다. 권한이 없으면 객체가 보이지 않 <code>ORA-00942</code>가 될 수 있습니다.</p></div>
</section>
<section class="summary-grid" aria-label="현재 DDS 관리 현황">
@@ -40,7 +40,7 @@
<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>
<tr><td><code th:text="${vectorObject}">ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS</code></td><td>객체별 TAG DATA GRANT + 토큰 Context + VECTOR_DISTANCE</td><td><a href="/vector-knowledge">지식 검색</a></td></tr>
</tbody></table></div>
</section>
</main>

View File

@@ -10,8 +10,8 @@
<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>
<p>이 화면은 직접 DDS END USER 비교 경로와 일반 객체의 선언형 Grant를 미리 보고 게시하는 단계입니다. 토큰 기반 벡터 경로는 별도 객체별 Data Grant predicate가 요청 시 공통 권한 테이블을 다시 평가합니다.</p>
<p class="mb-0">그룹 자체를 DDS 그룹으로 복사하지는 않습니다. 애플리케이션 그룹의 역할 상속을 계산해 직접 비교용 DATA ROLE Grant에 반영하고, 토큰 경로에서는 같은 effective role 계산을 predicate 함수가 사용합니다.</p>
</details>
</div>
@@ -27,6 +27,7 @@
<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>Bearer 토큰 벡터 검색</td><td>기술 사용자 Context + 객체별 Data Grant predicate</td><td><code>34_dds_token_data_grant_common_auth.sql</code> 경계</td></tr>
<tr><td>원문 표시 허용 컬럼</td><td><code>AS SELECT</code> 또는 <code>ALL COLUMNS EXCEPT</code></td><td>권한 변경 후 게시</td></tr>
</tbody>
</table>

View File

@@ -69,7 +69,7 @@
<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>로 별도 처리합니다.
<strong>DDS 독립 데모</strong> · VPD와 같은 관리 흐름을 사용하, 실제 보호는 객체별 <code>DATA GRANT</code>가 담당합니다. 토큰 경로는 <code>Bearer → CB_AGENT_CTX → predicate</code>, 직접 비교는 <code>END USER → DATA ROLE → DATA GRANT</code>니다.
</div>
</div>
@@ -83,13 +83,13 @@
<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>
<p th:text="${backofficeTrack == 'DDS' ? '보호 객체별 DATA GRANT predicate가 관리된 권한을 행·컬럼에 적용합니다.' : '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>
<p th:text="${backofficeTrack == 'DDS' ? '토큰 Context 또는 DDS END USER로 조회 주체를 전달합니다.' : '확인할 사용자를 나타내는 일회성 토큰을 준비합니다.'}">조회 주체를 준비합니다.</p>
</div>
<div class="architecture-arrow"></div>
<div class="architecture-step" th:classappend="${activeLayer == 'ords'} ? ' active'">

View File

@@ -11,7 +11,7 @@
<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>
<p class="mb-0">VPD 화면과 같은 사용자·그룹·역할·권한 규칙을 사용합니다. 아래에는 DDS END USER를 직접 선택하는 비교 경로와, 하나의 DDS 기술 사용자에 Bearer 토큰을 전달해 공통 권한을 평가하는 제품형 경로를 함께 둡니다.</p>
</details>
</div>
@@ -85,16 +85,58 @@
</div>
<details class="explanation-details mt-3">
<summary>VPD와 DDS의 차이 보기</summary>
<p>VPD는 요청마다 권한 테이블을 읽어 predicate를 계산합니다. DDS는 보호 VIEW에 선언한 DATA GRANT와 END USER 매핑을 DB가 적용합니다. 따라서 태그 규칙을 바꾸면 DDS grant 반영 절차도 함께 확인해야 합니다.</p>
<p>VPD는 요청마다 권한 테이블을 읽어 predicate를 계산합니다. DDS의 직접 비교 경로는 보호 VIEW에 선언한 DATA GRANT와 END USER 매핑을 용합니다. 토큰 경로에서는 토큰이 공통 사용자 Context를 만들고, 보호 VIEW의 객체별 DATA GRANT predicate가 같은 권한 테이블을 다시 평가합니다.</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>
<span class="architecture-kicker">3 · TOKEN CONTEXT</span>
<h2>Bearer 토큰으로 같은 DDS Data Grant 적용</h2>
<p class="section-subtitle">고객별 DDS 계정을 만들지 않고 하나의 기술 사용자 세션에서 토큰으로 업무 사용자를 식별합니다.</p>
</div>
<span class="badge text-bg-secondary">객체별 DATA GRANT</span>
</div>
<details class="explanation-details">
<summary>토큰 경계 설명 보기</summary>
<ol>
<li>서버가 설정한 하나의 DDS 기술 사용자로 연결합니다.</li>
<li>Bearer 토큰을 <code>CB_AGENT_CTX_PKG.SET_USER_BY_BEARER</code>에 전달합니다.</li>
<li><code>CB_DDS_VECTOR_SEARCH_DOCUMENTS</code>의 객체별 DATA GRANT predicate가 <code>CB_APP_USER</code>, 역할·그룹, <code>CB_PERMISSION</code>, <code>CB_PERMISSION_RULE</code>을 조회합니다.</li>
<li>ALLOW TAG는 OR로 합치고 DENY TAG는 최종 결과에서 제외합니다.</li>
</ol>
<p class="mb-0">토큰은 DATA GRANT 문법의 바인드 파라미터가 아닙니다. 토큰이 만든 신뢰된 Context를 Data Grant predicate가 참조하는 구조입니다. 토큰은 저장하거나 화면에 재표시하지 않습니다.</p>
</details>
<form hx-post="/vector-knowledge/token-search" hx-target="#vector-token-search-result" hx-swap="innerHTML" class="form-grid mt-3">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label class="span-2">Bearer 토큰
<input class="form-control" name="bearerToken" type="password" autocomplete="off" placeholder="발급된 임시 토큰을 붙여 넣으세요." required>
<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="예: ORACLE_VPD 태그가 있는 지식자료의 접근 조건" required></textarea>
</label>
<button class="btn rw-btn-primary" type="submit">토큰 권한으로 검색</button>
</form>
<section id="vector-token-search-result" class="mt-3" aria-live="polite">
<div class="empty-result-guide">Bearer 토큰과 질문을 입력하면 공통 권한체계를 반영한 DDS Data Grant 결과를 표시합니다.</div>
</section>
</section>
<section class="content-band">
<div class="section-heading">
<div>
<span class="architecture-kicker">4 · DIRECT CHECK</span>
<h2>DDS END USER 직접 조회 비교</h2>
<p class="section-subtitle">저수준 DDS 검증을 위해 선택한 END USER로 직접 접속해 DATA ROLE/DATA GRANT 결과를 확인합니다.</p>
</div>
<span class="badge text-bg-primary" th:text="${ddsVectorObject}">DDS view</span>
</div>

View File

@@ -10,7 +10,7 @@
<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>VPD는 Policy가 Filter function을 호출해 요청마다 predicate를 계산합니다. DDS는 보호 VIEW/TABLE별 DATA GRANT에 행·컬럼 조건을 선언하고, 필요하면 predicate가 공통 권한을 읽는 Definer-rights 함수를 호출합니다.</p>
<p class="mb-0">따라서 이 화면에서는 VPD Filter를 수정하지 않습니다. 일상적인 변경은 <a href="/permissions">권한 관리</a>에서 하고, DDS grant 반영은 승인된 SQL/배포 절차로 수행합니다.</p>
</details>
</div>
@@ -36,7 +36,7 @@
<span>사용자·그룹·역할</span><strong></strong>
<span>객체별 행·TAG·컬럼 권한</span><strong></strong>
<span>DATA ROLE / DATA GRANT</span><strong></strong>
<span>END USER 결과</span>
<span>토큰 Context 또는 END USER 결과</span>
</div>
<p class="text-muted mb-0">관리 대상 이름: <code th:text="${managementObject}">CB_VECTOR_SEARCH_DOCUMENTS</code></p>
</section>
@@ -55,8 +55,8 @@
<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>토큰 Context/END USER → DATA ROLE → 객체별 DATA GRANT</td>
<td>Grant predicate가 없거나 조건 불일치 시 차단</td>
<td><a class="btn btn-sm btn-outline-primary" href="/vector-knowledge">검색 결과 확인</a></td>
</tr>
</tbody>
@@ -64,8 +64,8 @@
</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>
<p>새 보호 대상을 추가할 때는 (1) 권한 관리에 논리 객체와 TAG 규칙을 등록하고 (2) DDS 전용 VIEW/TABLE을 만들고 (3) 해당 객체<code>CREATE OR REPLACE DATA GRANT ... WHERE ...</code>를 연결하고 (4) 토큰 Context와 직접 END USER의 허용·거부·권한 없음을 각각 검증합니다.</p>
<p class="mb-0">벡터 기준 SQL은 <code>sql/adb/32_dds_vector_tag_setup.sql</code>, 토큰 기반 공통 predicate 기준은 <code>sql/adb/34_dds_token_data_grant_common_auth.sql</code>입니다.</p>
</details>
</section>

View File

@@ -56,4 +56,19 @@ class DdsPropertiesTest {
assertEquals("dds_demo_my_role", properties.users().get("my").dataRole());
assertTrue(properties.users().get("my").mapped());
}
@Test
void bindsTokenTechnicalUserSeparatelyFromApplicationUsers() {
var source = new MapConfigurationPropertySource(Map.of(
"dds.token.username", "dds_demo_token",
"dds.token.password", "secret"
));
var properties = new Binder(source)
.bind("dds", Bindable.of(DdsProperties.class))
.orElseThrow(() -> new AssertionError("dds token properties did not bind"));
assertEquals("dds_demo_token", properties.token().username());
assertTrue(properties.token().configured());
}
}

View File

@@ -1,7 +1,7 @@
# 설계서: 기술 태그 기반 벡터 지식자료 검색과 VPD 연결
> **상태**: 제품형 운영 흐름 구현 · DDS 병행 검증 완료 · 운영 확장 항목 별도
> **추적성**: Redmine #565, #566 · 기준 구현: `28_agent_ords_vector_tag_vpd_setup.sql`, `29_agent_ords_vector_search_ords.sql`, `32_dds_vector_tag_setup.sql`
> **상태**: 제품형 운영 흐름 구현 · DDS 병행 검증 완료 · 객체별 Data Grant predicate 적용 · 운영 확장 항목 별도
> **추적성**: Redmine #565, #566 · 구현 커밋 `a5e70bc` · 기준 구현: `28_agent_ords_vector_tag_vpd_setup.sql`, `29_agent_ords_vector_search_ords.sql`, `32_dds_vector_tag_setup.sql`, `34_dds_token_data_grant_common_auth.sql`
## 1. 한 문장으로 이해하기
@@ -185,17 +185,18 @@ backoffice의 `/objects` 화면은 이 객체에 일반 Handler를 생성하지
## 10. DDS 병행 시나리오: 같은 TAG 권한, 단일 기술 사용자
VPD에서 검증한 기술 태그 권한을 DDS에서도 고객별 DDS 계정으로 복제하지 않고 적용할 수 있는지 별도로 확인했다. 업무 권한의 원천은 동일한 `CB_PERMISSION`·`CB_PERMISSION_RULE`이다. DDS 설치 스크립트는 이 규칙을 벡터 검색 뷰와 연결하고, 애플리케이션 함수가 토큰을 받아 같은 규칙을 `WHERE`로 집행한다.
VPD에서 검증한 기술 태그 권한을 DDS에서도 고객별 DDS 계정으로 복제하지 않고 적용할 수 있는지 별도로 확인했다. 업무 권한의 원천은 동일한 `CB_PERMISSION`·`CB_PERMISSION_RULE`이다. DDS 설치 스크립트는 이 규칙을 벡터 검색 뷰의 객체별 Data Grant predicate와 연결한다. 토큰 함수는 먼저 `CB_AGENT_CTX`에 업무 사용자를 설정하고, Data Grant predicate가 그 Context와 공통 권한 테이블을 사용해 각 행을 평가한다.
```text
Bearer token
→ CB_AGENT_BEARER_KEY에서 사용자 식별
→ CB_APP_USER의 직접 역할/활성 그룹 역할 계산
→ ALLOW TAG는 OR, DENY TAG는 NOT EXISTS
→ CB_DDS_VECTOR_SEARCH_DOCUMENTS 조회
→ CB_DDS_VECTOR_SEARCH_DOCUMENTS의 DATA GRANT predicate 평가
→ 허용된 청크 반환
```
하나의 `dds_demo_both` DDS 기술 사용자 세션에서 임시 `CB_SIMPLE_DDS_TOKEN_SELECT(token)` 함수를 실행한 결과는 다음과 같다.
하나의 `dds_demo_token` DDS 기술 사용자 세션에서 `CB_AGENT_CTX_PKG.SET_USER_BY_BEARER(token)`을 호출한 뒤 객체별 `ADMIN.DDS_DEMO_TOKEN_VECTOR_GRANT`가 공통 권한을 평가한 결과는 다음과 같다.
| 토큰 사용자 | 반환 청크 |
|---|---|
@@ -203,6 +204,6 @@ Bearer token
| `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`를 설계한다.
이 방식의 의미는 “DDS가 토큰 문자열을 Data Grant 문법의 인자로 받는다”가 아니다. `DATA GRANT``ON` 대상 뷰와 저장된 SQL predicate를 선언하고, 토큰 함수가 그 predicate가 참조할 Context를 만든다. 따라서 현재 제품형 기본 시나리오는 **단일 DDS 기술 사용자 + 객체별 DATA GRANT predicate + 공통 업무 권한**이다. 여러 보호 뷰/테이블에는 각각 Data Grant를 만들고, 각 predicate에서 논리 권한 대상과 행 규칙을 매핑한다. 드라이버가 SQL 실행 전에 `EndUserSecurityContext`를 전달하는 순수 DDS Context 방식은 별도 확장 경계이며, 그 경우에 `ORA_END_USER_CONTEXT`를 참조하는 일반 `DATA GRANT`를 설계한다.
토큰, 임시 함수, 임시 권한은 검증 뒤 삭제했다. 새 태그나 역할을 추가할 때도 VPD와 DDS가 각각 고객 사용자를 복제하는 대신, 공통 `CB_*` 권한을 먼저 수정하고 두 집행 경계의 회귀 테스트를 함께 실행한다.

View File

@@ -1,12 +1,12 @@
# 독립 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·DDS 병행 검증 완료, 토큰 기반 객체별 Data Grant 적용 완료
> **추적성**: Redmine #565, #566 · DDS 구현 커밋 `1e48864`, `a5e70bc`
> **기준 구현**: `sql/adb/32_dds_vector_tag_setup.sql`, `sql/adb/34_dds_token_data_grant_common_auth.sql`, `dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsGrantPublisher.java`, `dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsVectorKnowledgeService.java`
## 목적
VPD와 Oracle Deep Data Security(DDS)를 별도 인스턴스로 운영하면서도, 고객의 업무 권한을 정의하는 공통 테이블을 기준으로 같은 검색 시나리오를 비교한다. 고객마다 DDS 계정을 만드는 것이 목표가 아니다. 현재 기본 데모는 하나의 DDS 기술 사용자 세션에 Bearer 토큰을 함수 파라미터로 전달하고, 함수가 공통 권한 테이블을 읽어 `WHERE` 조건으로 결과를 제한한다.
VPD와 Oracle Deep Data Security(DDS)를 별도 인스턴스로 운영하면서도, 고객의 업무 권한을 정의하는 공통 테이블을 기준으로 같은 검색 시나리오를 비교한다. 고객마다 DDS 계정을 만드는 것이 목표가 아니다. 현재 기본 데모는 하나의 DDS 기술 사용자 세션에 Bearer 토큰으로 Context를 만들고, 객체별 Data Grant predicate가 공통 권한 테이블을 읽어 결과를 제한한다.
## 인스턴스 경계
@@ -14,8 +14,8 @@ VPD와 Oracle Deep Data Security(DDS)를 별도 인스턴스로 운영하면서
|---|---|---|
| 실행 디렉터리 | `/home/opc/apps/vpd-backoffice` | `/home/opc/apps/dds-backoffice` |
| 기본 포트 | `8082` | `8083` |
| 사용자 식별 | 공통 DB 계정 + Bearer/세션 컨텍스트 | 단일 DDS 기술 사용자 + 토큰 파라미터 함수(기본 경로) |
| 권한 기준 | 업무 권한 테이블 + VPD 함수 | 같은 업무 권한 테이블 + 토큰 기반 `WHERE`; DDS `DATA ROLE/GRANT`는 별도 경계 |
| 사용자 식별 | 공통 DB 계정 + Bearer/세션 컨텍스트 | 단일 DDS 기술 사용자 + 토큰 Context(기본 경로) |
| 권한 기준 | 업무 권한 테이블 + VPD 함수 | 같은 업무 권한 테이블 + 객체별 DDS `DATA GRANT` predicate |
| 보호 객체 | `V_CUSTOMERS_*` / VPD 전용 객체 | `V_DDS_CUSTOMERS_*` / DDS 전용 객체 |
| 운영 프로세스 | VPD start/stop/status | DDS start/stop/status |
@@ -28,7 +28,7 @@ VPD와 Oracle Deep Data Security(DDS)를 별도 인스턴스로 운영하면서
- 권한 없는 사용자의 default deny
- 보호 VIEW 우회 시도 차단
## DDS 기본 시나리오: 한 사용자, 토큰 파라미터, 공통 `WHERE`
## DDS 기본 시나리오: 한 기술 사용자, 토큰 Context, 객체별 `DATA GRANT`
### 일반 사용자가 이해하는 흐름
@@ -38,27 +38,33 @@ Bearer 토큰
→ CB_APP_USER와 연결
→ 직접 역할 + 활성 그룹 역할 계산
→ CB_PERMISSION_RULE의 TAG 허용/거부 규칙 계산
→ CB_DDS_VECTOR_SEARCH_DOCUMENTS에 함수가 WHERE 적용
→ CB_DDS_VECTOR_SEARCH_DOCUMENTS의 DATA GRANT predicate가 행을 평가
→ 허용된 청크만 반환
```
`dds_demo_both` 하나의 DDS 기술 사용자 세션에서 임시 함수 `ADMIN.CB_SIMPLE_DDS_TOKEN_SELECT(token)`로 위 흐름을 검증했다. 함수는 고객용 DDS 계정을 새로 만들지 않고 토큰으로 업무 사용자를 식별한다. 같은 ALLOW 권한의 태그는 OR로 합치고, DENY 태그는 `NOT EXISTS`로 제외한다.
`dds_demo_token` 하나의 DDS 기술 사용자 세션에서 토큰 함수`CB_AGENT_CTX`를 설정하고, 객체별 Data Grant `ADMIN.DDS_DEMO_TOKEN_VECTOR_GRANT`의 predicate 함수가 공통 권한을 다시 평가하는 흐름을 검증했다. 고객마다 DDS 계정을 만들지 않고 토큰으로 업무 사용자를 식별한다. 같은 ALLOW 권한의 태그는 OR로 합치고, DENY 태그는 `NOT EXISTS` 의미로 제외한다.
호출 계약은 의도적으로 단순하다.
```sql
SELECT ADMIN.CB_SIMPLE_DDS_TOKEN_SELECT(:bearer_token)
FROM dual;
BEGIN
ADMIN.CB_AGENT_CTX_PKG.SET_USER_BY_BEARER(:bearer_token);
END;
/
SELECT chunk_id, tech_tag
FROM ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS
ORDER BY chunk_id;
```
실제 함수 내부의 조회 조건은 다음 의미를 가진다.
실제 Data Grant predicate 내부의 조회 조건은 다음 의미를 가진다.
```sql
WHERE EXISTS ( TAG TAG와 )
AND NOT EXISTS ( TAG TAG와 )
```
즉, 토큰은 사용자 식별 입력이고 권한 결정은 기존 `CB_*` 테이블 담당한다. 토큰별로 DDS 계정을 바꾸거나 고객 사용자마다 DDS `END USER`를 만드는 단계는 없다.
즉, 토큰은 Context 초기화 입력이고 권한 결정은 객체별 DDS Data Grant가 기존 `CB_*` 테이블을 조회해 담당한다. 토큰별로 DDS 계정을 바꾸거나 고객 사용자마다 DDS DB 계정을 만드는 단계는 없다. DDS End User Security Context를 드라이버가 직접 전달하는 순수 DDS 경로는 이 데모와 별도다.
### 검증 결과
@@ -70,16 +76,30 @@ WHERE EXISTS (허용된 TAG 중 하나가 청크 TAG와 일치)
토큰 행, 임시 함수, 임시 권한은 검증 직후 삭제했다. 이 결과는 “DDS 사용자 수 = 고객 사용자 수”가 아니라 “DDS 기술 사용자 1개 + 고객 권한 테이블” 모델이 동작함을 보여 준다.
## 두 가지 DDS 집행 경계
## DDS 집행 경계
| 구분 | 현재 기본 데모 | 순수 DDS Context 확장 |
| 구분 | 토큰 기반 기본 데모 | 순수 DDS Context 확장 |
|---|---|---|
| 토큰 전달 | 함수의 `token` 파라미터 | 지원 드라이버/ORDS가 `EndUserSecurityContext`로 전달 |
| 권한 계산 | 함수가 공통 `CB_*` 테이블을 읽어 `WHERE` 생성 | `DATA GRANT` predicate가 DDS 런타임 컨텍스트 참조 |
| 사용자 객체 | DDS 기술 사용자 1개 | DDS END USER 또는 데이터 역할 매핑 필요 |
| 현재 상태 | 검증 완료 | 드라이버·ORDS 전달 경로를 별도 검증해야 함 |
| 토큰 전달 | 토큰 함수가 신뢰된 `CB_AGENT_CTX` 설정 | 지원 드라이버/ORDS가 `EndUserSecurityContext`로 전달 |
| 권한 계산 | 객체별 `DATA GRANT` predicate가 공통 `CB_*` 테이블 조회 | `DATA GRANT` predicate가 DDS 런타임 컨텍스트 참조 |
| 사용자 객체 | DDS 기술 사용자 1개 + DDS Data Role 1개 | DDS End User identity와 Data Role 매핑 |
| 현재 상태 | 벡터 VIEW에서 검증 완료 | 드라이버·ORDS 전달 경로를 별도 검증해야 함 |
중요한 경계는 `DATA GRANT` 자체가 호출 시점의 임의 함수 인자를 받는 구조가 아니라는 점이다. 따라서 현재 시나리오에서는 토큰을 받는 함수/API가 조회 경계가 되고, DDS는 그 함수가 조회하는 보호 뷰와 기술 사용자 세션을 보호한다. 향후 순수 DDS Context를 사용하려면 토큰을 SQL 실행 전에 드라이버가 붙이고, 그때 `ORA_END_USER_CONTEXT` 기반 `DATA GRANT`를 적용한다. ORDS Handler가 Bearer 값을 DDS 사용자명로 바꾸는 것만으로는 DDS Context가 자동 생성되지 않는다.
중요한 경계는 `DATA GRANT` 자체가 호출 시점의 임의 함수 인자를 받는 구조가 아니라는 점이다. 그렇다고 Data Grant를 생략하는 것은 아니다. `ON` 절로 보호 VIEW/TABLE을 지정하고, `WHERE` predicate가 Context와 공통 권한 테이블을 사용해 행을 결정한다. 토큰 함수는 그 predicate가 참조할 Context를 만든다. 향후 순수 DDS Context를 사용하려면 토큰을 SQL 실행 전에 드라이버가 붙이고, 그때 `ORA_END_USER_CONTEXT` 기반 Data Grant를 적용한다. ORDS Handler가 Bearer 값을 DDS 사용자명 문자열로 바꾸는 것만으로는 DDS Context가 자동 생성되지 않는다.
## 객체별 Grant와 공통 권한체계 매핑
보호 대상이 늘어나면 대상별로 Data Grant를 추가한다. 하나의 Data Grant는 하나의 `ON` 테이블·뷰·Materialized View를 대상으로 하며, 해당 객체의 `SELECT`/DML·컬럼 범위와 행 predicate를 선언한다.
| 공통 권한체계 | DDS 집행 위치 |
|---|---|
| `CB_PERMISSION.target_name` | 보호 객체와 논리 대상 매핑 |
| `CB_PERMISSION.action_name` | Data Grant의 `SELECT`/`UPDATE` 등 |
| `CB_PERMISSION_RULE` | Data Grant predicate의 `WHERE` 조건 |
| 직접 역할·활성 그룹 역할 | predicate 함수의 effective role 집합 |
| Bearer 키 | `CB_AGENT_CTX`의 업무 사용자 식별 |
같은 대상의 여러 Data Grant는 기본적으로 합집합으로 적용된다. 따라서 ALLOW TAG를 여러 개 허용하는 것은 OR로 표현할 수 있지만, DENY는 별도 Grant로 만들면 전체를 차단하지 못할 수 있다. 이 데모는 하나의 객체별 predicate 안에서 `(ALLOW A OR ALLOW B) AND NOT (DENY C)` 의미를 만든다.
## 실행 확인

View File

@@ -46,6 +46,7 @@ export DDS_BACKOFFICE_PG_PASSWORD="${DDS_BACKOFFICE_PG_PASSWORD:-${DDSUSER_PG_PA
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:-}}"
export DDS_BACKOFFICE_TOKEN_PASSWORD="${DDS_BACKOFFICE_TOKEN_PASSWORD:-${DDSUSER_TOKEN_PASSWORD:-}}"
PID_FILE="$APP_DIR/app.pid"
LOG_FILE="$APP_DIR/app.log"

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Apply the single-technical-user DDS token path.
# The password is supplied through the environment and is never printed.
set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ -f /home/opc/apps/vpd-backoffice/.env ]]; then
set -a
. /home/opc/apps/vpd-backoffice/.env
set +a
fi
: "${ADB_USER:?ADB_USER is required}"
: "${ADB_PASSWORD:?ADB_PASSWORD is required}"
: "${ADB_TNS:?ADB_TNS is required}"
: "${DDSUSER_TOKEN_PASSWORD:?DDSUSER_TOKEN_PASSWORD is required}"
sqlplus -S -L "${ADB_USER}/${ADB_PASSWORD}@${ADB_TNS}" <<SQL
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET DEFINE ON
DEFINE DDSUSER_TOKEN_PASSWORD = "${DDSUSER_TOKEN_PASSWORD}"
@${ROOT}/sql/adb/34_dds_token_data_grant_common_auth.sql
SQL
echo "DDS token DATA GRANT setup complete (technical user: dds_demo_token)"

View File

@@ -0,0 +1,163 @@
-- ============================================================
-- 34_dds_token_data_grant_common_auth.sql
-- Token-driven DDS enforcement using the common CB_* permission model.
--
-- This is the product-shaped DDS path:
-- 1. One local DDS END USER is used as the technical query identity.
-- 2. The application calls CB_AGENT_CTX_PKG.SET_USER_BY_BEARER(token).
-- 3. A DATA GRANT is attached to one DDS DATA ROLE on the protected VIEW.
-- 4. Its predicate function reads CB_PERMISSION / CB_PERMISSION_RULE,
-- including direct roles and active group-inherited roles.
--
-- The token is not a bind parameter in CREATE DATA GRANT syntax. It is
-- resolved before SELECT and stored in the trusted CB_AGENT_CTX namespace;
-- the DATA GRANT predicate then evaluates the common permission tables.
--
-- Prerequisite: 17_agent_ords_security_local_vpd_setup.sql,
-- 25_agent_ords_security_backoffice_support.sql,
-- 31_dds_standalone_demo_setup.sql,
-- 32_dds_vector_tag_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 ON
SET VERIFY OFF
PROMPT === 1. Creating one technical DDS END USER and its DATA ROLE ===
CREATE END USER IF NOT EXISTS "dds_demo_token" IDENTIFIED BY "&DDSUSER_TOKEN_PASSWORD";
BEGIN
EXECUTE IMMEDIATE 'CREATE ROLE cb_dds_token_connect_role';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -1921 THEN
RAISE;
END IF;
END;
/
GRANT CREATE SESSION TO cb_dds_token_connect_role;
CREATE DATA ROLE IF NOT EXISTS cb_dds_token_role;
GRANT cb_dds_token_connect_role TO cb_dds_token_role;
GRANT DATA ROLE cb_dds_token_role TO "dds_demo_token";
-- A DDS END USER is not a conventional database user. Standard object
-- privileges therefore go to a standard role inherited by the DATA ROLE.
GRANT EXECUTE ON admin.cb_agent_ctx_pkg TO cb_dds_token_connect_role;
PROMPT === 2. Creating the common permission predicate function ===
CREATE OR REPLACE FUNCTION admin.cb_dds_vector_tag_allowed(
p_tech_tag IN VARCHAR2
) RETURN NUMBER
AUTHID DEFINER
AS
v_user_id NUMBER;
v_allow NUMBER := 0;
v_deny NUMBER := 0;
BEGIN
BEGIN
v_user_id := TO_NUMBER(SYS_CONTEXT('CB_AGENT_CTX', 'USER_ID'));
EXCEPTION
WHEN OTHERS THEN
RETURN 0;
END;
IF v_user_id IS NULL OR p_tech_tag IS NULL THEN
RETURN 0;
END IF;
-- Direct roles and roles inherited from active application groups are
-- deliberately expanded at query time. CB_* remains the source of truth.
SELECT COUNT(*)
INTO v_allow
FROM (
SELECT ur.role_id
FROM cb_user_role ur
WHERE ur.user_id = v_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 = v_user_id
) effective_role
JOIN cb_app_user u
ON u.user_id = v_user_id
AND u.active = 'Y'
JOIN cb_permission p
ON p.role_id = effective_role.role_id
JOIN cb_permission_rule r
ON r.perm_id = p.perm_id
WHERE p.target_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
AND p.action_name = 'SELECT'
AND p.permission_effect = 'ALLOW'
AND (
r.rule_type = 'ALL'
OR (
r.rule_type = 'TAG'
AND REGEXP_LIKE(
UPPER(p_tech_tag),
'(^|,)' || UPPER(TRIM(r.rule_value)) || '(,|$)'
)
)
);
SELECT COUNT(*)
INTO v_deny
FROM (
SELECT ur.role_id
FROM cb_user_role ur
WHERE ur.user_id = v_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 = v_user_id
) effective_role
JOIN cb_permission p
ON p.role_id = effective_role.role_id
JOIN cb_permission_rule r
ON r.perm_id = p.perm_id
WHERE p.target_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
AND p.action_name = 'SELECT'
AND p.permission_effect = 'DENY'
AND r.rule_type = 'TAG'
AND REGEXP_LIKE(
UPPER(p_tech_tag),
'(^|,)' || UPPER(TRIM(r.rule_value)) || '(,|$)'
);
IF v_allow > 0 AND v_deny = 0 THEN
RETURN 1;
END IF;
RETURN 0;
END;
/
SHOW ERRORS
PROMPT === 3. Creating one object-level DATA GRANT ===
CREATE OR REPLACE DATA GRANT admin.dds_demo_token_vector_grant
AS SELECT
ON admin.cb_dds_vector_search_documents
WHERE admin.cb_dds_vector_tag_allowed(tech_tag) = 1
TO cb_dds_token_role;
PROMPT === 4. Verifying the object-level grant ===
SELECT grant_name, object_name, grantee
FROM dba_data_grants
WHERE grant_name = 'DDS_DEMO_TOKEN_VECTOR_GRANT';
PROMPT === Token-driven DDS DATA GRANT setup complete ===
PROMPT Call ADMIN.CB_AGENT_CTX_PKG.SET_USER_BY_BEARER(:token) on the
PROMPT technical connection, then SELECT from ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS.
EXIT;

View File

@@ -0,0 +1,24 @@
-- Remove only the token-driven DDS demonstration objects.
-- Run as ADMIN after confirming no application still uses dds_demo_token.
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO ON
SET FEEDBACK ON
SET DEFINE OFF
DROP DATA GRANT IF EXISTS admin.dds_demo_token_vector_grant;
DROP FUNCTION admin.cb_dds_vector_tag_allowed;
DROP DATA ROLE IF EXISTS cb_dds_token_role;
BEGIN
EXECUTE IMMEDIATE 'DROP ROLE cb_dds_token_connect_role';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -1919 THEN
RAISE;
END IF;
END;
/
DROP END USER IF EXISTS "dds_demo_token";
PROMPT DDS token DATA GRANT objects removed.
EXIT;