feat: enforce DDS token access through common grants
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 결과입니다.";
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user