feat: enforce DDS token access through common grants

This commit is contained in:
devmrko
2026-06-30 13:20:00 +09:00
parent 3a07788e5c
commit a5e70bcff1
19 changed files with 509 additions and 68 deletions

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