[Developer] #617 apply DDS MCP end-user authorization

This commit is contained in:
devmrko
2026-07-02 09:34:58 +09:00
parent fd09622c82
commit ef1331be4b
53 changed files with 2040 additions and 337 deletions

View File

@@ -1,6 +1,7 @@
package com.cloudhandson.ddsbackoffice;
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
import com.cloudhandson.ddsbackoffice.config.DdsMcpIamProperties;
import com.cloudhandson.vpdbackoffice.VpdBackofficeApplication;
import com.cloudhandson.vpdbackoffice.web.DashboardController;
import com.cloudhandson.vpdbackoffice.web.LoginController;
@@ -14,7 +15,7 @@ import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
@SpringBootApplication
@EnableConfigurationProperties(DdsProperties.class)
@EnableConfigurationProperties({DdsProperties.class, DdsMcpIamProperties.class})
@MapperScan("com.cloudhandson.vpdbackoffice.mapper")
@ComponentScan(
basePackages = {"com.cloudhandson.ddsbackoffice", "com.cloudhandson.vpdbackoffice"},
@@ -24,7 +25,7 @@ import org.springframework.context.annotation.FilterType;
DdsBackofficeApplication.class,
VpdBackofficeApplication.class,
com.cloudhandson.ddsbackoffice.config.AppConfig.class,
com.cloudhandson.ddsbackoffice.config.SecurityConfig.class,
com.cloudhandson.vpdbackoffice.config.SecurityConfig.class,
DashboardController.class,
LoginController.class,
VectorKnowledgeController.class,

View File

@@ -0,0 +1,79 @@
package com.cloudhandson.ddsbackoffice.config;
import java.net.URI;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;
/**
* OCI IAM client-credentials settings for the DDS MCP service identity.
*
* <p>The client credential proves the service may attach a DDS context; it
* is never used as the identity of an MCP caller.</p>
*/
@ConfigurationProperties(prefix = "dds.mcp.iam")
public record DdsMcpIamProperties(
String domainUrl,
String tokenUri,
String clientId,
String clientSecret,
String databaseScope,
Duration timeout,
Duration refreshSkew
) {
@ConstructorBinding
public DdsMcpIamProperties {
domainUrl = trim(domainUrl);
tokenUri = trim(tokenUri);
clientId = trim(clientId);
clientSecret = clientSecret == null ? "" : clientSecret;
databaseScope = trim(databaseScope);
timeout = timeout == null || timeout.isNegative() || timeout.isZero()
? Duration.ofSeconds(10) : timeout;
refreshSkew = refreshSkew == null || refreshSkew.isNegative()
? Duration.ofSeconds(60) : refreshSkew;
}
public boolean configured() {
return !clientId.isBlank()
&& !clientSecret.isBlank()
&& !databaseScope.isBlank()
&& !resolvedTokenUri().isBlank();
}
public URI tokenEndpoint() {
String value = resolvedTokenUri();
if (value.isBlank()) {
throw new IllegalStateException("DDS OCI IAM token endpoint가 설정되지 않았습니다.");
}
URI endpoint;
try {
endpoint = URI.create(value);
} catch (IllegalArgumentException exception) {
throw new IllegalStateException("DDS OCI IAM token endpoint 형식이 올바르지 않습니다.");
}
if (!"https".equalsIgnoreCase(endpoint.getScheme())
|| endpoint.getHost() == null
|| endpoint.getUserInfo() != null) {
throw new IllegalStateException("DDS OCI IAM token endpoint는 HTTPS URL이어야 합니다.");
}
return endpoint;
}
private String resolvedTokenUri() {
if (!tokenUri.isBlank()) {
return tokenUri;
}
if (domainUrl.isBlank()) {
return "";
}
return domainUrl.endsWith("/")
? domainUrl + "oauth2/v1/token"
: domainUrl + "/oauth2/v1/token";
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
}

View File

@@ -1,5 +1,6 @@
package com.cloudhandson.ddsbackoffice.config;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -23,11 +24,13 @@ public class SecurityConfig {
}
return http
.csrf(csrf -> csrf.ignoringRequestMatchers("/dds/mcp/messages"))
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31_536_000)))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/css/**", "/js/**", "/health", "/login").permitAll()
.requestMatchers("/css/**", "/js/**", "/health", "/login",
"/dds/mcp/sse", "/dds/mcp/messages").permitAll()
.anyRequest().authenticated())
.httpBasic(basic -> {
})

View File

@@ -0,0 +1,9 @@
package com.cloudhandson.ddsbackoffice.domain;
/** The internal result of revalidating an MCP Bearer header. */
public record DdsMcpAuthenticatedUser(
long applicationUserId,
String username,
DdsMcpEndUserPrincipal principal
) {
}

View File

@@ -0,0 +1,10 @@
package com.cloudhandson.ddsbackoffice.domain;
/** A published local DDS identity corresponding to one application user. */
public record DdsMcpEndUserPrincipal(
long applicationUserId,
String endUserName,
String dataRoleName,
String lookupKeyReference
) {
}

View File

@@ -0,0 +1,9 @@
package com.cloudhandson.ddsbackoffice.domain;
/** Summary of one publish of local DDS identities used by the MCP endpoint. */
public record DdsMcpEndUserPublishResult(
int publishedUsers,
int defaultDeniedUsers,
int revokedUsers
) {
}

View File

@@ -0,0 +1,13 @@
package com.cloudhandson.ddsbackoffice.domain;
import java.util.List;
import java.util.Map;
/** A deliberately small, already DDS-filtered MCP tool result. */
public record DdsMcpVectorSearchResult(
String query,
String embeddingMode,
int rowCount,
List<Map<String, Object>> rows
) {
}

View File

@@ -4,7 +4,7 @@ package com.cloudhandson.ddsbackoffice.domain;
public record DdsSqlEvidence(
boolean available,
String requestId,
String submittedStatement,
String executedSql,
String sqlId,
Integer childNumber,
String lastActiveTime,
@@ -13,7 +13,7 @@ public record DdsSqlEvidence(
String vpdPredicate,
String status
) {
public static DdsSqlEvidence unavailable(String requestId, String submittedStatement, String status) {
return new DdsSqlEvidence(false, requestId, submittedStatement, null, null, null, null, null, null, status);
public static DdsSqlEvidence unavailable(String requestId, String executedSql, String status) {
return new DdsSqlEvidence(false, requestId, executedSql, null, null, null, null, null, null, status);
}
}

View File

@@ -0,0 +1,31 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationSynchronizer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.stereotype.Component;
/** Republishes all local DDS MCP identities in the same request as an authorization change. */
@Component
public class DdsMcpAuthorizationChangeListener implements DdsAuthorizationSynchronizer {
private static final Logger log = LoggerFactory.getLogger(DdsMcpAuthorizationChangeListener.class);
private final DdsMcpEndUserPublisher publisher;
public DdsMcpAuthorizationChangeListener(DdsMcpEndUserPublisher publisher) {
this.publisher = publisher;
}
@Override
public void synchronize(String reason) {
try {
var result = publisher.publish();
log.info("DDS MCP authorization sync completed after {}: published={}, defaultDenied={}, revoked={}",
reason, result.publishedUsers(), result.defaultDeniedUsers(), result.revokedUsers());
} catch (RuntimeException exception) {
throw new DataAccessResourceFailureException(
"업무 권한 변경 뒤 DDS MCP 권한 동기화에 실패했습니다. DDS 게시 상태를 확인하세요.", exception);
}
}
}

View File

@@ -0,0 +1,65 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpAuthenticatedUser;
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
import org.springframework.stereotype.Service;
/** Revalidates the MCP request bearer before every tool invocation. */
@Service
public class DdsMcpBearerAuthenticator {
private static final int MAX_TOKEN_LENGTH = 4096;
private final BearerTokenService bearerTokenService;
private final UserMapper userMapper;
private final DdsMcpEndUserResolver endUserResolver;
private final Clock clock;
public DdsMcpBearerAuthenticator(
BearerTokenService bearerTokenService,
UserMapper userMapper,
DdsMcpEndUserResolver endUserResolver,
Clock clock
) {
this.bearerTokenService = bearerTokenService;
this.userMapper = userMapper;
this.endUserResolver = endUserResolver;
this.clock = clock;
}
public DdsMcpAuthenticatedUser authenticate(String authorization) {
String plainToken = bearerValue(authorization);
BearerTokenRecord token = bearerTokenService.findByPlainToken(plainToken);
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
if (token == null || !token.active(now) || !bearerTokenService.matches(token, plainToken)) {
throw denied();
}
AppUser user = userMapper.findById(token.userId());
if (user == null || !user.active()) {
throw denied();
}
return new DdsMcpAuthenticatedUser(user.userId(), user.username(), endUserResolver.resolve(user.userId()));
}
private String bearerValue(String authorization) {
if (authorization == null || !authorization.regionMatches(true, 0, "Bearer ", 0, 7)) {
throw denied();
}
String value = authorization.substring(7).trim();
if (value.isEmpty() || value.length() > MAX_TOKEN_LENGTH) {
throw denied();
}
return value;
}
private AppException denied() {
return new AppException("AUTHORIZATION_DENIED: MCP Bearer를 확인할 수 없습니다.");
}
}

View File

@@ -0,0 +1,172 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.config.DdsMcpIamProperties;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpEndUserPrincipal;
import com.cloudhandson.vpdbackoffice.service.AppException;
import java.lang.reflect.Method;
import java.lang.reflect.InvocationTargetException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.util.Base64;
import java.util.concurrent.Executor;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.sql.DataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/**
* The only DDS MCP path that may run protected SQL. It attaches the local
* END USER context, runs one work unit, and clears the context before the
* pooled connection is released.
*/
@Service
public class DdsMcpContextExecutor {
private static final Logger log = LoggerFactory.getLogger(DdsMcpContextExecutor.class);
private static final Executor DIRECT_EXECUTOR = Runnable::run;
private final DataSource dataSource;
private final DdsMcpIamProperties iamProperties;
private final DdsMcpDatabaseAccessTokenProvider tokenProvider;
public DdsMcpContextExecutor(
DataSource dataSource,
DdsMcpIamProperties iamProperties,
DdsMcpDatabaseAccessTokenProvider tokenProvider
) {
this.dataSource = dataSource;
this.iamProperties = iamProperties;
this.tokenProvider = tokenProvider;
}
public <T> T withContext(DdsMcpEndUserPrincipal principal, SqlWork<T> work) {
if (principal == null || work == null) {
throw unavailable("DDS 실행 주체가 없습니다.");
}
String databaseAccessToken = tokenProvider.accessToken();
java.sql.Connection connection = null;
Object oracleConnection = null;
boolean attachAttempted = false;
boolean clearFailed = false;
try {
connection = dataSource.getConnection();
connection.setNetworkTimeout(DIRECT_EXECUTOR, networkTimeoutMillis());
oracleConnection = unwrapOracleConnection(connection);
attachAttempted = true;
log.info("DDS context attach started for application user {}", principal.applicationUserId());
attach(oracleConnection, databaseAccessToken, principal.endUserName(), lookupKey(principal));
log.info("DDS context attach completed for application user {}", principal.applicationUserId());
return work.execute(connection);
} catch (AppException exception) {
throw exception;
} catch (Exception exception) {
log.warn("DDS END USER Context attachment failed: {}", failureCode(exception));
throw unavailable("DDS END USER Context를 연결할 수 없습니다.");
} finally {
if (attachAttempted && oracleConnection != null) {
try {
log.info("DDS context clear started");
clear(oracleConnection);
log.info("DDS context clear completed");
} catch (Exception ignored) {
clearFailed = true;
}
}
if (connection != null) {
try {
if (clearFailed) {
connection.abort(Runnable::run);
}
} catch (Exception ignored) {
// close below still runs; the pool may discard an aborted connection.
}
try {
connection.close();
} catch (Exception ignored) {
// The request has already failed closed; do not expose JDBC details.
}
}
if (clearFailed) {
throw unavailable("DDS END USER Context를 해제할 수 없어 연결을 폐기했습니다.");
}
}
}
private Object unwrapOracleConnection(java.sql.Connection connection) throws Exception {
Class<?> oracleConnectionType = Class.forName("oracle.jdbc.OracleConnection");
return connection.unwrap(oracleConnectionType);
}
private void attach(Object oracleConnection, String databaseAccessToken, String endUserName, String lookupKey)
throws Exception {
Class<?> contextType = Class.forName("oracle.jdbc.EndUserSecurityContext");
Object context = createLocalContext(contextType, databaseAccessToken, endUserName, lookupKey);
Class<?> oracleConnectionType = Class.forName("oracle.jdbc.OracleConnection");
oracleConnectionType.getMethod("setEndUserSecurityContext", contextType)
.invoke(oracleConnection, context);
}
private Object createLocalContext(
Class<?> contextType,
String databaseAccessToken,
String endUserName,
String lookupKey
) throws Exception {
// ojdbc 23.26 exposes createWithName(CharSequence, String, CharSequence).
// Keep the documented createWithUsername fallback for compatible driver
// releases whose public API uses that spelling.
try {
Method createWithName = contextType.getMethod(
"createWithName", CharSequence.class, String.class, CharSequence.class);
return createWithName.invoke(null, databaseAccessToken, endUserName, lookupKey);
} catch (NoSuchMethodException ignored) {
Method createWithUsername = contextType.getMethod(
"createWithUsername", String.class, String.class, String.class);
return createWithUsername.invoke(null, databaseAccessToken, endUserName, lookupKey);
}
}
private void clear(Object oracleConnection) throws Exception {
Class<?> oracleConnectionType = Class.forName("oracle.jdbc.OracleConnection");
oracleConnectionType.getMethod("clearEndUserSecurityContext").invoke(oracleConnection);
}
private String lookupKey(DdsMcpEndUserPrincipal principal) {
if (!"DDS_OCI_IAM_CLIENT_SECRET_DERIVED_V1".equals(principal.lookupKeyReference())) {
throw unavailable("DDS END USER lookup key 참조가 허용되지 않습니다.");
}
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(iamProperties.clientSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] digest = mac.doFinal(("dds-mcp-local-end-user:" + principal.applicationUserId())
.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
} catch (GeneralSecurityException exception) {
throw unavailable("DDS END USER lookup key를 만들 수 없습니다.");
}
}
private int networkTimeoutMillis() {
long configured = iamProperties.timeout().toMillis();
return (int) Math.max(1_000L, Math.min(60_000L, configured));
}
private String failureCode(Exception exception) {
Throwable cause = exception instanceof InvocationTargetException invocation && invocation.getCause() != null
? invocation.getCause() : exception;
if (cause instanceof java.sql.SQLException sqlException) {
return "sqlState=" + sqlException.getSQLState() + ", errorCode=" + sqlException.getErrorCode();
}
return cause.getClass().getSimpleName();
}
private AppException unavailable(String detail) {
return new AppException("DDS_CONTEXT_UNAVAILABLE: " + detail);
}
@FunctionalInterface
public interface SqlWork<T> {
T execute(java.sql.Connection connection) throws Exception;
}
}

View File

@@ -0,0 +1,123 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.config.DdsMcpIamProperties;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** Obtains and safely caches the short-lived OCI IAM database-access token. */
@Service
public class DdsMcpDatabaseAccessTokenProvider {
private static final Duration MINIMUM_CACHE_LIFETIME = Duration.ofSeconds(1);
private static final Logger log = LoggerFactory.getLogger(DdsMcpDatabaseAccessTokenProvider.class);
private final DdsMcpIamProperties properties;
private final ObjectMapper objectMapper;
private final Clock clock;
private final HttpClient client;
private volatile CachedToken cached;
@Autowired
public DdsMcpDatabaseAccessTokenProvider(
DdsMcpIamProperties properties,
ObjectMapper objectMapper,
Clock clock
) {
this(properties, objectMapper, clock, HttpClient.newBuilder().connectTimeout(properties.timeout()).build());
}
DdsMcpDatabaseAccessTokenProvider(
DdsMcpIamProperties properties,
ObjectMapper objectMapper,
Clock clock,
HttpClient client
) {
this.properties = properties;
this.objectMapper = objectMapper;
this.clock = clock;
this.client = client;
}
public String accessToken() {
if (!properties.configured()) {
throw unavailable("OCI IAM client, scope 또는 token endpoint가 설정되지 않았습니다.");
}
CachedToken current = cached;
Instant now = clock.instant();
if (current != null && current.validUntil().isAfter(now)) {
return current.value();
}
synchronized (this) {
current = cached;
now = clock.instant();
if (current != null && current.validUntil().isAfter(now)) {
return current.value();
}
CachedToken refreshed = fetch(now);
cached = refreshed;
return refreshed.value();
}
}
private CachedToken fetch(Instant now) {
try {
String clientCredentials = encode(properties.clientId()) + ":" + encode(properties.clientSecret());
String basic = Base64.getEncoder().encodeToString(clientCredentials.getBytes(StandardCharsets.UTF_8));
String body = "grant_type=client_credentials&scope=" + encode(properties.databaseScope());
HttpRequest request = HttpRequest.newBuilder(properties.tokenEndpoint())
.timeout(properties.timeout())
.header("Authorization", "Basic " + basic)
.header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw unavailable("OCI IAM token 발급이 거부되었습니다.");
}
JsonNode payload = objectMapper.readTree(response.body());
String token = payload.path("access_token").asText("").trim();
long expiresIn = payload.path("expires_in").asLong(0L);
if (token.isBlank() || expiresIn < 1) {
throw unavailable("OCI IAM token 응답이 완전하지 않습니다.");
}
Duration remaining = Duration.ofSeconds(expiresIn).minus(properties.refreshSkew());
if (remaining.compareTo(MINIMUM_CACHE_LIFETIME) < 0) {
remaining = MINIMUM_CACHE_LIFETIME;
}
return new CachedToken(token, now.plus(remaining));
} catch (AppException exception) {
throw exception;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw unavailable("OCI IAM token 발급이 중단되었습니다.");
} catch (Exception exception) {
log.warn("OCI IAM database-access token request failed: {}", exception.toString());
throw unavailable("OCI IAM token을 발급할 수 없습니다.");
}
}
private String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
private AppException unavailable(String detail) {
return new AppException("DDS_CONTEXT_UNAVAILABLE: " + detail);
}
private record CachedToken(String value, Instant validUntil) {
}
}

View File

@@ -0,0 +1,309 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpEndUserPublishResult;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Projects the existing application user/role/permission model to the local
* DDS identities consumed by the SSE MCP endpoint. No IAM user is created or
* changed here: OCI IAM only authorizes the service to attach a context.
*/
@Service
public class DdsMcpEndUserPublisher {
private static final String LOOKUP_KEY_REFERENCE = "DDS_OCI_IAM_CLIENT_SECRET_DERIVED_V1";
private static final Pattern TAG = Pattern.compile("[A-Za-z0-9_-]+");
private final JdbcTemplate jdbcTemplate;
public DdsMcpEndUserPublisher(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public DdsMcpEndUserPublishResult publish() {
ensureMapTable();
synchronizeMappings();
int published = 0;
int defaultDenied = 0;
int revoked = 0;
for (Mapping mapping : mappings()) {
try {
if (mapping.active()) {
ensureEndUser(mapping);
ensureDataRole(mapping);
ensureRoleGrant(mapping);
String predicate = vectorPredicate(mapping.applicationUserId());
replaceVectorGrant(mapping, predicate, excludedVectorColumns(mapping.applicationUserId()));
mark(mapping, "PUBLISHED", null);
published++;
if (predicate == null) {
defaultDenied++;
}
} else {
dropVectorGrant(mapping);
revokeRoleGrant(mapping);
mark(mapping, "REVOKED", null);
revoked++;
}
} catch (DataAccessException exception) {
markFailed(mapping);
throw new DataAccessResourceFailureException(
"DDS MCP END USER 권한을 게시하지 못했습니다. 기존 DDS 설정과 DB 권한을 확인하세요.", exception);
}
}
return new DdsMcpEndUserPublishResult(published, defaultDenied, revoked);
}
private void ensureMapTable() {
try {
jdbcTemplate.queryForObject("SELECT COUNT(*) FROM cb_dds_end_user_map", Integer.class);
} catch (DataAccessException missing) {
jdbcTemplate.execute("""
CREATE TABLE cb_dds_end_user_map (
application_user_id NUMBER PRIMARY KEY REFERENCES cb_app_user(user_id),
end_user_name VARCHAR2(128) NOT NULL UNIQUE,
data_role_name VARCHAR2(128) NOT NULL UNIQUE,
lookup_key_ref VARCHAR2(128) NOT NULL,
grant_name VARCHAR2(128) NOT NULL,
publish_status VARCHAR2(20) NOT NULL
CHECK (publish_status IN ('PENDING', 'PUBLISHED', 'REVOKED', 'FAILED')),
published_at TIMESTAMP,
last_error VARCHAR2(1000)
)
""");
}
}
private void synchronizeMappings() {
jdbcTemplate.update("""
MERGE INTO cb_dds_end_user_map target
USING (
SELECT user_id AS application_user_id,
'DDS_U_' || TO_CHAR(user_id) AS end_user_name,
'DDS_U_' || TO_CHAR(user_id) || '_ROLE' AS data_role_name,
? AS lookup_key_ref,
'DDS_MCP_U_' || TO_CHAR(user_id) || '_VECTOR_GRANT' AS grant_name
FROM cb_app_user
) source
ON (target.application_user_id = source.application_user_id)
WHEN MATCHED THEN UPDATE SET
target.end_user_name = source.end_user_name,
target.data_role_name = source.data_role_name,
target.lookup_key_ref = source.lookup_key_ref,
target.grant_name = source.grant_name
WHEN NOT MATCHED THEN INSERT (
application_user_id, end_user_name, data_role_name, lookup_key_ref,
grant_name, publish_status
) VALUES (
source.application_user_id, source.end_user_name, source.data_role_name,
source.lookup_key_ref, source.grant_name, 'PENDING'
)
""", LOOKUP_KEY_REFERENCE);
}
private List<Mapping> mappings() {
return jdbcTemplate.query("""
SELECT m.application_user_id, m.end_user_name, m.data_role_name, m.grant_name,
u.active
FROM cb_dds_end_user_map m
JOIN cb_app_user u ON u.user_id = m.application_user_id
ORDER BY m.application_user_id
""", (row, ignored) -> new Mapping(
row.getLong("application_user_id"),
row.getString("end_user_name"),
row.getString("data_role_name"),
row.getString("grant_name"),
"Y".equalsIgnoreCase(row.getString("active"))));
}
private void ensureEndUser(Mapping mapping) {
if (!endUserExists(mapping.endUserName())) {
jdbcTemplate.execute("CREATE END USER \"" + mapping.endUserName() + "\"");
}
}
private void ensureDataRole(Mapping mapping) {
if (!dataRoleExists(mapping.dataRoleName())) {
jdbcTemplate.execute("CREATE DATA ROLE " + mapping.dataRoleName());
}
}
private void ensureRoleGrant(Mapping mapping) {
Integer grants = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM dba_data_role_grants
WHERE grantee = ? AND data_role = ?
""", Integer.class, mapping.endUserName(), mapping.dataRoleName());
if (grants == null || grants == 0) {
jdbcTemplate.execute("GRANT DATA ROLE " + mapping.dataRoleName()
+ " TO \"" + mapping.endUserName() + "\"");
}
}
private void revokeRoleGrant(Mapping mapping) {
Integer grants = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM dba_data_role_grants
WHERE grantee = ? AND data_role = ?
""", Integer.class, mapping.endUserName(), mapping.dataRoleName());
if (grants != null && grants > 0) {
jdbcTemplate.execute("REVOKE DATA ROLE " + mapping.dataRoleName()
+ " FROM \"" + mapping.endUserName() + "\"");
}
}
private void replaceVectorGrant(Mapping mapping, String predicate, String excludedColumns) {
dropVectorGrant(mapping);
if (predicate == null) {
return;
}
String select = excludedColumns == null || excludedColumns.isBlank()
? "AS SELECT"
: "AS SELECT (ALL COLUMNS EXCEPT " + excludedColumns + ")";
jdbcTemplate.execute("CREATE DATA GRANT ADMIN." + mapping.grantName() + " " + select
+ " ON ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS WHERE " + predicate
+ " TO " + mapping.dataRoleName());
}
private void dropVectorGrant(Mapping mapping) {
Integer grants = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM dba_data_grants
WHERE owner = 'ADMIN' AND grant_name = ?
""", Integer.class, mapping.grantName());
if (grants != null && grants > 0) {
jdbcTemplate.execute("DROP DATA GRANT ADMIN." + mapping.grantName());
}
}
private String vectorPredicate(long userId) {
List<PermissionRule> rules = jdbcTemplate.query("""
WITH effective_role AS (
SELECT role_id FROM cb_user_role WHERE 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 NVL(UPPER(TRIM(p.permission_effect)), 'ALLOW') AS permission_effect,
UPPER(TRIM(r.rule_type)) AS rule_type,
TRIM(r.rule_value) AS rule_value
FROM effective_role er
JOIN cb_permission p ON p.role_id = er.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'
ORDER BY p.perm_id, r.rule_id
""", (row, ignored) -> new PermissionRule(
row.getString("permission_effect"), row.getString("rule_type"), row.getString("rule_value")),
userId, userId);
List<String> allow = new ArrayList<>();
List<String> deny = new ArrayList<>();
for (PermissionRule rule : rules) {
String clause = clause(rule);
if (clause == null) {
continue;
}
("DENY".equalsIgnoreCase(rule.effect()) ? deny : allow).add(clause);
}
if (allow.isEmpty()) {
return null;
}
String allowExpression = disjunction(allow);
return deny.isEmpty() ? allowExpression : "(" + allowExpression + ") AND NOT (" + disjunction(deny) + ")";
}
private String clause(PermissionRule rule) {
if ("ALL".equals(rule.type())) {
return "1 = 1";
}
if ("TAG".equals(rule.type()) && rule.value() != null && TAG.matcher(rule.value()).matches()) {
String tag = rule.value().toUpperCase(Locale.ROOT);
return "REGEXP_LIKE(UPPER(tech_tag), '(^|,)" + tag + "(,|$)')";
}
return null;
}
private String disjunction(List<String> clauses) {
return clauses.size() == 1 ? clauses.getFirst() : "(" + String.join(") OR (", clauses) + ")";
}
private String excludedVectorColumns(long userId) {
List<String> columns = jdbcTemplate.query("""
SELECT pc.column_name
FROM cb_protected_column pc
JOIN cb_protected_object po ON po.object_id = pc.object_id
WHERE po.object_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
AND pc.sensitive_yn = 'Y'
AND pc.column_name <> 'EMBEDDING'
AND NOT EXISTS (
WITH effective_role AS (
SELECT role_id FROM cb_user_role WHERE 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 1
FROM effective_role er
JOIN cb_permission p ON p.role_id = er.role_id
JOIN cb_permission_column allowed ON allowed.permission_id = p.perm_id
WHERE p.target_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
AND p.action_name = 'SELECT'
AND p.permission_effect = 'ALLOW'
AND allowed.column_name = pc.column_name
)
ORDER BY pc.column_name
""", (row, ignored) -> row.getString("column_name"), userId, userId);
return String.join(", ", columns);
}
private boolean endUserExists(String endUserName) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM dba_end_users WHERE username = ?", Integer.class, endUserName);
return count != null && count > 0;
}
private boolean dataRoleExists(String dataRoleName) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM dba_data_roles WHERE data_role = ?", Integer.class, dataRoleName);
return count != null && count > 0;
}
private void mark(Mapping mapping, String status, String error) {
jdbcTemplate.update("""
UPDATE cb_dds_end_user_map
SET publish_status = ?, published_at = SYSTIMESTAMP, last_error = ?
WHERE application_user_id = ?
""", status, error, mapping.applicationUserId());
}
private void markFailed(Mapping mapping) {
try {
mark(mapping, "FAILED", "DDS MCP local END USER publish failed");
} catch (DataAccessException ignored) {
// Keep the original DDL failure as the request failure.
}
}
private record Mapping(
long applicationUserId,
String endUserName,
String dataRoleName,
String grantName,
boolean active
) {
}
private record PermissionRule(String effect, String type, String value) {
}
}

View File

@@ -0,0 +1,60 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpEndUserPrincipal;
import com.cloudhandson.vpdbackoffice.service.AppException;
import java.util.List;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/** Resolves only published local DDS identities. Missing mappings always deny. */
@Service
public class DdsMcpEndUserResolver {
private final JdbcTemplate jdbcTemplate;
public DdsMcpEndUserResolver(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public DdsMcpEndUserPrincipal resolve(long applicationUserId) {
try {
List<DdsMcpEndUserPrincipal> principals = jdbcTemplate.query("""
SELECT m.application_user_id, m.end_user_name, m.data_role_name, m.lookup_key_ref
FROM cb_dds_end_user_map m
JOIN cb_app_user u ON u.user_id = m.application_user_id
WHERE m.application_user_id = ?
AND m.publish_status = 'PUBLISHED'
AND u.active = 'Y'
""", (row, ignored) -> new DdsMcpEndUserPrincipal(
row.getLong("application_user_id"),
row.getString("end_user_name"),
row.getString("data_role_name"),
row.getString("lookup_key_ref")), applicationUserId);
if (principals.size() != 1) {
throw denied();
}
DdsMcpEndUserPrincipal principal = principals.getFirst();
if (!safeEndUserName(principal.endUserName()) || !safeDataRoleName(principal.dataRoleName())) {
throw denied();
}
return principal;
} catch (AppException exception) {
throw exception;
} catch (DataAccessException exception) {
throw new AppException("DDS_CONTEXT_UNAVAILABLE: DDS END USER 게시 상태를 확인할 수 없습니다.");
}
}
private AppException denied() {
return new AppException("AUTHORIZATION_DENIED: DDS END USER가 게시되지 않았습니다.");
}
private boolean safeEndUserName(String value) {
return value != null && value.matches("DDS_U_[0-9]{1,30}");
}
private boolean safeDataRoleName(String value) {
return value != null && value.matches("DDS_U_[0-9]{1,30}_ROLE");
}
}

View File

@@ -0,0 +1,113 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpAuthenticatedUser;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpVectorSearchResult;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.stereotype.Service;
/** JSON-RPC surface for the DDS-only SSE MCP endpoint. */
@Service
public class DdsMcpSseService {
private final ObjectMapper objectMapper;
private final DdsMcpVectorSearchService vectorSearchService;
public DdsMcpSseService(ObjectMapper objectMapper, DdsMcpVectorSearchService vectorSearchService) {
this.objectMapper = objectMapper;
this.vectorSearchService = vectorSearchService;
}
public ObjectNode handle(DdsMcpAuthenticatedUser user, JsonNode request) {
ObjectNode response = objectMapper.createObjectNode();
response.put("jsonrpc", "2.0");
if (request != null && request.has("id")) {
response.set("id", request.get("id"));
}
String method = request == null ? "" : request.path("method").asText("");
try {
ObjectNode result = switch (method) {
case "initialize" -> initialize();
case "notifications/initialized" -> objectMapper.createObjectNode();
case "tools/list" -> tools();
case "tools/call" -> toolCall(user, request.path("params"));
default -> throw new AppException("지원하지 않는 MCP method입니다: " + method);
};
response.set("result", result);
} catch (Exception exception) {
ObjectNode error = objectMapper.createObjectNode();
error.put("code", -32000);
error.put("message", safeMessage(exception));
response.set("error", error);
}
return response;
}
private ObjectNode initialize() {
ObjectNode result = objectMapper.createObjectNode();
result.put("protocolVersion", "2024-11-05");
ObjectNode serverInfo = result.putObject("serverInfo");
serverInfo.put("name", "dds-end-user-mcp");
serverInfo.put("version", "0.1.0");
result.putObject("capabilities").putObject("tools");
return result;
}
private ObjectNode tools() {
ObjectNode result = objectMapper.createObjectNode();
ArrayNode tools = result.putArray("tools");
ObjectNode search = tools.addObject();
search.put("name", "dds_vector_search");
search.put("description", "DDS END USER Context와 DATA GRANT로 제한된 지식 검색");
ObjectNode schema = search.putObject("inputSchema");
schema.put("type", "object");
ObjectNode properties = schema.putObject("properties");
properties.putObject("query").put("type", "string").put("description", "검색 질문");
properties.putObject("limit").put("type", "integer").put("minimum", 1).put("maximum", 100);
ObjectNode embeddingMode = properties.putObject("embeddingMode");
embeddingMode.put("type", "string");
embeddingMode.putArray("enum").add("DEMO").add("AI");
embeddingMode.put("default", "DEMO");
schema.putArray("required").add("query");
schema.put("additionalProperties", false);
return result;
}
private ObjectNode toolCall(DdsMcpAuthenticatedUser user, JsonNode params) {
if (!"dds_vector_search".equals(params.path("name").asText(""))) {
throw new AppException("DDS MCP tool을 찾을 수 없습니다.");
}
JsonNode arguments = params.path("arguments");
DdsMcpVectorSearchResult search = vectorSearchService.search(
user.principal(),
arguments.path("query").asText(""),
arguments.path("limit").asInt(10),
arguments.path("embeddingMode").asText("DEMO"));
ObjectNode body = objectMapper.createObjectNode();
body.put("query", search.query());
body.put("embeddingMode", search.embeddingMode());
body.put("rowCount", search.rowCount());
body.set("rows", objectMapper.valueToTree(search.rows()));
ObjectNode result = objectMapper.createObjectNode();
result.putArray("content").addObject().put("type", "text")
.put("text", pretty(body));
result.put("isError", false);
return result;
}
private String pretty(ObjectNode body) {
try {
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(body);
} catch (Exception exception) {
return "DDS MCP 결과를 직렬화할 수 없습니다.";
}
}
private String safeMessage(Exception exception) {
String message = exception.getMessage();
return message == null || message.isBlank() ? "DDS MCP 요청을 처리할 수 없습니다." : message;
}
}

View File

@@ -0,0 +1,105 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpEndUserPrincipal;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpVectorSearchResult;
import com.cloudhandson.vpdbackoffice.domain.vector.VectorQueryEmbedding;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.VectorKnowledgeService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/** Runs the DDS-protected vector query only inside a local END USER context. */
@Service
public class DdsMcpVectorSearchService {
private static final Logger log = LoggerFactory.getLogger(DdsMcpVectorSearchService.class);
private final VectorKnowledgeService vectorKnowledgeService;
private final DdsProperties ddsProperties;
private final DdsMcpContextExecutor contextExecutor;
private final ObjectMapper objectMapper;
public DdsMcpVectorSearchService(
VectorKnowledgeService vectorKnowledgeService,
DdsProperties ddsProperties,
DdsMcpContextExecutor contextExecutor,
ObjectMapper objectMapper
) {
this.vectorKnowledgeService = vectorKnowledgeService;
this.ddsProperties = ddsProperties;
this.contextExecutor = contextExecutor;
this.objectMapper = objectMapper;
}
public DdsMcpVectorSearchResult search(
DdsMcpEndUserPrincipal principal,
String query,
int requestedLimit,
String embeddingMode
) {
VectorQueryEmbedding vector = vectorKnowledgeService.vectorizeQuery(query, embeddingMode);
String embedding = embeddingFrom(vector);
int limit = Math.max(1, Math.min(requestedLimit, 100));
return contextExecutor.withContext(principal, connection -> {
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 %s d
WHERE d.embedding IS NOT NULL
ORDER BY score
)
WHERE ROWNUM <= ?
""".formatted(ddsProperties.vectorObject());
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setQueryTimeout((int) Math.max(1, ddsProperties.queryTimeout().toSeconds()));
statement.setString(1, embedding);
statement.setInt(2, limit);
List<Map<String, Object>> rows = new ArrayList<>();
log.info("DDS vector query started");
try (ResultSet result = statement.executeQuery()) {
while (result.next()) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("chunkId", result.getLong("chunk_id"));
row.put("documentId", result.getString("document_id"));
row.put("chunkNo", result.getInt("chunk_no"));
row.put("title", result.getString("title"));
row.put("chunkText", result.getString("chunk_text"));
row.put("sourceUri", result.getString("source_uri"));
row.put("techTag", result.getString("tech_tag"));
row.put("score", result.getObject("score"));
rows.add(Map.copyOf(row));
}
}
log.info("DDS vector query completed with {} rows", rows.size());
return new DdsMcpVectorSearchResult(vector.query(), vector.embeddingMode(), rows.size(), List.copyOf(rows));
} catch (java.sql.SQLException exception) {
throw new AppException("DDS_QUERY_FAILED: DDS 보호 객체를 조회할 수 없습니다.");
}
});
}
private String embeddingFrom(VectorQueryEmbedding vector) {
try {
JsonNode embedding = objectMapper.readTree(vector.requestBody()).path("embedding");
if (!embedding.isArray() || embedding.isEmpty()) {
throw new AppException("DDS_QUERY_FAILED: 검색 임베딩을 만들 수 없습니다.");
}
return embedding.toString();
} catch (AppException exception) {
throw exception;
} catch (Exception exception) {
throw new AppException("DDS_QUERY_FAILED: 검색 임베딩을 읽을 수 없습니다.");
}
}
}

View File

@@ -70,6 +70,10 @@ public class DdsVectorKnowledgeService {
return commonVectorService.aiEmbeddingConfigured();
}
public boolean tokenSearchAvailable() {
return !properties.dbUrl().isBlank() && properties.token() != null && properties.token().configured();
}
public VectorIngestResult ingest(VectorIngestCommand command) {
return commonVectorService.ingest(command);
}
@@ -108,8 +112,6 @@ public class DdsVectorKnowledgeService {
String requestId = UUID.randomUUID().toString();
String marker = "DDS_EVIDENCE:" + requestId;
String submittedStatement = "지식자료 벡터 검색 · " + properties.vectorObject()
+ " · 유사도순 정렬 · 결과 " + limit + "";
String sql = "SELECT /* " + marker + " */ 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 "
@@ -144,7 +146,7 @@ public class DdsVectorKnowledgeService {
String message = rows.isEmpty()
? "DDS DATA GRANT를 통과한 검색 단위가 없습니다."
: rows.size() + "개 검색 단위가 DDS DATA GRANT를 통과했습니다.";
DdsSqlEvidence evidence = collectEvidence(connection, marker, submittedStatement, timeoutSeconds);
DdsSqlEvidence evidence = collectEvidence(connection, marker, sql, timeoutSeconds);
evidenceHistory.record(evidence);
return new DdsVectorSearchResult(
normalizedUserKey, userLabel, properties.vectorObject(), normalizedQuery, mode,
@@ -197,8 +199,6 @@ public class DdsVectorKnowledgeService {
String requestId = UUID.randomUUID().toString();
String marker = "DDS_EVIDENCE:" + requestId;
String submittedStatement = "지식자료 벡터 검색 · " + properties.vectorObject()
+ " · 유사도순 정렬 · 결과 " + limit + "";
String sql = "SELECT /* " + marker + " */ 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 "
@@ -212,6 +212,11 @@ public class DdsVectorKnowledgeService {
context.setString(1, normalizedToken);
context.execute();
}
try (PreparedStatement identifier = connection.prepareStatement(
"BEGIN DBMS_SESSION.SET_IDENTIFIER(?); END;")) {
identifier.setString(1, marker);
identifier.execute();
}
String sessionUser = readSingleValue(connection,
"SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM dual", timeoutSeconds);
String endUser = readSingleValue(connection,
@@ -244,7 +249,7 @@ public class DdsVectorKnowledgeService {
: rows.size() + "개 검색 단위가 토큰으로 식별된 업무 사용자("
+ (resolvedAppUser == null ? "확인 불가" : resolvedAppUser)
+ ")의 DDS DATA GRANT를 통과했습니다.";
DdsSqlEvidence evidence = collectEvidence(connection, marker, submittedStatement, timeoutSeconds);
DdsSqlEvidence evidence = collectEvidence(connection, marker, sql, timeoutSeconds);
evidenceHistory.record(evidence);
return new DdsVectorSearchResult(
"token", userLabel, properties.vectorObject(), normalizedQuery, mode,
@@ -271,7 +276,7 @@ public class DdsVectorKnowledgeService {
false, title, message, null, null, oracleCode, List.of(), null);
}
private DdsSqlEvidence collectEvidence(Connection connection, String marker, String submittedStatement,
private DdsSqlEvidence collectEvidence(Connection connection, String marker, String executedSql,
int timeoutSeconds) {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT sql_id, child_number, TO_CHAR(last_active_time, 'YYYY-MM-DD HH24:MI:SS'), executions
@@ -281,20 +286,20 @@ public class DdsVectorKnowledgeService {
statement.setString(1, "%" + marker + "%");
try (ResultSet result = statement.executeQuery()) {
if (!result.next()) {
return DdsSqlEvidence.unavailable(marker, submittedStatement, "실행 SQL ID를 아직 찾지 못했습니다.");
return DdsSqlEvidence.unavailable(marker, executedSql, "실행 SQL은 확인됐지만 SQL ID를 아직 찾지 못했습니다.");
}
String sqlId = result.getString(1);
int child = result.getInt(2);
String vpd = readPredicate(connection, "SELECT predicate FROM v$vpd_policy WHERE sql_id = ?", sqlId, timeoutSeconds);
String plan = readPlanPredicate(connection, sqlId, child, timeoutSeconds);
return new DdsSqlEvidence(true, marker, submittedStatement, sqlId, child, result.getString(3),
return new DdsSqlEvidence(true, marker, executedSql, sqlId, child, result.getString(3),
result.getLong(4), plan, vpd,
vpd != null ? "VPD 정책 predicate를 확인했습니다."
: "DDS DATA GRANT 경로의 실행 SQL을 확인했습니다.");
}
} catch (SQLException exception) {
return DdsSqlEvidence.unavailable(marker, submittedStatement,
"실행 근거 조회 권한이 없습니다. V$SQL, V$VPD_POLICY, DBMS_XPLAN 권한을 확인하세요.");
return DdsSqlEvidence.unavailable(marker, executedSql,
"실행 SQL 전문을 표시합니다. SQL ID와 실행 계획 조회는 이 환경에서 사용할 수 없습니다.");
}
}

View File

@@ -0,0 +1,80 @@
package com.cloudhandson.ddsbackoffice.web;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpAuthenticatedUser;
import com.cloudhandson.ddsbackoffice.service.DdsMcpBearerAuthenticator;
import com.cloudhandson.ddsbackoffice.service.DdsMcpSseService;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
/**
* DDS-specific SSE transport. Authorization is deliberately rechecked for
* every message rather than being trusted for the life of an SSE connection.
*/
@Controller
public class DdsMcpSseController {
private static final long SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L;
private final DdsMcpBearerAuthenticator bearerAuthenticator;
private final DdsMcpSseService service;
private final Map<String, Session> sessions = new ConcurrentHashMap<>();
public DdsMcpSseController(DdsMcpBearerAuthenticator bearerAuthenticator, DdsMcpSseService service) {
this.bearerAuthenticator = bearerAuthenticator;
this.service = service;
}
@GetMapping(path = "/dds/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter sse(@RequestHeader(name = "Authorization", required = false) String authorization) throws IOException {
DdsMcpAuthenticatedUser user = bearerAuthenticator.authenticate(authorization);
String sessionId = UUID.randomUUID().toString();
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS);
sessions.put(sessionId, new Session(user.applicationUserId(), emitter));
emitter.onCompletion(() -> sessions.remove(sessionId));
emitter.onTimeout(() -> sessions.remove(sessionId));
emitter.onError(error -> sessions.remove(sessionId));
emitter.send(SseEmitter.event().name("endpoint").data("/dds/mcp/messages?sessionId=" + sessionId));
return emitter;
}
@PostMapping(path = "/dds/mcp/messages", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> message(
@RequestHeader(name = "Authorization", required = false) String authorization,
@RequestParam(required = false) String sessionId,
@RequestBody JsonNode request
) throws IOException {
DdsMcpAuthenticatedUser user = bearerAuthenticator.authenticate(authorization);
ObjectNode response = service.handle(user, request);
if (sessionId == null || sessionId.isBlank()) {
return ResponseEntity.ok(response);
}
Session session = sessions.get(sessionId);
if (session == null || session.applicationUserId() != user.applicationUserId()) {
throw new AppException("AUTHORIZATION_DENIED: MCP 세션을 확인할 수 없습니다.");
}
try {
session.emitter().send(SseEmitter.event().name("message").data(response));
return ResponseEntity.accepted().build();
} catch (IOException exception) {
sessions.remove(sessionId);
throw exception;
}
}
private record Session(long applicationUserId, SseEmitter emitter) {
}
}

View File

@@ -48,6 +48,12 @@ public class DdsProtectionController {
return "dds-evidence";
}
@GetMapping("/dds-admin")
public String admin(Model model) {
model.addAttribute("ddsVectorObject", properties.vectorObject());
return "dds-admin";
}
@GetMapping("/dds-protection/direct")
public String directComparison(Model model) {
model.addAttribute("directPaths", protectionStatusService.directComparison());

View File

@@ -2,6 +2,7 @@ package com.cloudhandson.ddsbackoffice.web;
import com.cloudhandson.ddsbackoffice.domain.DdsProvisioningPlan;
import com.cloudhandson.ddsbackoffice.service.DdsGrantPublisher;
import com.cloudhandson.ddsbackoffice.service.DdsMcpEndUserPublisher;
import com.cloudhandson.ddsbackoffice.service.DdsProtectionEvidenceStore;
import com.cloudhandson.ddsbackoffice.service.DdsProtectionFingerprint;
import com.cloudhandson.ddsbackoffice.domain.DdsPublishedProtectionSpec;
@@ -16,10 +17,16 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
public class DdsProvisionController {
private final DdsGrantPublisher publisher;
private final DdsMcpEndUserPublisher mcpEndUserPublisher;
private final DdsProtectionEvidenceStore evidenceStore;
public DdsProvisionController(DdsGrantPublisher publisher, DdsProtectionEvidenceStore evidenceStore) {
public DdsProvisionController(
DdsGrantPublisher publisher,
DdsMcpEndUserPublisher mcpEndUserPublisher,
DdsProtectionEvidenceStore evidenceStore
) {
this.publisher = publisher;
this.mcpEndUserPublisher = mcpEndUserPublisher;
this.evidenceStore = evidenceStore;
}
@@ -33,10 +40,17 @@ public class DdsProvisionController {
public String publish(RedirectAttributes redirectAttributes) {
try {
DdsProvisioningPlan plan = publisher.publish();
var mcpResult = mcpEndUserPublisher.publish();
redirectAttributes.addFlashAttribute(
"successMessage",
plan.publishableCount() + "개 DDS DATA GRANT를 게시했습니다. 권한 없는 대상의 기존 Grant는 회수했습니다."
plan.publishableCount() + " 직접 DDS DATA GRANT"
+ mcpResult.publishedUsers() + "개 MCP local END USER 권한을 게시했습니다."
);
if (mcpResult.defaultDeniedUsers() > 0 || mcpResult.revokedUsers() > 0) {
redirectAttributes.addFlashAttribute("warningMessage",
"MCP 기본 거부 " + mcpResult.defaultDeniedUsers() + "명, 비활성 사용자 권한 회수 "
+ mcpResult.revokedUsers() + "명을 반영했습니다.");
}
try {
plan.grants().forEach(grant -> evidenceStore.recordPublished(
new DdsPublishedProtectionSpec(

View File

@@ -4,6 +4,8 @@ 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 com.cloudhandson.vpdbackoffice.service.BearerTokenService;
import com.cloudhandson.vpdbackoffice.service.UserService;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -19,13 +21,17 @@ public class DdsVectorKnowledgeController {
private final DdsVectorKnowledgeService service;
private final DdsQueryService ddsQueryService;
private final UserService userService;
private final BearerTokenService bearerTokenService;
public DdsVectorKnowledgeController(
DdsVectorKnowledgeService service,
DdsQueryService ddsQueryService
DdsQueryService ddsQueryService, UserService userService, BearerTokenService bearerTokenService
) {
this.service = service;
this.ddsQueryService = ddsQueryService;
this.userService = userService;
this.bearerTokenService = bearerTokenService;
}
@GetMapping("/vector-knowledge")
@@ -101,16 +107,29 @@ public class DdsVectorKnowledgeController {
return "fragments/dds-vector-search-result :: result";
}
@PostMapping("/vector-knowledge/user-search")
public String userSearch(@RequestParam long userId, @RequestParam String query,
@RequestParam(defaultValue = "10") int limit, @RequestParam(defaultValue = "DEMO") String embeddingMode,
Model model) {
var token = bearerTokenService.issueTemporaryToken(userId, "DDS 업무 사용자 검증");
try { model.addAttribute("searchResult", service.searchByToken(token.plainToken(), query, limit, embeddingMode)); }
finally { bearerTokenService.revokeToken(token.keyId(), "DDS 업무 사용자 검증 완료"); }
return "fragments/dds-vector-search-result :: result";
}
private void populatePage(Model model) {
try {
model.addAttribute("summary", service.summary());
model.addAttribute("aiEmbeddingConfigured", service.aiEmbeddingConfigured());
model.addAttribute("tokenSearchAvailable", service.tokenSearchAvailable());
} catch (DataAccessException exception) {
model.addAttribute("summary", null);
model.addAttribute("runtimeError", exception.getMessage());
model.addAttribute("aiEmbeddingConfigured", false);
model.addAttribute("tokenSearchAvailable", false);
}
model.addAttribute("ddsUsers", ddsQueryService.users());
model.addAttribute("appUsers", userService.findAll().stream().filter(user -> "Y".equals(user.activeYn())).toList());
model.addAttribute("ddsVectorObject", ddsQueryService.vectorObject());
}
}

View File

@@ -56,6 +56,16 @@ backoffice:
timeout: ${BACKOFFICE_AI_TIMEOUT_SECONDS:30}s
dds:
mcp:
permission-sync-enabled: true
iam:
domain-url: ${DDS_OCI_IAM_DOMAIN_URL:}
token-uri: ${DDS_OCI_IAM_TOKEN_URI:}
client-id: ${DDS_OCI_IAM_CLIENT_ID:}
client-secret: ${DDS_OCI_IAM_CLIENT_SECRET:}
database-scope: ${DDS_OCI_IAM_DATABASE_SCOPE:}
timeout: ${DDS_OCI_IAM_TIMEOUT_SECONDS:10s}
refresh-skew: ${DDS_OCI_IAM_REFRESH_SKEW_SECONDS:60s}
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}

View File

@@ -93,6 +93,8 @@ body { margin: 0; background: var(--dds-bg); color: var(--dds-ink); font-family:
.registration-result dd { font-size: .9rem; font-weight: 700; margin: .2rem 0 0; }
.advanced-workflow > details > summary { color: var(--dds-ink); cursor: pointer; font-size: 1rem; font-weight: 700; }
.advanced-workflow .section-subtitle { margin: .4rem 0 0; }
.advanced-workflow { display: none; }
.menu-flow { display: grid; gap: .6rem; grid-template-columns: repeat(4, minmax(0, 1fr)); }.menu-flow a { background: #f8faff; border: 1px solid var(--dds-line); border-radius: 8px; color: var(--dds-ink); padding: .9rem; text-decoration: none; }.menu-flow strong, .menu-flow span { display: block; }.menu-flow span { color: var(--dds-muted); font-size: .82rem; margin-top: .35rem; }.architecture-map { align-items: center; display: flex; flex-wrap: wrap; gap: .45rem; margin: 1rem 0; }.architecture-map span { background: #edf2ff; border-radius: 6px; padding: .45rem .6rem; }.architecture-map b { color: var(--dds-accent); }.erd-map { display: grid; gap: .5rem; grid-template-columns: repeat(5, minmax(0, 1fr)); margin: 1rem 0; }.erd-map div { border: 1px solid var(--dds-line); border-radius: 7px; padding: .65rem; }.erd-map strong, .erd-map small { display: block; }.erd-map small { color: var(--dds-muted); margin-top: .25rem; }
.form-grid > .btn { justify-self: start; min-width: 7.5rem; width: auto; }
.wizard-progress, .wizard-step-number { display: none; }
.permission-wizard .wizard-panel-heading { align-items: flex-start; }
@@ -134,6 +136,7 @@ body { margin: 0; background: var(--dds-bg); color: var(--dds-ink); font-family:
.hero { padding-top: 30px; }
.protection-status-topline, .protection-status-footer { align-items: flex-start; flex-direction: column; }
.knowledge-options-grid { grid-template-columns: 1fr; }
.menu-flow, .erd-map { grid-template-columns: 1fr; }
.knowledge-options-grid .full-row { grid-column: auto; }
.registration-result { flex-direction: column; }
}

View File

@@ -0,0 +1 @@
<!doctype html><html lang="ko" xmlns:th="http://www.thymeleaf.org"><head th:replace="~{fragments/layout :: head('Admin')}"></head><body><nav th:replace="~{fragments/layout :: nav}"></nav><main class="container py-4"><div class="page-title"><h1>Admin</h1><p class="context-summary">운영 환경과 진단 기능</p></div><section class="content-band"><h2>업무 사용자 검색 연결</h2><p class="section-subtitle">사용자·그룹·역할·권한은 테이블에서 조회합니다. 이 설정은 그 결과를 DDS 보호 객체에 전달할 단일 기술 연결만 관리합니다.</p><details class="explanation-details"><summary>설정 안내</summary><p class="mb-0">Bearer 토큰 원문과 DDS 비밀번호는 권한 테이블에 저장하지 않습니다. 서버 Secret 또는 Wallet에 기술 연결 정보를 등록하세요.</p></details></section><section class="content-band"><h2>보호 대상 설정</h2><p class="section-subtitle" th:text="${ddsVectorObject}">protected object</p><a class="btn btn-sm rw-btn-secondary" href="/dds">보호 대상 관리</a></section><section class="content-band"><h2>DDS 연결 진단</h2><p class="section-subtitle">통합·차단·PG·MY fixture는 업무 사용자 검증이 아닌 DB 연결 진단에만 사용합니다.</p><a class="btn btn-sm rw-btn-secondary" href="/dds">연결 진단 열기</a></section><section class="content-band"><h2>실행 근거</h2><p class="section-subtitle">SQL 실행 증거와 진단 설정</p><a class="btn btn-sm rw-btn-secondary" href="/dds-evidence">실행 근거 보기</a></section></main></body></html>

View File

@@ -6,16 +6,20 @@
<main class="container py-4">
<div class="page-title">
<h1>실행 근거</h1>
<p class="context-summary">최근 지식자료 검색의 SQL ID와 보호 조건</p>
<details class="explanation-details"><summary>도움말</summary><p class="mb-0">토큰·바인드 값·검색 본문은 기록하지 않습니다. SQL ID와 실행 계획 predicate는 DB 커서 캐시에서 읽으므로 캐시가 비워지면 조회할 수 없습니다.</p></details>
<p class="context-summary">FGA 감사 행으로 확인하는 보호 객체 접근 내역</p>
<details class="explanation-details"><summary>도움말</summary><p class="mb-0">요청 ID, 업무 사용자 Context, SQL 원문, 보호 객체, 성공·실패는 FGA/Unified Audit 감사 행에서 조회합니다. 토큰 원문과 바인드 값은 표시하지 않습니다.</p></details>
</div>
<section class="content-band">
<div class="section-heading"><div><h2>FGA 감사 증적</h2><p class="section-subtitle">보호 VIEW SELECT 실행 뒤 생성된 감사 행</p></div><span class="badge text-bg-secondary">감사 trail 연동</span></div>
<div class="empty-result-guide">다음 검색부터 요청 ID로 FGA 감사 행을 연결합니다. 감사 정책이 적용되면 SQL 원문·업무 사용자 Context·반환 코드가 이 영역에 표시됩니다.</div>
</section>
<section class="content-band" th:if="${#lists.isEmpty(evidenceEntries)}">
<h2>표시할 실행 내역이 없습니다.</h2><p class="section-subtitle">권한 검색 또는 직접 접근 검증을 실행하면 이 화면에 최근 내역이 표시됩니다.</p>
</section>
<section class="content-band" th:each="entry : ${evidenceEntries}">
<div class="section-heading"><div><h2 th:text="${entry.submittedStatement()}">지식자료 검색</h2><p class="section-subtitle" th:text="${entry.status()}">status</p></div><span class="badge text-bg-secondary" th:text="${entry.available()} ? '실행 확인' : '조회 제한'">status</span></div>
<div class="section-heading"><div><h2>지식자료 검색</h2><p class="section-subtitle" th:text="${entry.status()}">status</p></div><span class="badge text-bg-secondary" th:text="${entry.available()} ? '실행 확인' : '조회 제한'">status</span></div>
<dl class="protection-evidence-grid"><div><dt>SQL ID</dt><dd><code th:text="${entry.sqlId() ?: '-'}">-</code></dd></div><div><dt>마지막 실행</dt><dd th:text="${entry.lastActiveTime() ?: '-'}">-</dd></div><div><dt>실행 횟수</dt><dd th:text="${entry.executions() ?: '-'}">-</dd></div></dl>
<details class="technical-details"><summary>적용 조건 보기</summary><p th:if="${entry.planPredicate()}"><strong>실행 계획 predicate</strong><br><code th:text="${entry.planPredicate()}">predicate</code></p><p class="mb-0" th:if="${entry.vpdPredicate()}"><strong>VPD predicate</strong><br><code th:text="${entry.vpdPredicate()}">predicate</code></p></details>
<details class="technical-details"><summary>실행 SQL과 적용 조건 보기</summary><p><strong>실행 SQL</strong><br><code th:text="${entry.executedSql()}">statement</code></p><p th:if="${entry.planPredicate()}"><strong>실행 계획 predicate</strong><br><code th:text="${entry.planPredicate()}">predicate</code></p><p class="mb-0" th:if="${entry.vpdPredicate()}"><strong>VPD predicate</strong><br><code th:text="${entry.vpdPredicate()}">predicate</code></p></details>
</section>
</main>
</body>

View File

@@ -17,6 +17,12 @@
<div class="alert alert-warning" th:if="${runtimeError}">DDS 관리 데이터를 불러오지 못했습니다. <span th:text="${runtimeError}"></span></div>
<section class="content-band">
<div class="section-heading"><div><h2>권한 적용 흐름</h2><p class="section-subtitle">권한을 정하고, 보호 정책에 반영한 뒤 결과를 확인합니다.</p></div></div>
<div class="menu-flow"><a href="/permissions"><strong>권한 관리</strong><span>사용자·그룹·역할과 접근 태그 규칙</span></a><a href="/dds-protection"><strong>보호 정책 관리</strong><span>보호 대상과 현재 집행 상태</span></a><a href="/vector-knowledge"><strong>지식자료 관리</strong><span>자료 등록과 권한 검색</span></a><a href="/dds-evidence"><strong>접근 검증</strong><span>실행 결과와 적용 근거</span></a></div>
<details class="explanation-details mt-3"><summary>전체 구조 보기</summary><div class="architecture-map"><span>사용자 테이블 사용자</span><b></b><span>그룹 · 역할 · 접근 규칙</span><b></b><span>MCP Bearer</span><b></b><span>local DDS END USER Context</span><b></b><span>DATA GRANT</span><b></b><span>보호 VIEW · 검색 결과</span></div><div class="erd-map"><div><strong>CB_APP_USER</strong><small>업무 사용자</small></div><div><strong>CB_GROUP · CB_ROLE</strong><small>소속과 역할</small></div><div><strong>CB_PERMISSION_RULE</strong><small>TAG 허용·거부</small></div><div><strong>CB_AGENT_BEARER_KEY</strong><small>요청 사용자 식별</small></div><div><strong>CB_DDS_END_USER_MAP</strong><small>DDS 보안 사용자 매핑</small></div></div><p class="mb-0">업무 사용자는 기존 사용자 테이블에서 관리합니다. OCI IAM은 서비스의 DB Context attach만 승인하고, 실제 행·컬럼 권한은 해당 local DDS END USER의 DATA GRANT가 집행합니다.</p></details>
</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>

View File

@@ -10,8 +10,8 @@
<p class="context-summary">사용자·그룹·역할의 접근 규칙을 검토한 뒤 DDS 보호 규칙에 반영합니다.</p>
<details class="explanation-details">
<summary>도움말</summary>
<p>이 화면은 직접 DDS END USER 비교 경로와 일반 객체의 선언형 Grant를 미리 보고 게시하는 단계입니다. 토큰 기반 벡터 경로는 별도 객체별 Data Grant predicate가 요청 시 공통 권한 테이블을 다시 평가합니다.</p>
<p class="mb-0">그룹 자체를 DDS 그룹으로 복사하지는 않습니다. 애플리케이션 그룹 역할 상속을 계산해 직접 비교용 DATA ROLE Grant에 반영하고, 토큰 경로에서는 같은 effective role 계산을 predicate 함수가 사용합니다.</p>
<p>게시하면 직접 DDS 비교 Grant와 MCP SSE용 local END USER/DATA ROLE/DATA GRANT를 함께 갱신합니다. MCP 요청 Bearer는 기존 사용자 테이블의 업무 사용자를 찾고, DDS는 그 사용자의 게시된 권한만 집행합니다.</p>
<p class="mb-0">OCI IAM client-credentials token은 서비스의 Context attach 권한만 제공합니다. MCP 업무 사용자를 IAM에 따로 만들지 않으며, 그룹 역할 상속은 기존 권한 관리 기준으로 계산합니다.</p>
</details>
</div>
@@ -27,12 +27,12 @@
<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>MCP Bearer 벡터 검색</td><td>Bearer → 업무 사용자 → local DDS END USER Context → DATA GRANT</td><td>권한 저장 시 자동 bulk 동기화 · 이 화면은 전체 재동기화</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>
<p class="text-muted mt-3 mb-0">DDS는 게시된 Grant가 바뀔 때까지 이전 선언을 사용합니다. 이 화면의 게시 버튼이 기존 권한 관리와 MCP local END USER 권한을 동기화하는 경계입니다.</p>
</details>
</section>

View File

@@ -41,7 +41,7 @@
<div><dt>실행 횟수</dt><dd th:text="${searchResult.sqlEvidence().executions()}">0</dd></div>
</dl>
<details class="technical-details mt-2"><summary>실행 조건 보기</summary>
<p>요청: <span th:text="${searchResult.sqlEvidence().submittedStatement()}">statement</span></p>
<p><strong>실행 SQL</strong><br><code th:text="${searchResult.sqlEvidence().executedSql()}">statement</code></p>
<p th:if="${searchResult.sqlEvidence().planPredicate()}">계획 predicate: <code th:text="${searchResult.sqlEvidence().planPredicate()}">predicate</code></p>
<p class="mb-0" th:if="${searchResult.sqlEvidence().vpdPredicate()}">VPD predicate: <code th:text="${searchResult.sqlEvidence().vpdPredicate()}">predicate</code></p>
</details>

View File

@@ -22,6 +22,7 @@
<button class="rw-menu-trigger" type="button" data-submenu-trigger="knowledge" aria-controls="submenu-knowledge" aria-expanded="false">지식자료 관리</button>
<button class="rw-menu-trigger" type="button" data-submenu-trigger="verification" aria-controls="submenu-verification" aria-expanded="false">접근 검증</button>
</div>
<a class="btn btn-sm rw-btn-secondary" href="/dds-admin">Admin</a>
<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>
@@ -54,7 +55,7 @@
<div th:fragment="trackNotice" th:if="${backofficeTrack == 'DDS'}" class="container pt-3">
<div class="alert alert-info mb-0">
<strong>DDS 보호 운영</strong> · 실제 보호는 객체별 <code>DATA GRANT</code>가 담당합니다. 업무 사용자 검색은 <code>Bearer → CB_AGENT_CTX → 접근 조건</code>, 직접 접근 비교는 <code>END USER → DATA ROLE → DATA GRANT</code> 경로로 동작합니다.
<strong>DDS 보호 운영</strong> · 실제 보호는 객체별 <code>DATA GRANT</code>가 담당합니다. MCP 검색은 <code>Bearer → 업무 사용자 → local END USER Context → DATA GRANT</code>, 직접 접근 비교는 <code>END USER → DATA ROLE → DATA GRANT</code> 경로로 동작합니다.
</div>
</div>

View File

@@ -109,11 +109,11 @@
<summary>도움말</summary>
<p class="mb-0">토큰은 검색하는 사용자를 확인하기 위한 임시 값이며 저장하지 않습니다. 회수되었거나 만료된 토큰은 사용할 수 없습니다.</p>
</details>
<form hx-post="/vector-knowledge/token-search" hx-target="#vector-token-search-result" hx-swap="innerHTML" class="knowledge-entry-form">
<form hx-post="/vector-knowledge/user-search" hx-target="#vector-token-search-result" hx-swap="innerHTML" class="knowledge-entry-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>Bearer 토큰
<input class="form-control" name="bearerToken" type="password" autocomplete="off" placeholder="발급된 임시 토큰을 붙여 넣으세요." required>
<span class="form-hint">요청 처리 후 저장하지 않습니다. 회수·만료된 토큰은 거부됩니다.</span>
<label>업무 사용자
<select class="form-select" name="userId" required><option value="" selected disabled>사용자 선택</option><option th:each="user : ${appUsers}" th:value="${user.userId()}" th:text="${user.username() + ' · ' + user.deptCode()}"></option></select>
<span class="form-hint">선택한 사용자에게만 유효한 임시 토큰을 발급하고 검색 직후 회수합니다.</span>
</label>
<label>검색 질문
<textarea class="form-control" name="query" rows="3" placeholder="예: 세일즈 파이프라인 후속 조치 기준" required></textarea>