Consolidate data access control backoffice updates

This commit is contained in:
devmrko
2026-07-13 23:06:23 +09:00
parent 403298d474
commit e18b30feab
181 changed files with 11571 additions and 954 deletions

View File

@@ -11,7 +11,41 @@ public record BackofficeProperties(
Ai ai
) {
public record Security(String adminUser, String adminPassword, boolean requireHttps) {
public record Security(
String adminUser,
String adminPassword,
String adminPasswordHash,
boolean guestEnabled,
String guestUser,
String guestPassword,
String guestPasswordHash,
boolean requireHttps,
boolean rememberMeEnabled,
String rememberMeKey,
int rememberMeDays
) {
/** Remember-me is intentionally unavailable over HTTP or without a stable secret key. */
public boolean rememberMeConfigured() {
return requireHttps
&& rememberMeEnabled
&& rememberMeKey != null
&& !rememberMeKey.isBlank()
&& rememberMeDays >= 1
&& rememberMeDays <= 90;
}
public int rememberMeValiditySeconds() {
return rememberMeDays * 24 * 60 * 60;
}
public boolean guestConfigured() {
return guestEnabled
&& guestUser != null
&& !guestUser.isBlank()
&& ((guestPassword != null && !guestPassword.isBlank())
|| (guestPasswordHash != null && !guestPasswordHash.isBlank()));
}
}
public record Token(int maxDays) {
@@ -22,15 +56,20 @@ public record BackofficeProperties(
public record Ai(
boolean enabled,
String provider,
String baseUrl,
String model,
String apiKey,
Duration timeout,
String embeddingModel
String embeddingModel,
String ociConfigFile,
String ociProfile,
String ociRegion,
String ociCompartmentId
) {
public Ai(boolean enabled, String baseUrl, String model, String apiKey, Duration timeout) {
this(enabled, baseUrl, model, apiKey, timeout, "");
this(enabled, "openai", baseUrl, model, apiKey, timeout, "", "", "", "", "");
}
}
}

View File

@@ -1,71 +0,0 @@
package com.cloudhandson.vpdbackoffice.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* Authenticates only the streamable HTTP MCP endpoint with a service token.
* Business-data authorization remains the bearerToken tool argument, which is
* resolved to the VPD context by the ORDS handler.
*/
@Component
public class McpAccessTokenFilter extends OncePerRequestFilter {
private final String accessToken;
public McpAccessTokenFilter(@Value("${backoffice.mcp.access-token:}") String accessToken) {
this.accessToken = accessToken == null ? "" : accessToken.trim();
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !"/mcp".equals(request.getRequestURI());
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String bearerToken = bearerToken(request.getHeader("Authorization"));
if (!accessToken.isBlank() && bearerToken != null && constantTimeEquals(accessToken, bearerToken)) {
var authentication = new UsernamePasswordAuthenticationToken(
"mcp-client",
null,
List.of(new SimpleGrantedAuthority("ROLE_MCP"))
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String bearerToken(String authorization) {
if (authorization == null || !authorization.regionMatches(true, 0, "Bearer ", 0, 7)) {
return null;
}
String value = authorization.substring(7).trim();
return value.isEmpty() ? null : value;
}
private boolean constantTimeEquals(String expected, String actual) {
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
actual.getBytes(StandardCharsets.UTF_8)
);
}
}

View File

@@ -1,7 +1,6 @@
package com.cloudhandson.vpdbackoffice.config;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -20,7 +19,6 @@ public class OrdsClientConfig {
}
@Bean
@Qualifier("ordsAgentRestTemplate")
RestTemplate ordsAgentRestTemplate(BackofficeProperties properties) {
Duration timeout = properties.ords().agentTimeout();
return new RestTemplateBuilder()

View File

@@ -1,16 +1,18 @@
package com.cloudhandson.vpdbackoffice.config;
import java.util.ArrayList;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.http.HttpMethod;
@Configuration
public class SecurityConfig {
@@ -19,23 +21,48 @@ public class SecurityConfig {
SecurityFilterChain securityFilterChain(
HttpSecurity http,
BackofficeProperties properties,
McpAccessTokenFilter mcpAccessTokenFilter
UserDetailsService userDetailsService
) throws Exception {
if (properties.security().requireHttps()) {
http.requiresChannel(channel -> channel.anyRequest().requiresSecure());
}
var security = properties.security();
if (security.rememberMeConfigured()) {
http.rememberMe(rememberMe -> rememberMe
.key(security.rememberMeKey())
.userDetailsService(userDetailsService)
.rememberMeParameter("remember-me")
.rememberMeCookieName("VPD_REMEMBER_ME")
.tokenValiditySeconds(security.rememberMeValiditySeconds())
.useSecureCookie(true)
.alwaysRemember(false));
}
return http
.addFilterBefore(mcpAccessTokenFilter, BasicAuthenticationFilter.class)
.csrf(csrf -> csrf.ignoringRequestMatchers(
"/mcp", "/mcp/messages", "/mcp/*/messages", "/dds/mcp/messages"))
"/mcp", "/mcp/messages", "/mcp/*/messages"))
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31_536_000)))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/css/**", "/js/**", "/webjars/**", "/dds/mcp/sse", "/dds/mcp/messages")
.requestMatchers(
"/css/**", "/js/**", "/webjars/**",
"/mcp", "/mcp/sse", "/mcp/*/sse", "/mcp/messages", "/mcp/*/messages")
.permitAll()
.requestMatchers(HttpMethod.POST, "/mcp").hasAnyRole("ADMIN", "MCP")
.requestMatchers(HttpMethod.POST, "/login", "/logout").permitAll()
.requestMatchers(HttpMethod.POST,
"/probe",
"/vector-knowledge/search",
"/security-sql-scripts/explanation",
"/mcp-chatbot",
"/mcp-client-demo",
"/mcp-reasoning")
.authenticated()
.requestMatchers(HttpMethod.PUT, "/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.PATCH, "/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/**").hasRole("ADMIN")
.anyRequest().authenticated())
.httpBasic(basic -> {
})
@@ -52,11 +79,26 @@ public class SecurityConfig {
PasswordEncoder passwordEncoder
) {
var security = properties.security();
var user = User.withUsername(security.adminUser())
.password(passwordEncoder.encode(security.adminPassword()))
String encodedPassword = security.adminPasswordHash() == null || security.adminPasswordHash().isBlank()
? passwordEncoder.encode(security.adminPassword())
: security.adminPasswordHash();
List<UserDetails> users = new ArrayList<>();
users.add(User.withUsername(security.adminUser())
// BCrypt encoding includes a random salt. Re-encoding the configured
// password on every startup would invalidate remember-me signatures.
.password(encodedPassword)
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user);
.build());
if (security.guestConfigured()) {
String encodedGuestPassword = security.guestPasswordHash() == null || security.guestPasswordHash().isBlank()
? passwordEncoder.encode(security.guestPassword())
: security.guestPasswordHash();
users.add(User.withUsername(security.guestUser())
.password(encodedGuestPassword)
.roles("VIEWER")
.build());
}
return new InMemoryUserDetailsManager(users);
}
@Bean

View File

@@ -0,0 +1,31 @@
package com.cloudhandson.vpdbackoffice.domain.masking;
public record ColumnMaskingRule(
long columnId,
long objectId,
String owner,
String objectName,
String columnName,
long ruleId,
String ruleCode,
String ruleName,
String templateCode,
String ruleEnabledYn
) {
public String targetLabel() {
return owner + "." + objectName + "." + columnName;
}
public MaskingTemplate template() {
return MaskingTemplate.from(templateCode);
}
public String ruleLabel() {
return ruleName + " · " + template().label();
}
public boolean ruleEnabled() {
return "Y".equalsIgnoreCase(ruleEnabledYn);
}
}

View File

@@ -0,0 +1,77 @@
package com.cloudhandson.vpdbackoffice.domain.masking;
/**
* Read-only comparison between the configured masking metadata and the
* corresponding Oracle Data Redaction policy stored in the database.
*/
public record MaskingPolicyStatus(
String owner,
String objectName,
String policyName,
String enabled,
int configuredColumnCount,
int appliedColumnCount,
int mismatchedColumnCount,
int legacyVpdColumnPolicyCount
) {
public String targetLabel() {
return owner + "." + objectName;
}
public boolean policyEnabled() {
return "YES".equalsIgnoreCase(enabled);
}
/** A policy with no configured active column is correct only while disabled. */
public boolean inactiveAsExpected() {
return configuredColumnCount == 0 && !policyEnabled() && legacyVpdColumnPolicyCount == 0;
}
public boolean applied() {
return configuredColumnCount > 0
&& policyEnabled()
&& configuredColumnCount == appliedColumnCount
&& mismatchedColumnCount == 0
&& legacyVpdColumnPolicyCount == 0;
}
public String statusLabel() {
if (applied()) {
return "적용됨";
}
if (inactiveAsExpected()) {
return "미적용";
}
return "설정-DB 불일치";
}
public String badgeClass() {
if (applied()) {
return "text-bg-success";
}
if (inactiveAsExpected()) {
return "text-bg-secondary";
}
return "text-bg-danger";
}
public String detail() {
if (applied()) {
return "활성 규칙과 DB 정책 컬럼이 일치합니다.";
}
if (legacyVpdColumnPolicyCount > 0) {
return "기존 컬럼 NULL 정책(VPD CLS)이 활성입니다. ASO 설정과 별도로 값을 NULL 처리할 수 있습니다.";
}
if (inactiveAsExpected()) {
return "활성 컬럼 기본 규칙이 없어 DB 정책을 중지 상태로 보관합니다.";
}
if (configuredColumnCount > 0 && !policyEnabled()) {
return "활성 규칙이 있으나 DB 정책이 비활성입니다. 동기화가 필요합니다.";
}
if (configuredColumnCount == 0) {
return "활성 규칙이 없는데 DB 정책이 활성입니다. 동기화가 필요합니다.";
}
return "설정 컬럼과 DB Redaction 컬럼이 다릅니다. 동기화가 필요합니다.";
}
}

View File

@@ -0,0 +1,23 @@
package com.cloudhandson.vpdbackoffice.domain.masking;
public record MaskingRule(
long ruleId,
String ruleCode,
String ruleName,
String templateCode,
String description,
String enabledYn
) {
public boolean enabled() {
return "Y".equalsIgnoreCase(enabledYn);
}
public MaskingTemplate template() {
return MaskingTemplate.from(templateCode);
}
public String templateLabel() {
return template().label();
}
}

View File

@@ -0,0 +1,9 @@
package com.cloudhandson.vpdbackoffice.domain.masking;
public record MaskingRuleCreateCommand(
String ruleCode,
String ruleName,
String templateCode,
String description
) {
}

View File

@@ -0,0 +1,76 @@
package com.cloudhandson.vpdbackoffice.domain.masking;
import java.util.Arrays;
/**
* Curated Data Redaction behaviours. A rule selects one template; operators
* never enter raw DBMS_REDACT expressions from the backoffice UI.
*/
public enum MaskingTemplate {
NULLIFY(
"NULLIFY",
"값 숨김 (NULL)",
"값을 NULL로 반환합니다. ASO/Data Redaction 컬럼 마스킹에 사용합니다.",
"DBMS_REDACT.NULLIFY",
"NULL"),
FULL(
"FULL",
"전체 마스킹",
"전체 값을 가립니다. Oracle 기본값은 문자형 공백, 숫자형 0입니다.",
"DBMS_REDACT.FULL",
"문자형은 공백 / 숫자형은 0"),
TEXT_PARTIAL(
"TEXT_PARTIAL",
"문자열 일부 마스킹",
"첫 글자만 남기고 나머지를 가리는 사전 정의 문자열 규칙입니다.",
"DBMS_REDACT.REGEXP",
"A******** (예시)"),
RRN_PARTIAL(
"RRN_PARTIAL",
"주민등록번호 부분 마스킹",
"앞 6자리만 표시하고 나머지는 가리는 사전 정의 식별번호 규칙입니다.",
"DBMS_REDACT.REGEXP",
"900101-******* (예시)");
private final String code;
private final String label;
private final String description;
private final String asoFunction;
private final String previewResult;
MaskingTemplate(String code, String label, String description, String asoFunction, String previewResult) {
this.code = code;
this.label = label;
this.description = description;
this.asoFunction = asoFunction;
this.previewResult = previewResult;
}
public String code() {
return code;
}
public String label() {
return label;
}
public String description() {
return description;
}
public String asoFunction() {
return asoFunction;
}
/** Human-readable result shown before an administrator assigns the rule. */
public String previewResult() {
return previewResult;
}
public static MaskingTemplate from(String code) {
return Arrays.stream(values())
.filter(value -> value.code.equalsIgnoreCase(code))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("지원하지 않는 마스킹 템플릿입니다: " + code));
}
}

View File

@@ -0,0 +1,35 @@
package com.cloudhandson.vpdbackoffice.domain.masking;
public record UserMaskingRule(
long userId,
String username,
long columnId,
String owner,
String objectName,
String columnName,
String ruleName,
String templateCode,
String decision,
String activeYn
) {
public boolean unmasked() {
return "UNMASK".equalsIgnoreCase(decision);
}
public boolean active() {
return "Y".equalsIgnoreCase(activeYn);
}
public String decisionLabel() {
return unmasked() ? "원문 표시 허용" : "기본값과 동일 · 마스킹";
}
public String targetLabel() {
return owner + "." + objectName + "." + columnName;
}
public String ruleLabel() {
return ruleName + " · " + MaskingTemplate.from(templateCode).label();
}
}

View File

@@ -47,10 +47,10 @@ public record OperationStatusRow(
return "ORDS path와 handler schema/module/template 매핑을 확인하세요.";
}
if (policyNames == null || policyNames.isBlank()) {
return "VPD policy를 적용하세요.";
return "행 접근 정책(VPD)을 적용하세요.";
}
if (policyEnabled == null || !policyEnabled.toUpperCase().contains("YES")) {
return "VPD policy enable 상태를 확인하세요.";
return "행 접근 정책(VPD) enable 상태를 확인하세요.";
}
if (functionStatus != null && !"VALID".equalsIgnoreCase(functionStatus)) {
return "Policy function 컴파일 오류를 확인하세요.";

View File

@@ -13,4 +13,69 @@ public record PermissionView(
String filterPreview,
String nullPolicyPreview
) {
public String businessRuleSummary() {
if (rules == null || rules.isBlank()) {
return "-";
}
return java.util.Arrays.stream(rules.split(","))
.map(String::trim)
.filter(value -> !value.isBlank())
.map(PermissionView::businessRuleLabel)
.collect(java.util.stream.Collectors.joining(System.lineSeparator() + "AND "));
}
public String storedRuleSummary() {
return rules == null || rules.isBlank() ? "-" : rules;
}
public String vpdMappingSummary() {
return filterPreview == null || filterPreview.isBlank() ? "-" : filterPreview;
}
public String columnControlSummary() {
if (visibleColumns == null || visibleColumns.isBlank()) {
return "컬럼 원문/마스킹은 컬럼 마스킹에서 관리";
}
return "기존 권한별 표시 예외 값: " + visibleColumns
+ " · 신규 컬럼 제어는 컬럼 마스킹에서 관리";
}
private static String businessRuleLabel(String rawRule) {
String upper = rawRule.toUpperCase(java.util.Locale.ROOT);
if ("ALL".equals(upper)) {
return "전체 행";
}
if (upper.contains(" TOKEN_SUBJECT")) {
return "토큰으로 식별된 이해관계자 본인 행";
}
if (upper.contains(" OWN_CONTRACT")) {
return "담당 설계사 본인 계약";
}
if (upper.contains(" CHANNEL_CONTRACT")) {
return "토큰 사용자의 채널 계약";
}
if (upper.contains(" OWN_CUSTOMER")) {
return "담당 설계사 본인 계약에 연결된 고객/청구/외부보유";
}
if (upper.contains(" CHANNEL_CUSTOMER")) {
return "토큰 사용자 채널 계약에 연결된 고객/청구/외부보유";
}
if (upper.contains(" STATIC_SQL ")) {
return "정적 SQL 조건: " + rawRule.replaceFirst("(?i)^\\s*STATIC_SQL\\s+", "");
}
if (upper.startsWith("STATIC_SQL ")) {
return "정적 SQL 조건: " + rawRule.substring("STATIC_SQL ".length());
}
if (upper.contains(" MY_DEPT")) {
return "내 부서 행";
}
if (upper.contains(" SELF")) {
return "내 사번/소유자 행";
}
if (upper.contains(" TAG ")) {
return "태그 조건: " + rawRule;
}
return rawRule;
}
}

View File

@@ -238,7 +238,7 @@ public record ProbeResult(
case INVALID_TOKEN -> "입력한 토큰 정보가 일치하지 않습니다.";
case OBJECT_DISABLED -> "검증 대상이 비활성 상태입니다.";
case OBJECT_NOT_ACCESSIBLE -> "현재 권한으로 이 대상에 접근할 수 없습니다.";
case VPD_FILTER_ERROR -> "이 객체에 연결된 별도 VPD Filter가 실행되지 않습니다.";
case VPD_FILTER_ERROR -> "이 객체에 연결된 행 접근 Filter가 실행되지 않습니다.";
case ORDS_PATH_NOT_FOUND -> "검증 대상의 ORDS 경로를 찾지 못했습니다.";
case ORDS_NOT_CONFIGURED -> "ORDS 연결 주소가 아직 설정되지 않았습니다.";
case ORDS_UNAVAILABLE -> "ORDS 서버에 연결할 수 없습니다.";
@@ -250,9 +250,9 @@ public record ProbeResult(
public String plainSummary() {
return switch (status) {
case SUCCESS -> "토큰의 사용자와 역할을 기준으로 VPD가 적용되었고, 허용된 데이터 " + rowCount
case SUCCESS -> "토큰의 사용자와 역할을 기준으로 행 접근 정책이 적용되었고, 허용된 데이터 " + rowCount
+ "개가 반환되었습니다.";
case VPD_DENY_EMPTY_RESULT -> "호출은 정상 처리됐지만 VPD가 현재 사용자에게 허용한 행은 0개입니다. 권한 규칙과 실제 데이터가 맞지 않으면 정상 결과이며 오류가 아닐 수 있습니다.";
case VPD_DENY_EMPTY_RESULT -> "호출은 정상 처리됐지만 행 접근 정책이 현재 사용자에게 허용한 행은 0개입니다. 행 접근 규칙과 실제 데이터가 맞지 않으면 정상 결과이며 오류가 아닐 수 있습니다.";
case TOKEN_NOT_FOUND -> "입력한 원문과 일치하는 등록 기록이 현재 DB에 없습니다. 예전에 발급한 값이거나 다른 환경의 토큰일 수 있습니다.";
case TOKEN_INACTIVE -> "토큰은 DB에 있지만 만료되었거나 관리자가 회수해 더 이상 사용자 권한을 증명할 수 없습니다.";
case INVALID_TOKEN -> "화면에서 선택한 정보와 입력한 토큰 원문이 서로 다릅니다.";
@@ -270,17 +270,17 @@ public record ProbeResult(
public String nextAction() {
return switch (status) {
case SUCCESS -> "반환된 행과 마스킹 컬럼이 예상한 범위인지 확인하세요. 다르면 권한 화면의 행·열 규칙을 조정한 뒤 다시 검증하세요.";
case SUCCESS -> "반환된 행과 ASO 마스킹 컬럼이 예상한 범위인지 확인하세요. 행 범위가 다르면 행 접근 규칙을, 컬럼 표시가 다르면 컬럼 마스킹을 조정한 뒤 다시 검증하세요.";
case VPD_DENY_EMPTY_RESULT -> "유효 권한 화면에서 사용자에게 직접 또는 그룹으로 상속된 역할과 행 규칙을 확인하세요.";
case TOKEN_NOT_FOUND -> "토큰 화면에서 현재 환경의 사용자에게 새 토큰을 발급하고, 한 번만 표시되는 원문을 복사해 다시 검증하세요.";
case TOKEN_INACTIVE -> "토큰 화면에서 활성 토큰을 새로 발급한 뒤 다시 검증하세요.";
case INVALID_TOKEN -> "복사한 원문이 맞는지 확인하고, 원문을 잃었다면 새 토큰을 발급하세요.";
case OBJECT_DISABLED -> "보호 객체를 활성화하고 권한을 등록한 뒤 다시 검증하세요.";
case OBJECT_NOT_ACCESSIBLE -> "유효 권한과 ORDS handler의 대상 객체가 같은지 확인하세요.";
case VPD_FILTER_ERROR -> "토큰이나 권한을 바꾸지 말고 DB 보호 연결에서 이 객체의 Filter를 확인하세요. 일반 권한 객체라면 권한체계 자동 Filter로 복구하세요.";
case VPD_FILTER_ERROR -> "토큰이나 행 접근 규칙을 바꾸지 말고 DB 보호 연결에서 이 객체의 Filter를 확인하세요. 일반 권한 객체라면 표준 행 접근 Filter로 복구하세요.";
case ORDS_PATH_NOT_FOUND -> "보호 객체의 ORDS 경로와 실제 module/template 경로를 맞춘 뒤 다시 실행하세요.";
case ORDS_NOT_CONFIGURED -> "설정에서 ORDS 기준 주소를 등록한 뒤 백오피스를 재시작하세요.";
case ORDS_UNAVAILABLE, ORDS_TIMEOUT -> "권한 설정을 바꾸지 말고 먼저 ORDS 실행 상태, 주소와 네트워크를 확인하세요.";
case ORDS_UNAVAILABLE, ORDS_TIMEOUT -> "행 접근 규칙을 바꾸지 말고 먼저 ORDS 실행 상태, 주소와 네트워크를 확인하세요.";
case INVALID_ORDS_RESPONSE -> "ORDS handler가 rows 또는 items 배열을 반환하는지 확인하세요.";
case UNKNOWN_ERROR -> "아래 기술 상세의 오류 코드와 응답을 확인한 뒤 해당 단계부터 점검하세요.";
};

View File

@@ -0,0 +1,4 @@
package com.cloudhandson.vpdbackoffice.domain.schemametadata;
public record SchemaAnnotation(String name, String value) {
}

View File

@@ -0,0 +1,12 @@
package com.cloudhandson.vpdbackoffice.domain.schemametadata;
import java.util.List;
public record SchemaMetadataColumn(
String columnName,
String dataType,
boolean nullable,
String comment,
List<SchemaAnnotation> annotations
) {
}

View File

@@ -0,0 +1,12 @@
package com.cloudhandson.vpdbackoffice.domain.schemametadata;
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
import java.util.List;
public record SchemaMetadataView(
StructuredDataTable table,
String tableComment,
List<SchemaAnnotation> tableAnnotations,
List<SchemaMetadataColumn> columns
) {
}

View File

@@ -0,0 +1,12 @@
package com.cloudhandson.vpdbackoffice.domain.securityscript;
/** Curated, version-controlled database script displayed read-only in the backoffice. */
public record SecuritySqlScript(
String scriptId,
String category,
String fileName,
String title,
String description,
String source
) {
}

View File

@@ -0,0 +1,11 @@
package com.cloudhandson.vpdbackoffice.domain.securityscript;
/** Explanation generated from one immutable, curated security SQL script. */
public record SecuritySqlScriptExplanation(
String status,
String modelName,
String answer,
String prompt,
SecuritySqlScript script
) {
}

View File

@@ -0,0 +1,11 @@
package com.cloudhandson.vpdbackoffice.domain.securityscript;
/** Metadata for selecting a curated security SQL script without loading its source. */
public record SecuritySqlScriptSummary(
String scriptId,
String category,
String fileName,
String title,
String description
) {
}

View File

@@ -0,0 +1,5 @@
package com.cloudhandson.vpdbackoffice.domain.vpd;
/** A saved VPD policy or filter description, addressed by a stable composite key. */
public record VpdDescriptionNote(String noteKey, String description) {
}

View File

@@ -0,0 +1,52 @@
package com.cloudhandson.vpdbackoffice.mapper;
import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus;
import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface MaskingRuleMapper {
List<MaskingRule> findAllRules();
List<MaskingRule> findEnabledRules();
MaskingRule findRuleById(@Param("ruleId") long ruleId);
MaskingRule findRuleByCode(@Param("ruleCode") String ruleCode);
long nextRuleId();
void insertRule(@Param("ruleId") long ruleId, @Param("command") MaskingRuleCreateCommand command);
int updateRuleActive(@Param("ruleId") long ruleId, @Param("enabledYn") String enabledYn);
List<ColumnMaskingRule> findColumnRules();
List<MaskingPolicyStatus> findPolicyStatuses();
ColumnMaskingRule findColumnRule(@Param("columnId") long columnId);
void upsertColumnRule(@Param("columnId") long columnId, @Param("ruleId") long ruleId);
int deleteColumnRule(@Param("columnId") long columnId);
int deleteUserRulesForColumn(@Param("columnId") long columnId);
List<UserMaskingRule> findUserRules();
UserMaskingRule findUserRule(@Param("userId") long userId, @Param("columnId") long columnId);
void upsertUserRule(
@Param("userId") long userId,
@Param("columnId") long columnId,
@Param("decision") String decision
);
int deleteUserRule(@Param("userId") long userId, @Param("columnId") long columnId);
}

View File

@@ -21,6 +21,13 @@ public interface ProtectedObjectMapper {
List<ProtectedColumn> findColumns(@Param("objectId") long objectId);
List<ProtectedColumn> findColumnsByObjectIds(@Param("objectIds") List<Long> objectIds);
ProtectedColumn findColumnById(@Param("columnId") long columnId);
ProtectedColumn findColumnByObjectAndName(@Param("objectId") long objectId,
@Param("columnName") String columnName);
List<DatabaseObjectOption> findDatabaseObjects();
List<String> findDatabaseColumns(@Param("owner") String owner, @Param("objectName") String objectName);

View File

@@ -1,6 +1,7 @@
package com.cloudhandson.vpdbackoffice.mapper;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdFunctionOption;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdDescriptionNote;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyTemplateOption;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdSchemaObjectOption;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView;
@@ -66,6 +67,10 @@ public interface VpdPolicyMapper {
@Param("functionName") String functionName
);
List<VpdDescriptionNote> findPolicyDescriptions();
List<VpdDescriptionNote> findFilterDescriptions();
int upsertPolicyDescription(
@Param("objectOwner") String objectOwner,
@Param("objectName") String objectName,

View File

@@ -132,6 +132,33 @@ public class BackofficeSchemaService {
CONSTRAINT cb_protected_column_uk UNIQUE (object_id, column_name)
)
"""),
new TableDefinition("CB_MASKING_RULE", """
CREATE TABLE cb_masking_rule (
rule_id NUMBER PRIMARY KEY,
rule_code VARCHAR2(64) NOT NULL UNIQUE,
rule_name VARCHAR2(100) NOT NULL,
template_code VARCHAR2(30) NOT NULL,
description VARCHAR2(400),
enabled_yn CHAR(1) DEFAULT 'Y' CHECK (enabled_yn IN ('Y','N')) NOT NULL
)
"""),
new TableDefinition("CB_COLUMN_MASKING_RULE", """
CREATE TABLE cb_column_masking_rule (
column_id NUMBER PRIMARY KEY,
rule_id NUMBER NOT NULL,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)
"""),
new TableDefinition("CB_USER_MASKING_RULE", """
CREATE TABLE cb_user_masking_rule (
user_id NUMBER NOT NULL,
column_id NUMBER NOT NULL,
decision VARCHAR2(10) NOT NULL CHECK (decision IN ('MASK','UNMASK')),
active_yn CHAR(1) DEFAULT 'Y' CHECK (active_yn IN ('Y','N')) NOT NULL,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT cb_user_masking_rule_pk PRIMARY KEY (user_id, column_id)
)
"""),
new TableDefinition("CB_ORDS_PROBE_AUDIT", """
CREATE TABLE cb_ords_probe_audit (
audit_id NUMBER PRIMARY KEY,
@@ -224,6 +251,25 @@ public class BackofficeSchemaService {
VALUES (src.setting_key, src.setting_value, SYSTIMESTAMP)
""";
private static final List<MaskingRuleSeed> DEFAULT_MASKING_RULES = List.of(
new MaskingRuleSeed("MASK_NULLIFY", "값 숨김 (NULL)", "NULLIFY",
"값을 NULL로 반환하는 기본 마스킹 방식"),
new MaskingRuleSeed("MASK_FULL", "전체 마스킹", "FULL",
"문자형은 공백, 숫자형은 0으로 반환하는 전체 마스킹 방식"),
new MaskingRuleSeed("MASK_TEXT_PARTIAL", "문자열 일부 마스킹", "TEXT_PARTIAL",
"첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식"),
new MaskingRuleSeed("MASK_RRN_PARTIAL", "주민등록번호 부분 마스킹", "RRN_PARTIAL",
"앞 6자리만 보이고 나머지는 가리는 식별번호 마스킹 방식")
);
private static final String MASKING_RULE_SEED_SQL = """
MERGE INTO cb_masking_rule dst
USING (SELECT ? rule_id, ? rule_code, ? rule_name, ? template_code, ? description FROM dual) src
ON (dst.rule_code = src.rule_code)
WHEN NOT MATCHED THEN INSERT (rule_id, rule_code, rule_name, template_code, description, enabled_yn)
VALUES (src.rule_id, src.rule_code, src.rule_name, src.template_code, src.description, 'Y')
""";
private final JdbcTemplate jdbcTemplate;
private final BackofficeProperties properties;
@@ -342,6 +388,7 @@ public class BackofficeSchemaService {
}
runDml(results, "CB_PROTECTED_COLUMN", "DATA", PROTECTED_COLUMN_MIGRATION_SQL,
"민감 컬럼 legacy 값을 보강했습니다.", "UPDATED");
seedDefaultMaskingRules(results);
seedDefaultSettings(results);
return results;
}
@@ -412,6 +459,28 @@ public class BackofficeSchemaService {
}
}
private void seedDefaultMaskingRules(List<SchemaActionResult> results) {
long nextRuleId;
try {
Long currentMax = jdbcTemplate.queryForObject("SELECT NVL(MAX(rule_id), 0) FROM cb_masking_rule", Long.class);
nextRuleId = currentMax == null ? 1L : currentMax + 1L;
} catch (RuntimeException exception) {
results.add(new SchemaActionResult("CB_MASKING_RULE", "MASKING_RULE", "FAILED",
safeMessage(exception), MASKING_RULE_SEED_SQL));
return;
}
for (MaskingRuleSeed seed : DEFAULT_MASKING_RULES) {
try {
jdbcTemplate.update(MASKING_RULE_SEED_SQL, nextRuleId++, seed.code(), seed.name(), seed.templateCode(), seed.description());
results.add(new SchemaActionResult(seed.code(), "MASKING_RULE", "MERGED",
"기본 마스킹 규칙을 확인했습니다.", MASKING_RULE_SEED_SQL));
} catch (RuntimeException exception) {
results.add(new SchemaActionResult(seed.code(), "MASKING_RULE", "FAILED",
safeMessage(exception), MASKING_RULE_SEED_SQL));
}
}
}
private String currentUser() {
return jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
}
@@ -623,6 +692,7 @@ public class BackofficeSchemaService {
appendSql(builder, column.ddl());
}
appendSql(builder, PROTECTED_COLUMN_MIGRATION_SQL);
appendSql(builder, MASKING_RULE_SEED_SQL.replace("?", "'<MASKING_RULE_VALUE>'"));
appendSql(builder, SETTINGS_MERGE_SQL.replace("?", "'<BACKOFFICE_ORDS_BASE_URL>'"));
return builder.toString();
}
@@ -635,6 +705,7 @@ public class BackofficeSchemaService {
@sql/adb/17_agent_ords_security_local_vpd_setup.sql
@sql/adb/25_agent_ords_security_backoffice_support.sql
@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
@sql/adb/62_kb_aso_masking_backoffice_metadata.sql
@sql/adb/21_agent_ords_security_ords_enable_schema.sql
-- 2. ORDS parsing schema로 접속
@@ -644,8 +715,11 @@ public class BackofficeSchemaService {
-- 3. 대표 권한 부여 SQL
CONNECT %s/<password>@<tns_alias>
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
GRANT EXECUTE ON cb_agent_can_read_column TO cb_ords;
GRANT SELECT ON <owner>.<table_or_view> TO cb_ords;
-- 4. 마스킹 규칙을 UI에서 컬럼에 연결한 뒤 실행
@sql/adb/64_kb_aso_masking_default_column_rules.sql
@sql/adb/63_kb_aso_masking_rule_runtime.sql
""".formatted(owner.toLowerCase());
}
@@ -693,4 +767,7 @@ public class BackofficeSchemaService {
private record ConstraintDefinition(String name, String table, String objectType) {
}
private record MaskingRuleSeed(String code, String name, String templateCode, String description) {
}
}

View File

@@ -1,37 +0,0 @@
package com.cloudhandson.vpdbackoffice.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
/**
* Keeps the shared user/role/permission services independent from the DDS app.
* The normal VPD application has no synchronizer. The DDS application provides
* one and receives the change before the management request returns.
*/
@Service
public class DdsAuthorizationChangeNotifier {
private static final Logger log = LoggerFactory.getLogger(DdsAuthorizationChangeNotifier.class);
private final ObjectProvider<DdsAuthorizationSynchronizer> synchronizer;
public DdsAuthorizationChangeNotifier(ObjectProvider<DdsAuthorizationSynchronizer> synchronizer) {
this.synchronizer = synchronizer;
}
private DdsAuthorizationChangeNotifier() {
this.synchronizer = null;
}
public static DdsAuthorizationChangeNotifier noop() {
return new DdsAuthorizationChangeNotifier();
}
public void changed(String reason) {
if (synchronizer != null) {
log.info("DDS authorization change published: {}", reason);
synchronizer.ifAvailable(target -> target.synchronize(reason));
}
}
}

View File

@@ -1,7 +0,0 @@
package com.cloudhandson.vpdbackoffice.service;
/** Optional bridge implemented only by the dedicated DDS application. */
public interface DdsAuthorizationSynchronizer {
void synchronize(String reason);
}

View File

@@ -0,0 +1,41 @@
package com.cloudhandson.vpdbackoffice.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
/**
* Keeps user/role/permission services independent from optional external
* authorization runtimes. The normal VPD application can run without a
* synchronizer; if one exists, it receives the change before the management
* request returns.
*/
@Service
public class ExternalAuthorizationChangeNotifier {
private static final Logger log = LoggerFactory.getLogger(ExternalAuthorizationChangeNotifier.class);
private final ObjectProvider<ExternalAuthorizationSynchronizer> synchronizer;
public ExternalAuthorizationChangeNotifier(ObjectProvider<ExternalAuthorizationSynchronizer> synchronizer) {
this.synchronizer = synchronizer;
}
private ExternalAuthorizationChangeNotifier() {
this.synchronizer = null;
}
public static ExternalAuthorizationChangeNotifier noop() {
return new ExternalAuthorizationChangeNotifier();
}
public void changed(String reason) {
if (synchronizer != null) {
ExternalAuthorizationSynchronizer target = synchronizer.getIfAvailable();
if (target != null) {
log.info("External authorization change synchronized: {}", reason);
target.synchronize(reason);
}
}
}
}

View File

@@ -0,0 +1,7 @@
package com.cloudhandson.vpdbackoffice.service;
/** Optional bridge implemented by an external authorization runtime. */
public interface ExternalAuthorizationSynchronizer {
void synchronize(String reason);
}

View File

@@ -16,21 +16,21 @@ public class GroupService {
private final GroupMapper groupMapper;
private final AuditService auditService;
private final DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier;
private final ExternalAuthorizationChangeNotifier authorizationChangeNotifier;
@Autowired
public GroupService(
GroupMapper groupMapper,
AuditService auditService,
DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier
ExternalAuthorizationChangeNotifier authorizationChangeNotifier
) {
this.groupMapper = groupMapper;
this.auditService = auditService;
this.ddsAuthorizationChangeNotifier = ddsAuthorizationChangeNotifier;
this.authorizationChangeNotifier = authorizationChangeNotifier;
}
public GroupService(GroupMapper groupMapper, AuditService auditService) {
this(groupMapper, auditService, DdsAuthorizationChangeNotifier.noop());
this(groupMapper, auditService, ExternalAuthorizationChangeNotifier.noop());
}
public List<AppGroup> findAll() {
@@ -50,7 +50,7 @@ public class GroupService {
long groupId = groupMapper.nextGroupId();
groupMapper.insertGroup(groupId, command);
auditService.record(new AuditEvent("GROUP_CREATED", null, null, "SUCCESS", null, null, command.groupCode()));
ddsAuthorizationChangeNotifier.changed("GROUP_CREATED");
authorizationChangeNotifier.changed("GROUP_CREATED");
}
@Transactional
@@ -69,7 +69,7 @@ public class GroupService {
}
auditService.record(new AuditEvent("GROUP_ACTIVE_CHANGED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",active=" + active));
ddsAuthorizationChangeNotifier.changed("GROUP_ACTIVE_CHANGED");
authorizationChangeNotifier.changed("GROUP_ACTIVE_CHANGED");
}
@Transactional
@@ -77,7 +77,7 @@ public class GroupService {
groupMapper.insertGroupUser(groupId, userId);
auditService.record(new AuditEvent("GROUP_USER_ADDED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",userId=" + userId));
ddsAuthorizationChangeNotifier.changed("GROUP_USER_ADDED");
authorizationChangeNotifier.changed("GROUP_USER_ADDED");
}
@Transactional
@@ -96,7 +96,7 @@ public class GroupService {
}
auditService.record(new AuditEvent("GROUP_USER_REMOVED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",userId=" + userId));
ddsAuthorizationChangeNotifier.changed("GROUP_USER_REMOVED");
authorizationChangeNotifier.changed("GROUP_USER_REMOVED");
}
@Transactional
@@ -104,7 +104,7 @@ public class GroupService {
groupMapper.insertGroupRole(groupId, roleId);
auditService.record(new AuditEvent("GROUP_ROLE_ADDED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",roleId=" + roleId));
ddsAuthorizationChangeNotifier.changed("GROUP_ROLE_ADDED");
authorizationChangeNotifier.changed("GROUP_ROLE_ADDED");
}
@Transactional
@@ -123,6 +123,6 @@ public class GroupService {
}
auditService.record(new AuditEvent("GROUP_ROLE_REMOVED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",roleId=" + roleId));
ddsAuthorizationChangeNotifier.changed("GROUP_ROLE_REMOVED");
authorizationChangeNotifier.changed("GROUP_ROLE_REMOVED");
}
}

View File

@@ -0,0 +1,359 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Reconciles the backoffice masking metadata with the managed Oracle
* Data Redaction policies. Metadata is the source of truth: a table with no
* active linked columns has its managed policy disabled, rather than silently
* retaining redaction after its UI configuration was removed.
*/
@Service
public class MaskingPolicySynchronizer {
private static final String OWNER = "POC_2";
private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
private static final Map<String, String> MANAGED_POLICIES = managedPolicyMap();
private final JdbcTemplate jdbcTemplate;
private final MaskingRuleMapper mapper;
public MaskingPolicySynchronizer(JdbcTemplate jdbcTemplate, MaskingRuleMapper mapper) {
this.jdbcTemplate = jdbcTemplate;
this.mapper = mapper;
}
private static Map<String, String> managedPolicyMap() {
Map<String, String> policies = new LinkedHashMap<>();
policies.put("KB_CUSTOMERS", "KB_CUSTOMER_PII_REDACT");
policies.put("KB_CLAIMS", "KB_CLAIM_AMOUNT_REDACT");
policies.put("KB_CONTRACTS", "KB_CONTRACT_PREMIUM_REDACT");
policies.put("KB_EXTERNAL_HOLDINGS", "KB_EXT_HOLDING_REDACT");
return Collections.unmodifiableMap(policies);
}
public Set<String> managedObjectNames() {
return MANAGED_POLICIES.keySet();
}
public boolean isManagedObject(String objectName) {
return objectName != null && MANAGED_POLICIES.containsKey(objectName.trim().toUpperCase(Locale.ROOT));
}
static String managedPolicyName(String objectName) {
return MANAGED_POLICIES.get(objectName);
}
/**
* Applies the current active column-rule metadata to managed DBMS_REDACT policies.
*
* <p>The backoffice metadata is the source of truth. In particular, when an object has no
* active linked column rule, its policy is disabled. This avoids an old redaction policy
* continuing to mask data after an operator removed every rule from the UI.</p>
*/
public MaskingPolicySyncResult synchronize() {
Map<String, List<ColumnMaskingRule>> desiredByObject = new LinkedHashMap<>();
for (ColumnMaskingRule rule : mapper.findColumnRules()) {
if (OWNER.equalsIgnoreCase(rule.owner())
&& rule.ruleEnabled()
&& MANAGED_POLICIES.containsKey(rule.objectName())) {
desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule);
}
}
int disabledPolicies = 0;
int enabledPolicies = 0;
int addedColumns = 0;
int modifiedColumns = 0;
int droppedColumns = 0;
for (Map.Entry<String, String> policy : MANAGED_POLICIES.entrySet()) {
String objectName = policy.getKey();
String policyName = policy.getValue();
List<ColumnMaskingRule> desired = desiredByObject.getOrDefault(objectName, List.of());
String enableStatus = policyEnableStatus(objectName, policyName);
if (desired.isEmpty()) {
if ("YES".equals(enableStatus)) {
disablePolicy(objectName, policyName);
disabledPolicies++;
}
continue;
}
boolean exists = enableStatus != null;
if (exists) {
if (!"YES".equals(enableStatus)) {
enablePolicy(objectName, policyName);
enabledPolicies++;
}
}
Set<String> desiredColumns = desired.stream()
.map(ColumnMaskingRule::columnName)
.map(this::requiredColumnName)
.collect(LinkedHashSet::new, Set::add, Set::addAll);
Set<String> actualColumns = exists
? new LinkedHashSet<>(redactionColumns(objectName))
: new LinkedHashSet<>();
for (String actualColumn : actualColumns) {
if (!desiredColumns.contains(actualColumn)) {
dropColumn(objectName, policyName, actualColumn);
droppedColumns++;
}
}
boolean firstColumn = !exists;
for (ColumnMaskingRule desiredColumn : desired) {
String columnName = requiredColumnName(desiredColumn.columnName());
if (firstColumn) {
addPolicy(objectName, policyName, columnName, desiredColumn.template());
firstColumn = false;
addedColumns++;
} else if (actualColumns.contains(columnName)) {
modifyColumn(objectName, policyName, columnName, desiredColumn.template());
modifiedColumns++;
} else {
addColumn(objectName, policyName, columnName, desiredColumn.template());
addedColumns++;
}
upsertColumnExpression(objectName, columnName, desiredColumn.columnId());
}
}
return new MaskingPolicySyncResult(
disabledPolicies, enabledPolicies, addedColumns, modifiedColumns, droppedColumns
);
}
private String policyEnableStatus(String objectName, String policyName) {
List<String> statuses = jdbcTemplate.queryForList("""
SELECT enable
FROM redaction_policies
WHERE object_owner = ? AND object_name = ? AND policy_name = ?
""", String.class, OWNER, objectName, policyName);
return statuses.isEmpty() ? null : statuses.getFirst();
}
private List<String> redactionColumns(String objectName) {
return jdbcTemplate.queryForList("""
SELECT column_name
FROM redaction_columns
WHERE object_owner = ? AND object_name = ?
""", String.class, OWNER, objectName).stream()
.map(this::requiredColumnName)
.toList();
}
private void disablePolicy(String objectName, String policyName) {
jdbcTemplate.update("""
BEGIN
DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
END;
""", OWNER, objectName, policyName);
}
private void enablePolicy(String objectName, String policyName) {
jdbcTemplate.update("""
BEGIN
DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
END;
""", OWNER, objectName, policyName);
}
private void dropColumn(String objectName, String policyName, String columnName) {
jdbcTemplate.update("""
BEGIN
DBMS_REDACT.ALTER_POLICY(
object_schema => ?, object_name => ?, policy_name => ?,
action => DBMS_REDACT.DROP_COLUMN, column_name => ?
);
END;
""", OWNER, objectName, policyName, columnName);
}
private void addPolicy(
String objectName, String policyName, String columnName, MaskingTemplate template
) {
callTemplate("ADD_POLICY", objectName, policyName, columnName, template);
}
private void addColumn(
String objectName, String policyName, String columnName, MaskingTemplate template
) {
callTemplate("ADD_COLUMN", objectName, policyName, columnName, template);
}
private void modifyColumn(
String objectName, String policyName, String columnName, MaskingTemplate template
) {
callTemplate("MODIFY_COLUMN", objectName, policyName, columnName, template);
}
/**
* Template choice is an enum, so DBMS_REDACT constants are rendered only from trusted source
* code. They cannot be supplied from a request parameter or backoffice table value.
*/
private void callTemplate(
String operation, String objectName, String policyName, String columnName, MaskingTemplate template
) {
String functionConstant = switch (template) {
case NULLIFY -> "DBMS_REDACT.NULLIFY";
case FULL -> "DBMS_REDACT.FULL";
case TEXT_PARTIAL, RRN_PARTIAL -> "DBMS_REDACT.REGEXP";
};
String actionConstant = switch (operation) {
case "ADD_POLICY" -> null;
case "ADD_COLUMN" -> "DBMS_REDACT.ADD_COLUMN";
case "MODIFY_COLUMN" -> "DBMS_REDACT.MODIFY_COLUMN";
default -> throw new IllegalArgumentException("Unsupported redaction operation");
};
String regexPattern = switch (template) {
case TEXT_PARTIAL -> "(^.).*$";
case RRN_PARTIAL -> "(^[0-9]{6})-?[0-9]{7}$";
default -> null;
};
String regexReplacement = switch (template) {
case TEXT_PARTIAL -> "\\1***";
case RRN_PARTIAL -> "\\1-*******";
default -> null;
};
if ("ADD_POLICY".equals(operation)) {
String sql = template == MaskingTemplate.TEXT_PARTIAL || template == MaskingTemplate.RRN_PARTIAL
? """
BEGIN
DBMS_REDACT.ADD_POLICY(
object_schema => ?, object_name => ?, policy_name => ?,
policy_description => 'Managed by VPD masking backoffice',
column_name => ?, function_type => %s, expression => '1=1',
regexp_pattern => ?, regexp_replace_string => ?, enable => TRUE
);
END;
""".formatted(functionConstant)
: """
BEGIN
DBMS_REDACT.ADD_POLICY(
object_schema => ?, object_name => ?, policy_name => ?,
policy_description => 'Managed by VPD masking backoffice',
column_name => ?, function_type => %s, expression => '1=1', enable => TRUE
);
END;
""".formatted(functionConstant);
if (regexPattern == null) {
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName);
} else {
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement);
}
return;
}
String sql = template == MaskingTemplate.TEXT_PARTIAL || template == MaskingTemplate.RRN_PARTIAL
? """
BEGIN
DBMS_REDACT.ALTER_POLICY(
object_schema => ?, object_name => ?, policy_name => ?, action => %s,
column_name => ?, function_type => %s, regexp_pattern => ?, regexp_replace_string => ?
);
END;
""".formatted(actionConstant, functionConstant)
: """
BEGIN
DBMS_REDACT.ALTER_POLICY(
object_schema => ?, object_name => ?, policy_name => ?, action => %s,
column_name => ?, function_type => %s
);
END;
""".formatted(actionConstant, functionConstant);
if (regexPattern == null) {
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName);
} else {
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement);
}
}
private void upsertColumnExpression(String objectName, String columnName, long columnId) {
String expressionName = "CBMR_" + columnId;
String expression = maskingExpression(columnId);
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM redaction_expressions WHERE policy_expression_name = ?
""", Integer.class, expressionName);
if (count != null && count > 0) {
jdbcTemplate.update("""
BEGIN
DBMS_REDACT.UPDATE_POLICY_EXPRESSION(
policy_expression_name => ?, expression => ?,
policy_expression_description => 'Mask unless trusted context allows original value'
);
END;
""", expressionName, expression);
} else {
jdbcTemplate.update("""
BEGIN
DBMS_REDACT.CREATE_POLICY_EXPRESSION(
policy_expression_name => ?, expression => ?,
policy_expression_description => 'Mask unless trusted context allows original value'
);
END;
""", expressionName, expression);
}
if (!expressionAppliedToColumn(expressionName, objectName, columnName)) {
jdbcTemplate.update("""
BEGIN
DBMS_REDACT.APPLY_POLICY_EXPR_TO_COL(
object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ?
);
END;
""", OWNER, objectName, columnName, expressionName);
}
}
static String maskingExpression(long columnId) {
return "SYS_CONTEXT('CB_AGENT_CTX', 'MR_" + columnId
+ "') IS NULL OR SYS_CONTEXT('CB_AGENT_CTX', 'MR_" + columnId + "') <> 'Y'";
}
private boolean expressionAppliedToColumn(String expressionName, String objectName, String columnName) {
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM redaction_expressions
WHERE policy_expression_name = ?
AND object_name = ?
AND column_name = ?
""", Integer.class, expressionName, objectName, columnName);
return count != null && count > 0;
}
private String requiredColumnName(String value) {
String normalized = value == null ? "" : value.trim().toUpperCase();
if (!COLUMN_NAME.matcher(normalized).matches()) {
throw new AppException("동기화할 보호 컬럼명이 유효하지 않습니다.");
}
return normalized;
}
public record MaskingPolicySyncResult(
int disabledPolicies,
int enabledPolicies,
int addedColumns,
int modifiedColumns,
int droppedColumns
) {
public String summary() {
return "DB ASO 정책 동기화 완료: 비활성 " + disabledPolicies + "건, 활성 " + enabledPolicies
+ "건, 컬럼 추가 " + addedColumns + "건, 변경 " + modifiedColumns + "건, 해제 "
+ droppedColumns + "";
}
}
}

View File

@@ -0,0 +1,223 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent;
import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class MaskingRuleService {
private static final Pattern RULE_CODE = Pattern.compile("[A-Z][A-Z0-9_]{2,63}");
private static final Set<String> DECISIONS = Set.of("MASK", "UNMASK");
private final MaskingRuleMapper mapper;
private final UserMapper userMapper;
private final ProtectedObjectService protectedObjectService;
private final AuditService auditService;
private final MaskingPolicySynchronizer maskingPolicySynchronizer;
public MaskingRuleService(
MaskingRuleMapper mapper,
UserMapper userMapper,
ProtectedObjectService protectedObjectService,
AuditService auditService,
MaskingPolicySynchronizer maskingPolicySynchronizer
) {
this.mapper = mapper;
this.userMapper = userMapper;
this.protectedObjectService = protectedObjectService;
this.auditService = auditService;
this.maskingPolicySynchronizer = maskingPolicySynchronizer;
}
public List<MaskingRule> findAllRules() {
return mapper.findAllRules();
}
public List<MaskingRule> findEnabledRules() {
return mapper.findEnabledRules();
}
public List<ColumnMaskingRule> findColumnRules() {
return mapper.findColumnRules();
}
/** Reads the actual Oracle Data Redaction state for the three managed KB objects. */
public List<MaskingPolicyStatus> findPolicyStatuses() {
return mapper.findPolicyStatuses();
}
public Set<String> managedObjectNames() {
return maskingPolicySynchronizer.managedObjectNames();
}
public List<UserMaskingRule> findUserRules() {
return mapper.findUserRules();
}
@Transactional
public void createRule(MaskingRuleCreateCommand command) {
String code = normalizeCode(command.ruleCode());
String name = normalizeRequired(command.ruleName(), 100, "규칙명");
String templateCode = normalizeTemplate(command.templateCode());
String description = normalizeOptional(command.description(), 400, "설명");
if (mapper.findRuleByCode(code) != null) {
throw new AppException("이미 등록된 컬럼 마스킹 규칙 코드입니다: " + code);
}
long ruleId = mapper.nextRuleId();
mapper.insertRule(ruleId, new MaskingRuleCreateCommand(code, name, templateCode, description));
auditService.record(new AuditEvent("MASKING_RULE_CREATED", null, null, "SUCCESS", null, null,
"ruleId=" + ruleId + ",code=" + code + ",template=" + templateCode));
}
@Transactional
public MaskingPolicySynchronizer.MaskingPolicySyncResult setRuleActive(long ruleId, boolean active) {
if (mapper.updateRuleActive(ruleId, active ? "Y" : "N") == 0) {
throw new AppException("컬럼 마스킹 규칙을 찾을 수 없습니다.");
}
auditService.record(new AuditEvent("MASKING_RULE_ACTIVE_CHANGED", null, null, "SUCCESS", null, null,
"ruleId=" + ruleId + ",active=" + active));
return synchronizeDatabasePolicies();
}
@Transactional
public MaskingPolicySynchronizer.MaskingPolicySyncResult assignRuleToColumn(long columnId, long ruleId) {
var column = protectedObjectService.findColumn(columnId);
if (column == null) {
throw new AppException("보호 컬럼을 찾을 수 없습니다.");
}
if (!column.sensitive()) {
throw new AppException("민감 표시 보호 컬럼에만 컬럼 마스킹 규칙을 연결할 수 있습니다.");
}
MaskingRule rule = requireEnabledRule(ruleId);
mapper.upsertColumnRule(columnId, ruleId);
auditService.record(new AuditEvent("COLUMN_MASKING_RULE_ASSIGNED", null, column.objectId(), "SUCCESS", null, null,
"columnId=" + columnId + ",rule=" + rule.ruleCode()));
return synchronizeDatabasePolicies();
}
@Transactional
public MaskingPolicySynchronizer.MaskingPolicySyncResult addTargetColumn(long objectId, String columnName) {
ProtectedObject object = protectedObjectService.assertEnabled(objectId);
if (!maskingPolicySynchronizer.isManagedObject(object.objectName())) {
throw new AppException("ASO 마스킹 정책 관리 대상 객체가 아닙니다: " + object.objectName());
}
var column = protectedObjectService.addSensitiveColumnTarget(objectId, columnName);
auditService.record(new AuditEvent("MASKING_TARGET_COLUMN_REGISTERED", null, objectId, "SUCCESS", null, null,
object.objectName() + "." + column.columnName()));
return synchronizeDatabasePolicies();
}
@Transactional
public MaskingPolicySynchronizer.MaskingPolicySyncResult removeRuleFromColumn(long columnId) {
mapper.deleteUserRulesForColumn(columnId);
if (mapper.deleteColumnRule(columnId) == 0) {
throw new AppException("해제할 컬럼 마스킹 규칙을 찾을 수 없습니다.");
}
auditService.record(new AuditEvent("COLUMN_MASKING_RULE_REMOVED", null, null, "SUCCESS", null, null,
"columnId=" + columnId));
return synchronizeDatabasePolicies();
}
/**
* Reconciles the current metadata with Oracle Data Redaction. This is exposed for the one-time
* repair of settings saved before automatic synchronization was introduced.
*/
@Transactional
public MaskingPolicySynchronizer.MaskingPolicySyncResult synchronizeDatabasePolicies() {
MaskingPolicySynchronizer.MaskingPolicySyncResult result = maskingPolicySynchronizer.synchronize();
auditService.record(new AuditEvent("MASKING_POLICY_SYNCHRONIZED", null, null, "SUCCESS", null, null,
"disabled=" + result.disabledPolicies() + ",enabled=" + result.enabledPolicies()
+ ",added=" + result.addedColumns() + ",modified=" + result.modifiedColumns()
+ ",dropped=" + result.droppedColumns()));
return result;
}
@Transactional
public void assignUserRule(long userId, long columnId, String decision) {
if (userMapper.findById(userId) == null) {
throw new AppException("사용자를 찾을 수 없습니다.");
}
ColumnMaskingRule columnRule = mapper.findColumnRule(columnId);
if (columnRule == null || !columnRule.ruleEnabled()) {
throw new AppException("먼저 활성 컬럼 마스킹 규칙을 민감 컬럼에 연결하세요.");
}
String normalizedDecision = normalizeDecision(decision);
mapper.upsertUserRule(userId, columnId, normalizedDecision);
auditService.record(new AuditEvent("USER_MASKING_RULE_ASSIGNED", userId, columnRule.objectId(), "SUCCESS", null, null,
"columnId=" + columnId + ",decision=" + normalizedDecision));
}
@Transactional
public void removeUserRule(long userId, long columnId) {
if (mapper.deleteUserRule(userId, columnId) == 0) {
throw new AppException("해제할 사용자별 컬럼 마스킹 규칙을 찾을 수 없습니다.");
}
auditService.record(new AuditEvent("USER_MASKING_RULE_REMOVED", userId, null, "SUCCESS", null, null,
"columnId=" + columnId));
}
private MaskingRule requireEnabledRule(long ruleId) {
MaskingRule rule = mapper.findRuleById(ruleId);
if (rule == null || !rule.enabled()) {
throw new AppException("활성 컬럼 마스킹 규칙을 선택하세요.");
}
return rule;
}
private String normalizeCode(String value) {
String normalized = normalizeRequired(value, 64, "규칙 코드").toUpperCase(Locale.ROOT);
if (!RULE_CODE.matcher(normalized).matches()) {
throw new AppException("규칙 코드는 영문 대문자·숫자·밑줄로 3~64자여야 합니다.");
}
return normalized;
}
private String normalizeTemplate(String value) {
try {
return MaskingTemplate.from(normalizeRequired(value, 30, "마스킹 템플릿")).code();
} catch (IllegalArgumentException exception) {
throw new AppException(exception.getMessage());
}
}
private String normalizeDecision(String value) {
String normalized = normalizeRequired(value, 10, "사용자별 적용 방식").toUpperCase(Locale.ROOT);
if (!DECISIONS.contains(normalized)) {
throw new AppException("사용자별 적용 방식은 MASK 또는 UNMASK만 가능합니다.");
}
return normalized;
}
private String normalizeRequired(String value, int maxLength, String label) {
String normalized = value == null ? "" : value.trim();
if (normalized.isEmpty()) {
throw new AppException(label + "은(는) 필수입니다.");
}
if (normalized.length() > maxLength) {
throw new AppException(label + "은(는) " + maxLength + "자 이내여야 합니다.");
}
return normalized;
}
private String normalizeOptional(String value, int maxLength, String label) {
String normalized = value == null ? "" : value.trim();
if (normalized.length() > maxLength) {
throw new AppException(label + "은(는) " + maxLength + "자 이내여야 합니다.");
}
return normalized.isEmpty() ? null : normalized;
}
}

View File

@@ -130,7 +130,7 @@ public class McpChatbotService {
선택한 tool: %s
라우팅 근거: %s
Bearer Token이 없어 ORDS tools/call은 실행하지 않았습니다. 토큰을 입력하면 실제 VPD/ORDS 결과까지 조회합니다.
Bearer Token이 없어 ORDS tools/call은 실행하지 않았습니다. 토큰을 입력하면 실제 ORDS 행 접근 결과까지 조회합니다.
""".formatted(tool.name(), routingReason);
}
@@ -141,13 +141,13 @@ public class McpChatbotService {
int rowCount = payload.path("rowCount").asInt(0);
JsonNode maskedColumns = payload.path("maskedColumns");
return """
질문을 MCP tool로 라우팅해 ORDS/VPD 결과를 조회했습니다.
질문을 MCP tool로 라우팅해 ORDS 행 접근 결과를 조회했습니다.
선택한 tool: %s
라우팅 근거: %s
ORDS 상태: %s
반환 행 수: %d
NULL 처리 컬럼: %s
ASO 마스킹 확인 컬럼: %s
질문: %s
""".formatted(tool.name(), routingReason, status, rowCount, maskedColumns.toString(), question);
@@ -155,7 +155,7 @@ public class McpChatbotService {
private String systemPrompt() {
return """
당신은 Oracle ORDS/VPD MCP 라우팅 결과를 설명하는 운영 보조자입니다.
당신은 Oracle ORDS 행 접근 MCP 라우팅 결과를 설명하는 운영 보조자입니다.
제공된 MCP tool 결과 JSON만 근거로 한국어로 간결하게 답변하세요.
Bearer Token 원문은 절대 출력하지 마세요.
""";
@@ -180,7 +180,7 @@ public class McpChatbotService {
답변 형식:
1. 한 문장 요약
2. 선택한 tool과 근거
3. 행 필터/컬럼 NULL 처리/오류 여부
3. 행 접근 필터(VPD)/ASO 컬럼 마스킹/오류 여부
4. 운영자가 다음에 확인할 것
""".formatted(question, tool.name(), tool.displayName(), tool.ordsPath(), routingReason, clientResult.toolsCallResponse());
}

View File

@@ -134,7 +134,7 @@ public class McpReasoningService {
private String buildPrompt(String question, McpToolView tool, String evidenceJson) {
String normalizedQuestion = question == null || question.isBlank()
? "요약부터 작성해줘. 이 ORDS/VPD 검증 결과에서 조회 행 수, 주요 식별자, NULL 처리 여부, 권한 범위, 다음 확인 조치를 정리해줘."
? "요약부터 작성해줘. 이 ORDS 행 접근 검증 결과에서 조회 행 수, 주요 식별자, ASO 마스킹 여부, 권한 범위, 다음 확인 조치를 정리해줘."
: question.trim();
return """
질문:
@@ -155,13 +155,13 @@ public class McpReasoningService {
- 그 다음 "## 판단 근거" 섹션에 표를 사용해 rowCount, maskedColumns, status, errorCode를 정리한다.
- 그 다음 "## 상세" 섹션에서 반환 행과 권한 범위를 설명한다.
- 마지막 "## 다음 조치" 섹션은 운영자가 확인할 항목만 짧게 쓴다.
- VPD 행 필터, 컬럼 NULL 처리, ORDS 오류 여부를 구분한다.
- 행 접근 필터(VPD), ASO 컬럼 마스킹, ORDS 오류 여부를 구분한다.
""".formatted(normalizedQuestion, tool.name(), tool.displayName(), tool.ordsPath(), evidenceJson);
}
private String systemPrompt() {
return """
당신은 Oracle ADB VPD/Redaction/ORDS 권한 검증 보조자입니다.
당신은 Oracle ADB 행 접근(VPD)/Redaction/ORDS 권한 검증 보조자입니다.
백오피스가 제공한 도구 실행 증거만 근거로 판단하고, 토큰 원문이나 비밀 값을 재출력하지 마세요.
""";
}

View File

@@ -1,8 +1,6 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
@@ -10,30 +8,41 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.List;
import org.springframework.stereotype.Service;
/** MCP boundary exposing only the row-access-aware GPT-5.4-mini Select AI query tool. */
@Service
public class McpSseService {
private static final String SELECT_AI_ROUTER_TOOL = "ords.agent.kb_select_ai_router";
private static final String SELECT_AI_ROUTER_PATH = "cb-ords/kb-select-ai-agent/run";
private static final String SELECT_AI_VPD_QUERY_TOOL = "ords.query.kb_select_ai_vpd";
private static final String SELECT_AI_VPD_QUERY_PATH = "cb-ords/kb-select-ai-vpd/query";
private static final String SELECT_AI_VPD_QUERY_PROFILE =
"KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1";
private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView(
SELECT_AI_VPD_QUERY_TOOL,
"GPT-5.4-mini Select AI 자연어 질의를 행 접근 컨텍스트로 실행합니다. 테이블/컬럼 comment, annotation, constraint 메타데이터를 사용하고 생성 SQL은 KB 업무 테이블의 읽기 전용 SELECT/WITH만 허용합니다.",
-1L,
"KB Select AI 행 접근 자연어 조회",
SELECT_AI_VPD_QUERY_PATH
);
private final McpToolRegistry toolRegistry;
private final OrdsProbeService ordsProbeService;
private final SelectAiAgentOrdsService selectAiAgentOrdsService;
private final ObjectMapper objectMapper;
public McpSseService(
McpToolRegistry toolRegistry,
OrdsProbeService ordsProbeService,
SelectAiAgentOrdsService selectAiAgentOrdsService,
ObjectMapper objectMapper
) {
this.toolRegistry = toolRegistry;
this.ordsProbeService = ordsProbeService;
this.selectAiAgentOrdsService = selectAiAgentOrdsService;
this.objectMapper = objectMapper;
}
public ObjectNode handle(String contextPath, JsonNode request) {
return handle(contextPath, request, "");
}
/**
* The HTTP bearer token is the business-user subject token; no separate MCP token is used.
*/
public ObjectNode handle(String contextPath, JsonNode request, String vpdBearerToken) {
ObjectNode response = objectMapper.createObjectNode();
response.put("jsonrpc", "2.0");
if (request != null && request.has("id")) {
@@ -41,12 +50,13 @@ public class McpSseService {
}
String method = request == null || !request.hasNonNull("method") ? "" : request.get("method").asText();
JsonNode parameters = request == null ? objectMapper.createObjectNode() : request.path("params");
try {
response.set("result", switch (method) {
case "initialize" -> initializeResult(contextPath);
case "notifications/initialized" -> objectMapper.createObjectNode();
case "tools/list" -> toolsListResult();
case "tools/call" -> toolsCallResult(request.path("params"));
case "tools/call" -> toolsCallResult(parameters, vpdBearerToken);
default -> throw new AppException("지원하지 않는 MCP method입니다: " + method);
});
} catch (Exception e) {
@@ -59,6 +69,11 @@ public class McpSseService {
return response;
}
/** The only tool registered by this MCP server. */
public List<McpToolView> registeredTools() {
return List.of(SELECT_AI_VPD_QUERY_VIEW);
}
private ObjectNode initializeResult(String contextPath) {
ObjectNode result = objectMapper.createObjectNode();
result.put("protocolVersion", "2024-11-05");
@@ -75,83 +90,35 @@ public class McpSseService {
private ObjectNode toolsListResult() {
ObjectNode result = objectMapper.createObjectNode();
ArrayNode tools = objectMapper.createArrayNode();
for (McpToolView tool : toolRegistry.listTools()) {
ObjectNode item = objectMapper.createObjectNode();
item.put("name", tool.name());
item.put("description", tool.description());
item.set("inputSchema", inputSchema(tool));
tools.add(item);
}
tools.add(selectAiRouterTool());
tools.add(selectAiVpdQueryTool());
result.set("tools", tools);
return result;
}
private ObjectNode inputSchema(McpToolView tool) {
private ObjectNode selectAiVpdQueryTool() {
ObjectNode item = objectMapper.createObjectNode();
item.put("name", SELECT_AI_VPD_QUERY_TOOL);
item.put("description", SELECT_AI_VPD_QUERY_VIEW.description());
ObjectNode schema = objectMapper.createObjectNode();
schema.put("type", "object");
ObjectNode properties = objectMapper.createObjectNode();
ObjectNode bearerToken = objectMapper.createObjectNode();
bearerToken.put("type", "string");
bearerToken.put("description", "ORDS 호출에 사용할 Bearer Token 원문");
properties.set("bearerToken", bearerToken);
ObjectNode prompt = objectMapper.createObjectNode();
prompt.put("type", "string");
prompt.put("description", "KB 업무 원장에 대해 조회할 내용을 자연어로 입력합니다.");
prompt.put("maxLength", 4000);
properties.set("prompt", prompt);
ObjectNode limit = objectMapper.createObjectNode();
limit.put("type", "integer");
limit.put("description", "조회 row 제한. 1부터 500까지 허용");
limit.put("description", "최대 반환 행 수. 1부터 100까지 허용하며 기본값은 50입니다.");
limit.put("minimum", 1);
limit.put("maximum", 500);
limit.put("maximum", 100);
properties.set("limit", limit);
schema.set("properties", properties);
ArrayNode required = objectMapper.createArrayNode();
required.add("bearerToken");
if (isVectorTool(tool)) {
ObjectNode embedding = objectMapper.createObjectNode();
embedding.put("type", "array");
embedding.put("description", "외부 임베딩 모델이 만든 검색 벡터. 개발 환경에서는 4차원 벡터를 사용합니다.");
ObjectNode items = objectMapper.createObjectNode();
items.put("type", "number");
embedding.set("items", items);
embedding.put("minItems", 1);
properties.set("embedding", embedding);
required.add("embedding");
}
schema.set("required", required);
schema.put("additionalProperties", false);
return schema;
}
private ObjectNode selectAiRouterTool() {
ObjectNode item = objectMapper.createObjectNode();
item.put("name", SELECT_AI_ROUTER_TOOL);
item.put("description", "Bearer Token으로 ORDS Select AI Team을 호출해 KB 원장 질의용 SQL을 생성합니다. Team의 VPD 컨텍스트가 적용됩니다.");
ObjectNode schema = objectMapper.createObjectNode();
schema.put("type", "object");
ObjectNode properties = objectMapper.createObjectNode();
ObjectNode bearerToken = objectMapper.createObjectNode();
bearerToken.put("type", "string");
bearerToken.put("description", "ORDS 호출에 사용할 Bearer Token 원문");
properties.set("bearerToken", bearerToken);
ObjectNode prompt = objectMapper.createObjectNode();
prompt.put("type", "string");
prompt.put("description", "KB 원장에 대해 생성할 SQL을 자연어로 요청합니다. 이 Team은 읽기 전용 SHOWSQL 생성만 허용합니다.");
prompt.put("maxLength", 8000);
properties.set("prompt", prompt);
ObjectNode conversationId = objectMapper.createObjectNode();
conversationId.put("type", "string");
conversationId.put("description", "선택값. 동일 대화 흐름을 이어갈 때 사용하는 안전한 식별자");
conversationId.put("pattern", "^[A-Za-z0-9._:-]{1,128}$");
properties.set("conversationId", conversationId);
schema.set("properties", properties);
ArrayNode required = objectMapper.createArrayNode();
required.add("bearerToken");
required.add("prompt");
schema.set("required", required);
schema.put("additionalProperties", false);
@@ -159,67 +126,32 @@ public class McpSseService {
return item;
}
private ObjectNode toolsCallResult(JsonNode params) {
private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) {
String toolName = params.path("name").asText("");
if (!SELECT_AI_VPD_QUERY_TOOL.equals(toolName)) {
throw new AppException("등록되지 않은 MCP tool입니다: " + toolName);
}
JsonNode arguments = params.path("arguments");
if (SELECT_AI_ROUTER_TOOL.equals(toolName)) {
return selectAiRouterCallResult(arguments);
String token = vpdBearerToken == null ? "" : vpdBearerToken.trim();
if (token.isBlank()) {
return tokenAccessDeniedResult();
}
McpToolView tool = findTool(toolName);
String bearerToken = arguments.path("bearerToken").asText("");
int limit = normalizeLimit(arguments.path("limit").asInt(50));
String requestBody = null;
if (isVectorTool(tool)) {
JsonNode embedding = arguments.get("embedding");
if (embedding == null || !embedding.isArray() || embedding.isEmpty()) {
throw new AppException("벡터 검색 tool에는 embedding 배열이 필요합니다.");
}
ObjectNode body = objectMapper.createObjectNode();
body.set("embedding", embedding);
requestBody = body.toString();
JsonNode response;
try {
response = selectAiAgentOrdsService.run(
token,
arguments.path("prompt").asText(""),
normalizeLimit(arguments.path("limit").asInt(50))
);
} catch (VpdTokenAccessDeniedException ignored) {
return tokenAccessDeniedResult();
}
ProbeResult probeResult = ordsProbeService.runProbe(
new ProbeCommand(null, tool.objectId(), bearerToken, limit, requestBody));
ObjectNode payload = objectMapper.createObjectNode();
payload.put("toolName", tool.name());
payload.put("objectId", tool.objectId());
payload.put("object", tool.displayName());
payload.put("ordsPath", tool.ordsPath());
payload.put("status", probeResult.status().name());
payload.put("rowCount", probeResult.rowCount());
payload.set("columns", objectMapper.valueToTree(probeResult.columns()));
payload.set("maskedColumns", objectMapper.valueToTree(probeResult.maskedColumns()));
payload.set("rows", objectMapper.valueToTree(probeResult.rows()));
payload.put("errorCode", probeResult.errorCode());
payload.put("errorMessage", probeResult.errorMessage());
payload.put("requestHeaders", probeResult.requestHeaders());
payload.put("requestPayload", probeResult.requestPayload());
payload.put("responseHeaders", probeResult.responseHeaders());
payload.put("responseBody", probeResult.responseBody());
ObjectNode result = objectMapper.createObjectNode();
ArrayNode content = objectMapper.createArrayNode();
ObjectNode text = objectMapper.createObjectNode();
text.put("type", "text");
text.put("text", pretty(payload));
content.add(text);
result.set("content", content);
result.put("isError", probeResult.errorCode() != null);
return result;
}
private ObjectNode selectAiRouterCallResult(JsonNode arguments) {
JsonNode response = selectAiAgentOrdsService.run(
arguments.path("bearerToken").asText(""),
arguments.path("prompt").asText(""),
arguments.path("conversationId").asText("")
);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("toolName", SELECT_AI_ROUTER_TOOL);
payload.put("team", "KB_SELECT_AI_ROUTER_TEAM");
payload.put("ordsPath", SELECT_AI_ROUTER_PATH);
payload.put("toolName", SELECT_AI_VPD_QUERY_TOOL);
payload.put("profile", SELECT_AI_VPD_QUERY_PROFILE);
payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH);
payload.set("response", response);
ObjectNode result = objectMapper.createObjectNode();
@@ -233,23 +165,27 @@ public class McpSseService {
return result;
}
private McpToolView findTool(String toolName) {
List<McpToolView> tools = toolRegistry.listTools();
return tools.stream()
.filter(tool -> tool.name().equals(toolName))
.findFirst()
.orElseThrow(() -> new AppException("MCP tool을 찾을 수 없습니다: " + toolName));
}
private ObjectNode tokenAccessDeniedResult() {
ObjectNode payload = objectMapper.createObjectNode();
payload.put("status", "VPD_TOKEN_DENIED");
payload.put("message", "토큰이 없거나 유효하지 않아 이 요청을 수행할 권한이 없습니다.");
private boolean isVectorTool(McpToolView tool) {
return tool != null && tool.displayName().toUpperCase().endsWith("CB_VECTOR_SEARCH_DOCUMENTS");
ObjectNode result = objectMapper.createObjectNode();
ArrayNode content = objectMapper.createArrayNode();
ObjectNode text = objectMapper.createObjectNode();
text.put("type", "text");
text.put("text", pretty(payload));
content.add(text);
result.set("content", content);
result.put("isError", true);
return result;
}
private int normalizeLimit(int limit) {
if (limit < 1) {
return 50;
}
return Math.min(limit, 500);
return Math.min(limit, 100);
}
private String pretty(Object value) {

View File

@@ -33,7 +33,7 @@ public class McpToolRegistry {
return new McpToolView(
name,
object.descriptionOrDefault() + " · "
+ object.displayName() + "을 Bearer Token으로 ORDS 호출해 VPD/표시 보호 결과를 조회합니다." + vectorHint,
+ object.displayName() + "을 Bearer Token으로 ORDS 호출해 행 접근/컬럼 표시 결과를 조회합니다." + vectorHint,
object.objectId(),
object.displayName(),
object.ordsPath()

View File

@@ -0,0 +1,231 @@
package com.cloudhandson.vpdbackoffice.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.oracle.bmc.auth.ConfigFileAuthenticationDetailsProvider;
import com.oracle.bmc.model.BmcException;
import com.oracle.bmc.generativeaiinference.GenerativeAiInferenceClient;
import com.oracle.bmc.generativeaiinference.model.ChatDetails;
import com.oracle.bmc.generativeaiinference.model.ChatChoice;
import com.oracle.bmc.generativeaiinference.model.BaseChatResponse;
import com.oracle.bmc.generativeaiinference.model.GenericChatRequest;
import com.oracle.bmc.generativeaiinference.model.GenericChatResponse;
import com.oracle.bmc.generativeaiinference.model.JsonSchemaResponseFormat;
import com.oracle.bmc.generativeaiinference.model.OnDemandServingMode;
import com.oracle.bmc.generativeaiinference.model.ResponseJsonSchema;
import com.oracle.bmc.generativeaiinference.model.SystemMessage;
import com.oracle.bmc.generativeaiinference.model.TextContent;
import com.oracle.bmc.generativeaiinference.model.UserMessage;
import com.oracle.bmc.generativeaiinference.requests.ChatRequest;
import com.oracle.bmc.generativeaiinference.responses.ChatResponse;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import org.springframework.core.env.Environment;
/**
* Calls OCI Generative AI through the operator-managed OCI configuration profile.
*
* <p>The application neither reads nor stores a private key itself. OCI's SDK
* reads the path/profile supplied through the service environment and signs the
* request. This client is intentionally limited to non-streaming text chat.
* It does not execute SQL or hand the model a database connection.</p>
*/
@Service
public class OciGenerativeAiChatClient {
private final Environment environment;
private final ObjectMapper objectMapper;
public OciGenerativeAiChatClient(Environment environment, ObjectMapper objectMapper) {
this.environment = environment;
this.objectMapper = objectMapper;
}
public boolean configured() {
return missingConfigurationNames().isEmpty();
}
/** Returns setting names only; no configured value or credential is ever exposed. */
public String configurationHint() {
List<String> missing = missingConfigurationNames();
return missing.isEmpty() ? "" : String.join(", ", missing);
}
private List<String> missingConfigurationNames() {
OciSettings settings = settings();
return Stream.of(
setting(settings.enabled(), "BACKOFFICE_AI_ENABLED=true"),
setting("oci".equalsIgnoreCase(settings.provider()), "BACKOFFICE_AI_PROVIDER=oci"),
setting(hasText(settings.ociConfigFile()), "BACKOFFICE_AI_OCI_CONFIG_FILE"),
setting(hasText(settings.ociProfile()), "BACKOFFICE_AI_OCI_PROFILE"),
setting(hasText(settings.ociRegion()), "BACKOFFICE_AI_OCI_REGION"),
setting(hasText(settings.ociCompartmentId()), "BACKOFFICE_AI_OCI_COMPARTMENT_ID"),
setting(hasText(settings.baseUrl()), "BACKOFFICE_AI_BASE_URL"),
setting(hasText(settings.model()), "BACKOFFICE_AI_MODEL"))
.filter(value -> value != null)
.toList();
}
public String modelName() {
return settings().model();
}
/** Sends a bounded, source-only request and returns the first textual choice. */
public String chat(String systemPrompt, String userPrompt) {
if (!configured()) {
throw new AppException("OCI AI 호출 설정이 없습니다.");
}
OciSettings ai = settings();
try (GenerativeAiInferenceClient client = GenerativeAiInferenceClient.builder()
.region(ai.ociRegion())
.build(new ConfigFileAuthenticationDetailsProvider(ai.ociConfigFile(), ai.ociProfile()))) {
client.setEndpoint(ai.baseUrl());
ChatResponse response = client.chat(ChatRequest.builder()
.chatDetails(ChatDetails.builder()
.compartmentId(ai.ociCompartmentId())
.servingMode(OnDemandServingMode.builder().modelId(ai.model()).build())
.chatRequest(GenericChatRequest.builder()
.messages(List.of(
SystemMessage.builder().content(List.of(text(systemPrompt))).build(),
UserMessage.builder().content(List.of(text(userPrompt))).build()))
// SQL source plus block-level commentary can exceed 1,200
// completion tokens. GPT-5.5 can consume reasoning tokens
// before producing text, so leave a bounded 4K response budget.
.maxCompletionTokens(4_096)
// GPT-5.5 rejects temperature. The verified PoC route sends
// no temperature and explicitly requests one non-streaming response.
.isStream(false)
// Mirror the working PoC route: force a named JSON field so
// GPT-5.5 returns final text rather than only reasoning output.
.responseFormat(explanationResponseFormat())
.build())
.build())
.build());
return extractText(response);
} catch (AppException exception) {
throw exception;
} catch (BmcException exception) {
// Keep the operational hint useful without returning OCI's raw body,
// request IDs, request content, or any authentication detail to a browser.
throw new AppException("OCI Generative AI 설명 호출에 실패했습니다 (HTTP "
+ exception.getStatusCode() + ").");
} catch (Exception exception) {
throw new AppException("OCI Generative AI 설명 호출에 실패했습니다 ("
+ exception.getClass().getSimpleName() + ").");
}
}
private TextContent text(String value) {
return TextContent.builder().text(value).build();
}
private String extractText(ChatResponse response) {
if (response == null || response.getChatResult() == null) {
throw new AppException("OCI Generative AI 응답 본문이 없습니다.");
}
BaseChatResponse baseResponse = response.getChatResult().getChatResponse();
if (!(baseResponse instanceof GenericChatResponse generic)) {
throw new AppException("OCI Generative AI 응답 형식이 예상과 다릅니다 ("
+ safeType(baseResponse) + ").");
}
if (generic.getChoices() == null || generic.getChoices().isEmpty()) {
throw new AppException("OCI Generative AI 응답에 선택 결과가 없습니다.");
}
ChatChoice choice = generic.getChoices().getFirst();
if (choice.getMessage() == null || choice.getMessage().getContent() == null) {
throw new AppException("OCI Generative AI 응답에 메시지 콘텐츠가 없습니다.");
}
List<?> contents = choice.getMessage().getContent();
String result = contents.stream()
.filter(TextContent.class::isInstance)
.map(TextContent.class::cast)
.map(TextContent::getText)
.filter(this::hasText)
.reduce("", String::concat);
if (result.isBlank()) {
throw new AppException("OCI Generative AI 응답에 텍스트가 없습니다 (콘텐츠: "
+ contents.stream().map(this::safeType).distinct().reduce((left, right) -> left + ", " + right)
.orElse("없음") + ", 종료: " + safeFinishReason(choice.getFinishReason()) + ").");
}
return explanationFromJson(result);
}
private JsonSchemaResponseFormat explanationResponseFormat() {
Map<String, Object> schema = Map.of(
"type", "object",
"properties", Map.of("explanation", Map.of("type", "string")),
"required", List.of("explanation"),
"additionalProperties", false
);
return JsonSchemaResponseFormat.builder()
.jsonSchema(ResponseJsonSchema.builder()
.name("security_sql_explanation")
.description("Markdown explanation for an approved read-only security SQL script")
.schema(schema)
.isStrict(true)
.build())
.build();
}
private String explanationFromJson(String json) {
try {
JsonNode explanation = objectMapper.readTree(json).path("explanation");
if (explanation.isTextual() && !explanation.asText().isBlank()) {
return explanation.asText();
}
} catch (Exception ignored) {
// Fall through to the safe, actionable message below. The model output
// is untrusted and is never echoed into an exception message.
}
throw new AppException("OCI Generative AI 응답에 explanation JSON 필드가 없습니다.");
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
private String setting(boolean configured, String name) {
return configured ? null : name;
}
private String safeType(Object value) {
return value == null ? "없음" : value.getClass().getSimpleName();
}
private String safeFinishReason(String value) {
return value == null || value.isBlank() ? "없음" : value.replaceAll("[^A-Za-z0-9_-]", "");
}
/**
* Reads the service environment directly. This is deliberate: OCI SDK API-key
* configuration is supplied by the operator's .env/EnvironmentFile, not by
* database data or a browser request. Values are never rendered or logged.
*/
private OciSettings settings() {
return new OciSettings(
Boolean.parseBoolean(environment.getProperty("BACKOFFICE_AI_ENABLED", "false")),
environment.getProperty("BACKOFFICE_AI_PROVIDER", ""),
environment.getProperty("BACKOFFICE_AI_BASE_URL", ""),
environment.getProperty("BACKOFFICE_AI_MODEL", ""),
environment.getProperty("BACKOFFICE_AI_OCI_CONFIG_FILE", ""),
environment.getProperty("BACKOFFICE_AI_OCI_PROFILE", ""),
environment.getProperty("BACKOFFICE_AI_OCI_REGION", ""),
environment.getProperty("BACKOFFICE_AI_OCI_COMPARTMENT_ID", "")
);
}
private record OciSettings(
boolean enabled,
String provider,
String baseUrl,
String model,
String ociConfigFile,
String ociProfile,
String ociRegion,
String ociCompartmentId
) {
}
}

View File

@@ -46,19 +46,19 @@ public class PermissionService {
private final PermissionMapper permissionMapper;
private final ProtectedObjectService protectedObjectService;
private final AuditService auditService;
private final DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier;
private final ExternalAuthorizationChangeNotifier authorizationChangeNotifier;
@Autowired
public PermissionService(
PermissionMapper permissionMapper,
ProtectedObjectService protectedObjectService,
AuditService auditService,
DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier
ExternalAuthorizationChangeNotifier authorizationChangeNotifier
) {
this.permissionMapper = permissionMapper;
this.protectedObjectService = protectedObjectService;
this.auditService = auditService;
this.ddsAuthorizationChangeNotifier = ddsAuthorizationChangeNotifier;
this.authorizationChangeNotifier = authorizationChangeNotifier;
}
public PermissionService(
@@ -66,7 +66,7 @@ public class PermissionService {
ProtectedObjectService protectedObjectService,
AuditService auditService
) {
this(permissionMapper, protectedObjectService, auditService, DdsAuthorizationChangeNotifier.noop());
this(permissionMapper, protectedObjectService, auditService, ExternalAuthorizationChangeNotifier.noop());
}
public List<AppRole> findRoles() {
@@ -90,7 +90,7 @@ public class PermissionService {
long roleId = permissionMapper.nextRoleId();
permissionMapper.insertRole(roleId, roleName.trim(), description, normalizeSensitivityLevel(maxSensitivityLevel));
auditService.record(new AuditEvent("ROLE_CREATED", null, null, "SUCCESS", null, null, roleName));
ddsAuthorizationChangeNotifier.changed("ROLE_CREATED");
authorizationChangeNotifier.changed("ROLE_CREATED");
}
@Transactional
@@ -102,7 +102,7 @@ public class PermissionService {
}
auditService.record(new AuditEvent("ROLE_MAX_SENSITIVITY_UPDATED", null, null, "SUCCESS", null, null,
"roleId=" + roleId + ", max=" + normalized));
ddsAuthorizationChangeNotifier.changed("ROLE_MAX_SENSITIVITY_UPDATED");
authorizationChangeNotifier.changed("ROLE_MAX_SENSITIVITY_UPDATED");
}
@Transactional
@@ -128,7 +128,7 @@ public class PermissionService {
throw new AppException("삭제할 역할을 찾을 수 없습니다.");
}
auditService.record(new AuditEvent("ROLE_DELETED", null, null, "SUCCESS", null, null, "roleId=" + roleId));
ddsAuthorizationChangeNotifier.changed("ROLE_DELETED");
authorizationChangeNotifier.changed("ROLE_DELETED");
}
@Transactional
@@ -143,7 +143,6 @@ public class PermissionService {
}
protectedObjectService.assertEnabled(command.objectId());
validateRules(command.objectId(), command.rules());
validateVisibleColumns(command.objectId(), command.visibleColumns());
Long existingId = permissionMapper.findPermissionId(command.roleId(), command.objectId());
long permissionId = existingId == null ? permissionMapper.nextPermissionId() : existingId;
@@ -167,17 +166,12 @@ public class PermissionService {
}
permissionMapper.deleteVisibleColumns(permissionId);
if (command.visibleColumns() != null) {
for (String columnName : command.visibleColumns()) {
permissionMapper.insertVisibleColumn(permissionId, columnName.trim().toUpperCase(Locale.ROOT));
}
}
auditService.record(new AuditEvent(
"PERMISSION_SAVED", null, command.objectId(), "SUCCESS", null, null,
"roleId=" + command.roleId()
));
ddsAuthorizationChangeNotifier.changed("PERMISSION_SAVED");
authorizationChangeNotifier.changed("PERMISSION_SAVED");
return new PermissionSet(permissionId, command.roleId(), command.objectId(), "SELECT", permissionEffect, List.of(), List.of());
}
@@ -203,7 +197,7 @@ public class PermissionService {
}
auditService.record(new AuditEvent("PERMISSION_DELETED", null, null, "SUCCESS", null, null,
"permissionId=" + permissionId));
ddsAuthorizationChangeNotifier.changed("PERMISSION_DELETED");
authorizationChangeNotifier.changed("PERMISSION_DELETED");
}
public int countPermissionsByObjectId(long objectId) {
@@ -305,21 +299,6 @@ public class PermissionService {
}
}
private void validateVisibleColumns(long objectId, List<String> visibleColumns) {
if (visibleColumns == null || visibleColumns.isEmpty()) {
return;
}
Set<String> allowed = new HashSet<>();
for (ProtectedColumn column : protectedObjectService.findColumns(objectId)) {
allowed.add(column.columnName().toUpperCase(Locale.ROOT));
}
for (String columnName : visibleColumns) {
if (!allowed.contains(columnName.trim().toUpperCase(Locale.ROOT))) {
throw new AppException("등록되지 않은 컬럼입니다: " + columnName);
}
}
}
private String normalize(String value) {
return clean(value).toUpperCase(Locale.ROOT);
}

View File

@@ -7,12 +7,17 @@ import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObjectCreateCommand;
import com.cloudhandson.vpdbackoffice.mapper.ProtectedObjectMapper;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -21,13 +26,17 @@ public class ProtectedObjectService {
private final ProtectedObjectMapper mapper;
private final AuditService auditService;
private volatile CacheEntry<List<DatabaseObjectOption>> databaseObjectsCache;
private final AtomicReference<CacheEntry<List<DatabaseObjectOption>>> databaseObjectsCache =
new AtomicReference<>();
private final Map<String, CacheEntry<List<String>>> databaseColumnsCache = new ConcurrentHashMap<>();
private final Map<Long, CacheEntry<List<ProtectedColumn>>> protectedColumnsCache = new ConcurrentHashMap<>();
private static final long CATALOG_CACHE_MILLIS = 60_000L;
// Object and column metadata changes only through this service, which clears
// the affected cache entries. Keep dictionary metadata warm between screens.
private static final long CATALOG_CACHE_MILLIS = 15 * 60_000L;
private static final Set<String> SENSITIVITY_LEVELS = Set.of(
"PUBLIC", "INTERNAL", "CONFIDENTIAL", "RESTRICTED");
private static final Set<String> REDACTION_METHODS = Set.of("NONE", "NULLIFY", "PARTIAL", "FULL");
private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
public ProtectedObjectService(ProtectedObjectMapper mapper, AuditService auditService) {
this.mapper = mapper;
@@ -43,12 +52,12 @@ public class ProtectedObjectService {
}
public List<DatabaseObjectOption> findDatabaseObjects() {
CacheEntry<List<DatabaseObjectOption>> cached = databaseObjectsCache;
CacheEntry<List<DatabaseObjectOption>> cached = databaseObjectsCache.get();
if (cached != null && !cached.expired()) {
return cached.value();
}
List<DatabaseObjectOption> objects = List.copyOf(mapper.findDatabaseObjects());
databaseObjectsCache = new CacheEntry<>(objects, System.currentTimeMillis() + CATALOG_CACHE_MILLIS);
databaseObjectsCache.set(new CacheEntry<>(objects, System.currentTimeMillis() + CATALOG_CACHE_MILLIS));
return objects;
}
@@ -70,7 +79,7 @@ public class ProtectedObjectService {
}
if (isLegacyAutoPath(object.ordsPath(), object.owner(), object.objectName())) {
mapper.updateOrdsPath(object.objectId(), defaultOrdsPath(object.owner(), object.objectName()));
databaseObjectsCache = null;
databaseObjectsCache.set(null);
return mapper.findById(object.objectId());
}
return object;
@@ -86,6 +95,83 @@ public class ProtectedObjectService {
return columns;
}
/**
* Loads protected-object columns in one round trip. This is used by the
* permissions page, where requesting each object's columns independently
* turns a single page render into an ADB N+1 query pattern.
*/
public Map<Long, List<ProtectedColumn>> findColumnsByObjectIds(Collection<Long> objectIds) {
List<Long> requestedIds = objectIds.stream()
.filter(java.util.Objects::nonNull)
.distinct()
.toList();
if (requestedIds.isEmpty()) {
return Map.of();
}
List<Long> missingIds = new ArrayList<>();
Map<Long, List<ProtectedColumn>> result = new LinkedHashMap<>();
for (Long objectId : requestedIds) {
CacheEntry<List<ProtectedColumn>> cached = protectedColumnsCache.get(objectId);
if (cached != null && !cached.expired()) {
result.put(objectId, cached.value());
} else {
missingIds.add(objectId);
}
}
if (!missingIds.isEmpty()) {
Map<Long, List<ProtectedColumn>> loaded = new LinkedHashMap<>();
for (ProtectedColumn column : mapper.findColumnsByObjectIds(missingIds)) {
loaded.computeIfAbsent(column.objectId(), ignored -> new ArrayList<>()).add(column);
}
long expiresAt = System.currentTimeMillis() + CATALOG_CACHE_MILLIS;
for (Long objectId : missingIds) {
List<ProtectedColumn> columns = List.copyOf(loaded.getOrDefault(objectId, List.of()));
protectedColumnsCache.put(objectId, new CacheEntry<>(columns, expiresAt));
result.put(objectId, columns);
}
}
return Map.copyOf(result);
}
public ProtectedColumn findColumn(long columnId) {
return mapper.findColumnById(columnId);
}
@Transactional
public ProtectedColumn addSensitiveColumnTarget(long objectId, String columnName) {
ProtectedObject object = assertEnabled(objectId);
String normalizedColumnName = normalizeColumnName(columnName);
Set<String> databaseColumns = new HashSet<>();
for (String databaseColumn : findDatabaseColumns(object.owner(), object.objectName())) {
databaseColumns.add(databaseColumn.toUpperCase(Locale.ROOT));
}
if (!databaseColumns.contains(normalizedColumnName)) {
throw new AppException("DB 객체에 존재하지 않는 컬럼입니다: "
+ object.owner() + "." + object.objectName() + "." + normalizedColumnName);
}
ProtectedColumn existing = mapper.findColumnByObjectAndName(objectId, normalizedColumnName);
if (existing != null) {
if (existing.sensitive()) {
return existing;
}
mapper.updateColumnPolicy(existing.columnId(), "CONFIDENTIAL", "FULL");
protectedColumnsCache.remove(objectId);
auditService.record(new AuditEvent("PROTECTED_COLUMN_MASKING_TARGET_ENABLED", null, objectId, "SUCCESS", null,
null, normalizedColumnName));
return mapper.findColumnById(existing.columnId());
}
long columnId = mapper.nextColumnId();
mapper.insertColumn(columnId, objectId, normalizedColumnName, "Y", "CONFIDENTIAL", "FULL");
protectedColumnsCache.remove(objectId);
auditService.record(new AuditEvent("PROTECTED_COLUMN_MASKING_TARGET_ADDED", null, objectId, "SUCCESS", null, null,
normalizedColumnName));
return mapper.findColumnById(columnId);
}
@Transactional
public void createObject(ProtectedObjectCreateCommand command) {
ProtectedObjectCreateCommand normalized = normalizeCreateCommand(command);
@@ -97,7 +183,7 @@ public class ProtectedObjectService {
mapper.insertColumn(mapper.nextColumnId(), objectId, column, sensitiveYn,
defaultSensitivityLevel(sensitiveYn), defaultRedactionMethod(sensitiveYn));
}
databaseObjectsCache = null;
databaseObjectsCache.set(null);
protectedColumnsCache.remove(objectId);
auditService.record(new AuditEvent("PROTECTED_OBJECT_CREATED", null, objectId, "SUCCESS", null, null,
normalized.objectName()));
@@ -114,7 +200,7 @@ public class ProtectedObjectService {
if (isLegacyAutoPath(existing.ordsPath(), normalizedOwner, normalizedObjectName)) {
mapper.updateOrdsPath(existing.objectId(), defaultOrdsPath(normalizedOwner, normalizedObjectName));
}
databaseObjectsCache = null;
databaseObjectsCache.set(null);
protectedColumnsCache.remove(existing.objectId());
auditService.record(new AuditEvent("PROTECTED_OBJECT_RE_ENABLED", null, existing.objectId(), "SUCCESS", null,
null, existing.displayName()));
@@ -122,7 +208,7 @@ public class ProtectedObjectService {
}
if (isLegacyAutoPath(existing.ordsPath(), normalizedOwner, normalizedObjectName)) {
mapper.updateOrdsPath(existing.objectId(), defaultOrdsPath(normalizedOwner, normalizedObjectName));
databaseObjectsCache = null;
databaseObjectsCache.set(null);
return mapper.findById(existing.objectId());
}
return existing;
@@ -145,7 +231,7 @@ public class ProtectedObjectService {
for (String column : columns) {
mapper.insertColumn(mapper.nextColumnId(), objectId, column, "N", "PUBLIC", "NONE");
}
databaseObjectsCache = null;
databaseObjectsCache.set(null);
protectedColumnsCache.remove(objectId);
auditService.record(new AuditEvent("PROTECTED_OBJECT_AUTO_CREATED", null, objectId, "SUCCESS", null, null,
command.objectName()));
@@ -210,7 +296,7 @@ public class ProtectedObjectService {
if (updated == 0) {
throw new AppException("설명을 수정할 조회 대상을 찾을 수 없습니다.");
}
databaseObjectsCache = null;
databaseObjectsCache.set(null);
auditService.record(new AuditEvent("PROTECTED_OBJECT_DESCRIPTION_UPDATED", null, objectId, "SUCCESS", null, null,
normalized));
}
@@ -237,7 +323,7 @@ public class ProtectedObjectService {
if (updated == 0) {
throw new AppException("보호 객체를 찾을 수 없습니다.");
}
databaseObjectsCache = null;
databaseObjectsCache.set(null);
protectedColumnsCache.remove(objectId);
auditService.record(new AuditEvent("PROTECTED_OBJECT_DISABLED", null, objectId, "SUCCESS", null, null, null));
}
@@ -273,6 +359,14 @@ public class ProtectedObjectService {
return normalized;
}
private String normalizeColumnName(String value) {
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
if (!COLUMN_NAME.matcher(normalized).matches()) {
throw new AppException("컬럼명은 영문 대문자·숫자·밑줄로 된 DB 컬럼명이어야 합니다.");
}
return normalized;
}
private record CacheEntry<T>(T value, long expiresAt) {
boolean expired() {

View File

@@ -0,0 +1,268 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView;
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Pattern;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SchemaMetadataService {
private static final String OWNER = "POC_2";
private static final int MAX_COMMENT_LENGTH = 4000;
private static final int MAX_ANNOTATION_VALUE_LENGTH = 4000;
private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
private final JdbcTemplate jdbcTemplate;
private final StructuredDataService structuredDataService;
public SchemaMetadataService(JdbcTemplate jdbcTemplate, StructuredDataService structuredDataService) {
this.jdbcTemplate = jdbcTemplate;
this.structuredDataService = structuredDataService;
}
public List<StructuredDataTable> tables() {
return structuredDataService.tables();
}
public String defaultKey() {
return structuredDataService.defaultKey();
}
public SchemaMetadataView find(String tableKey) {
StructuredDataTable table = structuredDataService.requireTable(tableKey);
String tableName = table.tableName();
String tableComment = tableComment(tableName);
Map<String, List<SchemaAnnotation>> annotations = annotationsByTarget(tableName);
List<SchemaMetadataColumn> columns = columns(tableName, annotations);
return new SchemaMetadataView(
table,
nullToEmpty(tableComment),
annotations.getOrDefault(tableTargetKey(), List.of()),
columns
);
}
@Transactional
public void updateTableComment(String tableKey, String comment) {
StructuredDataTable table = structuredDataService.requireTable(tableKey);
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment");
jdbcTemplate.execute("COMMENT ON TABLE " + qualifiedTable(table.tableName())
+ " IS " + quoteLiteral(normalizedComment));
}
@Transactional
public void updateColumnComment(String tableKey, String columnName, String comment) {
StructuredDataTable table = structuredDataService.requireTable(tableKey);
String column = requireColumn(table.tableName(), columnName);
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment");
jdbcTemplate.execute("COMMENT ON COLUMN " + qualifiedTable(table.tableName()) + "."
+ quoteName(column) + " IS " + quoteLiteral(normalizedComment));
}
@Transactional
public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) {
StructuredDataTable table = structuredDataService.requireTable(tableKey);
updateAnnotation(table.tableName(), null, annotationName, annotationValue);
}
@Transactional
public void updateColumnAnnotation(
String tableKey,
String columnName,
String annotationName,
String annotationValue
) {
StructuredDataTable table = structuredDataService.requireTable(tableKey);
String column = requireColumn(table.tableName(), columnName);
updateAnnotation(table.tableName(), column, annotationName, annotationValue);
}
private void updateAnnotation(
String tableName,
String columnName,
String annotationName,
String annotationValue
) {
String key = requireSimpleName(annotationName, "annotation name");
String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value");
if (annotationExists(tableName, columnName, key)) {
jdbcTemplate.execute(annotationSql(tableName, columnName, "DROP " + quoteName(key)));
}
if (!value.isBlank()) {
jdbcTemplate.execute(annotationSql(tableName, columnName,
"ADD " + quoteName(key) + " " + quoteLiteral(value)));
}
}
private String tableComment(String tableName) {
List<String> values = jdbcTemplate.query("""
SELECT comments
FROM all_tab_comments
WHERE owner = ?
AND table_name = ?
""", (rs, rowNum) -> rs.getString(1), OWNER, tableName);
return values.isEmpty() ? "" : values.getFirst();
}
private List<SchemaMetadataColumn> columns(
String tableName,
Map<String, List<SchemaAnnotation>> annotations
) {
return jdbcTemplate.query("""
SELECT c.column_name,
CASE
WHEN c.data_type IN ('VARCHAR2', 'CHAR', 'NVARCHAR2', 'NCHAR')
THEN c.data_type || '(' || c.char_length || ')'
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL AND c.data_scale IS NOT NULL
THEN c.data_type || '(' || c.data_precision || ',' || c.data_scale || ')'
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL
THEN c.data_type || '(' || c.data_precision || ')'
ELSE c.data_type
END AS display_type,
c.nullable,
cc.comments
FROM all_tab_columns c
LEFT JOIN all_col_comments cc
ON cc.owner = c.owner
AND cc.table_name = c.table_name
AND cc.column_name = c.column_name
WHERE c.owner = ?
AND c.table_name = ?
ORDER BY c.column_id
""", (rs, rowNum) -> new SchemaMetadataColumn(
rs.getString("column_name"),
rs.getString("display_type"),
"Y".equalsIgnoreCase(rs.getString("nullable")),
nullToEmpty(rs.getString("comments")),
annotations.getOrDefault(columnTargetKey(rs.getString("column_name")), List.of())
), OWNER, tableName);
}
private Map<String, List<SchemaAnnotation>> annotationsByTarget(String tableName) {
Map<String, LinkedHashMap<String, List<String>>> grouped = new LinkedHashMap<>();
jdbcTemplate.query("""
SELECT column_name, annotation_name, annotation_value
FROM all_annotations_usage
WHERE object_name = ?
AND object_type = 'TABLE'
ORDER BY column_name NULLS FIRST, annotation_name, annotation_value
""", rs -> {
String target = rs.getString("column_name") == null
? tableTargetKey()
: columnTargetKey(rs.getString("column_name"));
grouped
.computeIfAbsent(target, ignored -> new LinkedHashMap<>())
.computeIfAbsent(rs.getString("annotation_name"), ignored -> new ArrayList<>())
.add(nullToEmpty(rs.getString("annotation_value")));
}, tableName);
Map<String, List<SchemaAnnotation>> result = new LinkedHashMap<>();
grouped.forEach((target, valuesByName) -> {
List<SchemaAnnotation> annotations = new ArrayList<>();
valuesByName.forEach((name, values) -> annotations.add(new SchemaAnnotation(
name,
String.join("\n--- duplicate annotation value ---\n", values)
)));
result.put(target, annotations);
});
return result;
}
private boolean annotationExists(String tableName, String columnName, String annotationName) {
Integer count = columnName == null
? jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM all_annotations_usage
WHERE object_name = ?
AND object_type = 'TABLE'
AND annotation_name = ?
AND column_name IS NULL
""", Integer.class, tableName, annotationName)
: jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM all_annotations_usage
WHERE object_name = ?
AND object_type = 'TABLE'
AND annotation_name = ?
AND column_name = ?
""", Integer.class, tableName, annotationName, columnName);
return count != null && count > 0;
}
private String annotationSql(String tableName, String columnName, String operation) {
if (columnName == null) {
return "ALTER TABLE " + qualifiedTable(tableName) + " ANNOTATIONS (" + operation + ")";
}
return "ALTER TABLE " + qualifiedTable(tableName) + " MODIFY " + quoteName(columnName)
+ " ANNOTATIONS (" + operation + ")";
}
private String requireColumn(String tableName, String columnName) {
String column = requireSimpleName(columnName, "column name");
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM all_tab_columns
WHERE owner = ?
AND table_name = ?
AND column_name = ?
""", Integer.class, OWNER, tableName, column);
if (count == null || count == 0) {
throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다.");
}
return column;
}
private String requireSimpleName(String value, String label) {
if (value == null || value.isBlank()) {
throw new AppException(label + "은(는) 필수입니다.");
}
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!ORACLE_SIMPLE_NAME.matcher(normalized).matches()) {
throw new AppException(label + " 형식이 올바르지 않습니다. 영문 대문자, 숫자, _, $, #만 사용할 수 있습니다.");
}
return normalized;
}
private String normalizeText(String value, int maxLength, String label) {
String normalized = value == null ? "" : value.trim();
if (normalized.length() > maxLength) {
throw new AppException(label + "은(는) " + maxLength + "자 이하여야 합니다.");
}
return normalized;
}
private String qualifiedTable(String tableName) {
return quoteName(OWNER) + "." + quoteName(requireSimpleName(tableName, "table name"));
}
private String quoteName(String value) {
return "\"" + value.replace("\"", "\"\"") + "\"";
}
private String quoteLiteral(String value) {
return "'" + value.replace("'", "''") + "'";
}
private String nullToEmpty(String value) {
return value == null ? "" : value;
}
private String tableTargetKey() {
return "<TABLE>";
}
private String columnTargetKey(String columnName) {
return requireSimpleName(columnName, "column name");
}
}

View File

@@ -0,0 +1,136 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript;
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptExplanation;
import java.util.stream.IntStream;
import org.springframework.stereotype.Service;
/**
* Sends a selected, read-only SQL source to the configured LLM for explanation.
* The source comes from SecuritySqlScriptService's fixed catalogue; neither a
* request value nor an LLM response can modify or execute database SQL.
*/
@Service
public class SecuritySqlScriptExplanationService {
private final SecuritySqlScriptService scriptService;
private final OciGenerativeAiChatClient aiClient;
public SecuritySqlScriptExplanationService(
SecuritySqlScriptService scriptService,
OciGenerativeAiChatClient aiClient
) {
this.scriptService = scriptService;
this.aiClient = aiClient;
}
public SecuritySqlScriptExplanation explain(String scriptId) {
SecuritySqlScript script = scriptService.find(scriptId);
String prompt = buildPrompt(script);
if (!aiClient.configured()) {
return new SecuritySqlScriptExplanation(
"AI_NOT_CONFIGURED",
aiClient.modelName(),
"OCI AI 호출 설정이 없어 설명을 생성하지 않았습니다. 누락 또는 불일치: "
+ aiClient.configurationHint(),
prompt,
script
);
}
try {
return new SecuritySqlScriptExplanation(
"SUCCESS",
aiClient.modelName(),
aiClient.chat(systemPrompt(), prompt),
prompt,
script
);
} catch (AppException exception) {
return new SecuritySqlScriptExplanation(
"AI_CALL_FAILED",
aiClient.modelName(),
exception.getMessage(),
prompt,
script
);
} catch (Exception exception) {
return new SecuritySqlScriptExplanation(
"AI_CALL_FAILED",
aiClient.modelName(),
"AI 설명 호출에 실패했습니다. SQL 원문은 변경되지 않았으며, 잠시 후 다시 시도하세요.",
prompt,
script
);
}
}
private String systemPrompt() {
return """
당신은 Oracle 보안 운영 SQL을 검토하는 선임 데이터베이스 보안 엔지니어다.
제공된 source만 근거로 한국어 Markdown 설명을 작성한다. SQL을 실행·수정·제안된 명령으로 바꾸지 않는다.
source에 없는 객체·권한·실행 결과를 추측하지 않는다. 비밀값을 요청하거나 출력하지 않는다.
최종 응답은 반드시 explanation 키 하나에 Markdown 문자열을 담은 JSON 객체로 반환한다.
""";
}
private String buildPrompt(SecuritySqlScript script) {
return """
다음은 Git 형상에 저장된 Oracle 보안 SQL 스크립트다. 운영자가 코드를 이해할 수 있도록 전체 설명과 부분별 주석을 작성한다.
[스크립트 메타데이터]
- 분류: %s
- 파일: %s
- 제목: %s
- 용도: %s
[줄 번호가 붙은 SQL 원문]
%s
[출력 형식]
## 전체 설명
- 이 스크립트가 만드는/변경하는 DB 객체와 목적을 5줄 이내로 설명한다.
- 실행 전제조건, 실행 사용자, 다른 스크립트와의 순서가 source에 있으면 명시한다.
## 실행 흐름
- source의 실제 실행 순서를 번호 목록으로 정리한다.
## 블록별 주석
- 주석 heading, PROMPT, CREATE/ALTER/MERGE/GRANT/DECLARE/BEGIN, PACKAGE, PROCEDURE, FUNCTION 단위로 블록을 나눈다.
- 각 블록은 반드시 `### [L시작-L끝] 블록명` 제목으로 시작한다.
- 각 블록에서 “무엇을 하는지”, “입력/참조 객체”, “보안·VPD·ASO 영향”, “실패/주의점”을 source 근거가 있는 범위에서 bullet로 적는다.
## 토큰 처리·사용자 적용 흐름
- source에 Bearer/Authorization/auth_header/token/key 또는 token을 받아 context를 설정하는 코드가 있으면, 반드시 “입력 → 검증/조회 → CB_AGENT_CTX 등 context 설정 → VPD/ASO/Select AI 적용 → 실패 시 동작” 순서로 설명한다.
- 각 단계에는 source line range와 실제 식별자(예: auth_header, set_vpd_context, SYS_CONTEXT)를 붙인다.
- source가 토큰을 직접 다루지 않는 메타데이터/초기값 스크립트라면 “이 스크립트는 토큰을 직접 검증하거나 사용자별 접근을 판정하지 않는다”라고 명시하고, source 주석/호출 관계에서 확인되는 다음 런타임 단계를 설명한다.
- VPD는 행 접근, ASO/DBMS_REDACT는 컬럼 표시 보호라는 점을 source 근거가 있는 범위에서 구분한다.
- 실제 어떤 사용자가 어떤 행·원문 컬럼을 볼지는 토큰으로 설정된 DB context와 당시 권한 데이터에 따라 확정된다고 구분한다.
- source에 없는 역할명, 사용자명, 권한 결과는 만들지 않는다.
## 운영 확인 포인트
- source에서 직접 확인 가능한 DB 객체·권한·정책·ORDS endpoint를 최대 7개로 정리한다.
## 판단 한계
- source만으로 확인할 수 없는 실행 결과나 권한 효과가 있으면 명시한다.
[엄격한 규칙]
- Markdown으로 120줄 이내에 작성한다.
- 줄 번호와 SQL 객체명은 제공된 source와 일치해야 한다.
- 일반론이나 추측은 쓰지 않는다.
""".formatted(
script.category(),
script.fileName(),
script.title(),
script.description(),
numberedSource(script.source())
);
}
private String numberedSource(String source) {
String[] lines = source.split("\\R", -1);
return IntStream.range(0, lines.length)
.mapToObj(index -> "%4d | %s".formatted(index + 1, lines[index]))
.reduce((left, right) -> left + "\n" + right)
.orElse("");
}
}

View File

@@ -0,0 +1,102 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript;
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
/**
* Read-only catalogue of security deployment SQL bundled from the Git-tracked
* sql/adb directory. Script ids are an application whitelist: request input
* never becomes a filesystem or classpath path.
*/
@Service
public class SecuritySqlScriptService {
private static final List<ScriptDefinition> CURATED_SCRIPTS = List.of(
new ScriptDefinition(
"aso-masking-metadata",
"ASO / 마스킹",
"62_kb_aso_masking_backoffice_metadata.sql",
"컬럼 마스킹 규칙 메타데이터",
"ASO 컬럼 마스킹 규칙·컬럼 연결·사용자 예외를 관리하는 백오피스 메타데이터를 생성합니다."
),
new ScriptDefinition(
"aso-masking-runtime",
"ASO / 마스킹",
"63_kb_aso_masking_rule_runtime.sql",
"ASO 마스킹 런타임 적용",
"백오피스 컬럼 마스킹 규칙을 Oracle Data Redaction 정책과 신뢰 컨텍스트에 반영합니다."
),
new ScriptDefinition(
"aso-masking-default-columns",
"ASO / 마스킹",
"64_kb_aso_masking_default_column_rules.sql",
"ASO 기본 대상 컬럼",
"주민번호·청구/지급금·타사보유 컬럼의 마스킹 블랙리스트 초기값을 연결합니다."
),
new ScriptDefinition(
"select-ai-vpd-api",
"Select AI / 행 접근",
"65_kb_select_ai_vpd_query_api.sql",
"행 접근 적용 Select AI 조회 API",
"생성 SQL을 KB 업무 테이블의 단일 읽기 전용 SELECT/WITH로 검증해 행 접근 컨텍스트에서 실행합니다."
),
new ScriptDefinition(
"select-ai-vpd-ords",
"ORDS / Select AI",
"66_kb_select_ai_vpd_query_ords.sql",
"Select AI 행 접근 ORDS Endpoint",
"Bearer 토큰을 검증해 행 접근 컨텍스트를 설정한 뒤 Select AI 조회 API를 노출합니다."
)
);
public List<SecuritySqlScriptSummary> list() {
return CURATED_SCRIPTS.stream()
.map(definition -> new SecuritySqlScriptSummary(
definition.scriptId(),
definition.category(),
definition.fileName(),
definition.title(),
definition.description()
))
.toList();
}
public SecuritySqlScript find(String scriptId) {
ScriptDefinition definition = CURATED_SCRIPTS.stream()
.filter(candidate -> candidate.scriptId().equals(scriptId))
.findFirst()
.orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다."));
return new SecuritySqlScript(
definition.scriptId(),
definition.category(),
definition.fileName(),
definition.title(),
definition.description(),
readSource(definition.fileName())
);
}
private String readSource(String fileName) {
ClassPathResource resource = new ClassPathResource("sql/adb/" + fileName);
try (InputStream input = resource.getInputStream()) {
return new String(input.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException exception) {
throw new AppException("배포된 보안 SQL 스크립트를 읽을 수 없습니다: " + fileName);
}
}
private record ScriptDefinition(
String scriptId,
String category,
String fileName,
String title,
String description
) {
}
}

View File

@@ -5,24 +5,25 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.net.URI;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
/** Calls the ORDS boundary; the database endpoint owns bearer-to-VPD-context mapping. */
/** Calls the ORDS boundary; the database endpoint owns bearer-to-row-access-context mapping. */
@Service
public class SelectAiAgentOrdsService {
static final String ORDS_PATH = "/cb-ords/kb-select-ai-agent/run";
private static final int MAX_PROMPT_LENGTH = 8_000;
static final String ORDS_PATH = "/cb-ords/kb-select-ai-vpd/query";
private static final int MAX_PROMPT_LENGTH = 4_000;
private static final int MAX_LIMIT = 100;
private final SettingService settingService;
private final RestTemplate restTemplate;
@@ -30,21 +31,29 @@ public class SelectAiAgentOrdsService {
public SelectAiAgentOrdsService(
SettingService settingService,
@Qualifier("ordsAgentRestTemplate") RestTemplate restTemplate,
RestTemplate ordsAgentRestTemplate,
ObjectMapper objectMapper
) {
this.settingService = settingService;
this.restTemplate = restTemplate;
this.restTemplate = ordsAgentRestTemplate;
this.objectMapper = objectMapper;
}
/**
* Compatibility overload for callers of the former SQL-generation tool.
* The row-access query endpoint is stateless and therefore ignores conversationId.
*/
public JsonNode run(String bearerToken, String prompt, String conversationId) {
return run(bearerToken, prompt, 50);
}
public JsonNode run(String bearerToken, String prompt, int limit) {
String normalizedToken = required(bearerToken, "bearerToken");
String normalizedPrompt = required(prompt, "prompt");
if (normalizedPrompt.length() > MAX_PROMPT_LENGTH) {
throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다.");
}
String normalizedConversationId = normalizeConversationId(conversationId);
int normalizedLimit = normalizeLimit(limit);
String baseUrl = settingService.ordsBaseUrl();
if (baseUrl == null || baseUrl.isBlank()) {
throw new AppException("ORDS base URL이 설정되지 않았습니다.");
@@ -52,9 +61,7 @@ public class SelectAiAgentOrdsService {
ObjectNode requestBody = objectMapper.createObjectNode();
requestBody.put("prompt", normalizedPrompt);
if (normalizedConversationId != null) {
requestBody.put("conversationId", normalizedConversationId);
}
requestBody.put("limit", normalizedLimit);
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(normalizedToken);
@@ -70,14 +77,18 @@ public class SelectAiAgentOrdsService {
);
JsonNode body = parse(response.getBody());
if (body.hasNonNull("error")) {
throw new AppException("Select AI Agent ORDS 오류: " + body.path("error").asText());
throw new AppException("Select AI 행 접근 ORDS 오류: " + body.path("error").asText());
}
return body;
} catch (HttpStatusCodeException e) {
throw new AppException("Select AI Agent ORDS HTTP " + e.getStatusCode().value()
if (e.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)
|| e.getStatusCode().isSameCodeAs(HttpStatus.FORBIDDEN)) {
throw new VpdTokenAccessDeniedException();
}
throw new AppException("Select AI 행 접근 ORDS HTTP " + e.getStatusCode().value()
+ ": " + responseError(e.getResponseBodyAsString()));
} catch (ResourceAccessException e) {
throw new AppException("Select AI Agent ORDS 연결 또는 응답 시간 초과: " + e.getMessage());
throw new AppException("Select AI 행 접근 ORDS 연결 또는 응답 시간 초과: " + e.getMessage());
}
}
@@ -91,13 +102,13 @@ public class SelectAiAgentOrdsService {
private JsonNode parse(String value) {
try {
if (value == null || value.isBlank()) {
throw new AppException("Select AI Agent ORDS 응답 본문이 비어 있습니다.");
throw new AppException("Select AI 행 접근 ORDS 응답 본문이 비어 있습니다.");
}
return objectMapper.readTree(value);
} catch (AppException e) {
throw e;
} catch (Exception e) {
throw new AppException("Select AI Agent ORDS 응답 JSON 파싱 실패: " + e.getMessage());
throw new AppException("Select AI 행 접근 ORDS 응답 JSON 파싱 실패: " + e.getMessage());
}
}
@@ -117,14 +128,10 @@ public class SelectAiAgentOrdsService {
return value.trim();
}
private String normalizeConversationId(String value) {
if (value == null || value.isBlank()) {
return null;
private int normalizeLimit(int value) {
if (value < 1) {
return 50;
}
String normalized = value.trim();
if (!normalized.matches("[A-Za-z0-9._:-]{1,128}")) {
throw new AppException("conversationId는 영문/숫자/._:-만 사용하고 128자 이하여야 합니다.");
}
return normalized;
return Math.min(value, MAX_LIMIT);
}
}

View File

@@ -60,10 +60,27 @@ public class StructuredDataService {
}
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT * FROM " + OWNER + "." + table.tableName() + " WHERE ROWNUM <= ?", ROW_LIMIT);
previewSql(table), ROW_LIMIT);
return new StructuredDataPreview(table, columns, rows, ROW_LIMIT);
} catch (DataAccessException exception) {
throw new AppException("정형 데이터를 조회할 수 없습니다. POC_2 조회 권한과 대상 테이블 상태를 확인하세요.");
}
}
/**
* The table is selected from a closed application whitelist, so the query
* text remains fixed and no request value can become a SQL identifier.
*/
private String previewSql(StructuredDataTable table) {
return switch (table.key()) {
case "customers" -> "SELECT * FROM POC_2.KB_CUSTOMERS WHERE ROWNUM <= ?";
case "products" -> "SELECT * FROM POC_2.KB_PRODUCTS WHERE ROWNUM <= ?";
case "contracts" -> "SELECT * FROM POC_2.KB_CONTRACTS WHERE ROWNUM <= ?";
case "coverages" -> "SELECT * FROM POC_2.KB_COVERAGES WHERE ROWNUM <= ?";
case "claims" -> "SELECT * FROM POC_2.KB_CLAIMS WHERE ROWNUM <= ?";
case "external-holdings" -> "SELECT * FROM POC_2.KB_EXTERNAL_HOLDINGS WHERE ROWNUM <= ?";
case "stakeholders" -> "SELECT * FROM POC_2.KB_STAKEHOLDERS WHERE ROWNUM <= ?";
default -> throw new AppException("선택할 수 없는 정형 데이터 테이블입니다.");
};
}
}

View File

@@ -15,21 +15,21 @@ public class UserService {
private final UserMapper userMapper;
private final AuditService auditService;
private final DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier;
private final ExternalAuthorizationChangeNotifier authorizationChangeNotifier;
@Autowired
public UserService(
UserMapper userMapper,
AuditService auditService,
DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier
ExternalAuthorizationChangeNotifier authorizationChangeNotifier
) {
this.userMapper = userMapper;
this.auditService = auditService;
this.ddsAuthorizationChangeNotifier = ddsAuthorizationChangeNotifier;
this.authorizationChangeNotifier = authorizationChangeNotifier;
}
public UserService(UserMapper userMapper, AuditService auditService) {
this(userMapper, auditService, DdsAuthorizationChangeNotifier.noop());
this(userMapper, auditService, ExternalAuthorizationChangeNotifier.noop());
}
public List<AppUser> findAll() {
@@ -45,7 +45,7 @@ public class UserService {
long userId = userMapper.nextUserId();
userMapper.insertUser(userId, command);
auditService.record(new AuditEvent("USER_CREATED", null, null, "SUCCESS", null, null, command.username()));
ddsAuthorizationChangeNotifier.changed("USER_CREATED");
authorizationChangeNotifier.changed("USER_CREATED");
}
@Transactional
@@ -56,7 +56,7 @@ public class UserService {
}
auditService.record(new AuditEvent("USER_ACTIVE_CHANGED", null, null, "SUCCESS", null, null,
"userId=" + userId + ",active=" + active));
ddsAuthorizationChangeNotifier.changed("USER_ACTIVE_CHANGED");
authorizationChangeNotifier.changed("USER_ACTIVE_CHANGED");
}
@Transactional
@@ -64,7 +64,7 @@ public class UserService {
userMapper.insertUserRole(userId, roleId);
auditService.record(new AuditEvent("USER_ROLE_GRANTED", null, null, "SUCCESS", null, null,
"userId=" + userId + ",roleId=" + roleId));
ddsAuthorizationChangeNotifier.changed("USER_ROLE_GRANTED");
authorizationChangeNotifier.changed("USER_ROLE_GRANTED");
}
@Transactional
@@ -75,6 +75,6 @@ public class UserService {
}
auditService.record(new AuditEvent("USER_ROLE_REVOKED", null, null, "SUCCESS", null, null,
"userId=" + userId + ",roleId=" + roleId));
ddsAuthorizationChangeNotifier.changed("USER_ROLE_REVOKED");
authorizationChangeNotifier.changed("USER_ROLE_REVOKED");
}
}

View File

@@ -1,6 +1,7 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdBulkApplyResult;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdDescriptionNote;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdFunctionOption;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdFunctionSource;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdObjectFilterDetail;
@@ -20,6 +21,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.dao.DataAccessException;
@@ -31,9 +33,54 @@ import org.springframework.transaction.annotation.Transactional;
public class VpdPolicyService {
private static final Set<String> ALLOWED_STATEMENTS = Set.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX");
private static final long CATALOG_CACHE_MILLIS = 60_000L;
// ALL_* dictionary views are comparatively expensive in Autonomous Database.
// Mutating VPD operations call clearCatalogCache(), so a longer read cache does
// not delay an administrator's own changes from appearing in the UI.
private static final long CATALOG_CACHE_MILLIS = 15 * 60_000L;
private static final String COMMON_POLICY_NAME = "CB_PERMISSION_SELECT_POLICY";
private static final String DEFAULT_PERMISSION_FILTER_FUNCTION = "CB_AGENT_DOC_VPD_FILTER";
/*
* DBMS_RLS.ADD_POLICY accepts PL/SQL BOOLEAN arguments. Keep all four
* permitted flag combinations as fixed statements: database object names
* and function references are JDBC bind values, and no request value is
* ever interpolated into executable SQL or PL/SQL source.
*/
private static final String ADD_POLICY_ENABLED_WITH_UPDATE_CHECK = """
BEGIN
DBMS_RLS.ADD_POLICY(
object_schema => ?, object_name => ?, policy_name => ?,
function_schema => ?, policy_function => ?, statement_types => ?,
update_check => TRUE, enable => TRUE, policy_type => DBMS_RLS.DYNAMIC
);
END;
""";
private static final String ADD_POLICY_ENABLED_WITHOUT_UPDATE_CHECK = """
BEGIN
DBMS_RLS.ADD_POLICY(
object_schema => ?, object_name => ?, policy_name => ?,
function_schema => ?, policy_function => ?, statement_types => ?,
update_check => FALSE, enable => TRUE, policy_type => DBMS_RLS.DYNAMIC
);
END;
""";
private static final String ADD_POLICY_DISABLED_WITH_UPDATE_CHECK = """
BEGIN
DBMS_RLS.ADD_POLICY(
object_schema => ?, object_name => ?, policy_name => ?,
function_schema => ?, policy_function => ?, statement_types => ?,
update_check => TRUE, enable => FALSE, policy_type => DBMS_RLS.DYNAMIC
);
END;
""";
private static final String ADD_POLICY_DISABLED_WITHOUT_UPDATE_CHECK = """
BEGIN
DBMS_RLS.ADD_POLICY(
object_schema => ?, object_name => ?, policy_name => ?,
function_schema => ?, policy_function => ?, statement_types => ?,
update_check => FALSE, enable => FALSE, policy_type => DBMS_RLS.DYNAMIC
);
END;
""";
private static final Pattern RETURN_LITERAL = Pattern.compile(
"(?is)\\bRETURN\\s+'((?:''|[^'])*)'\\s*;"
);
@@ -42,7 +89,8 @@ public class VpdPolicyService {
private final JdbcTemplate jdbcTemplate;
private final OpenAiCompatibleClient aiClient;
private final Map<String, CacheEntry<List<VpdTargetView>>> vpdTargetsCache = new ConcurrentHashMap<>();
private volatile CacheEntry<VpdPolicyFormOptions> formOptionsCache;
private final AtomicReference<CacheEntry<VpdPolicyFormOptions>> formOptionsCache =
new AtomicReference<>();
public VpdPolicyService(VpdPolicyMapper mapper, JdbcTemplate jdbcTemplate, OpenAiCompatibleClient aiClient) {
this.mapper = mapper;
@@ -71,7 +119,7 @@ public class VpdPolicyService {
}
public VpdPolicyFormOptions formOptions() {
CacheEntry<VpdPolicyFormOptions> cached = formOptionsCache;
CacheEntry<VpdPolicyFormOptions> cached = formOptionsCache.get();
if (cached != null && !cached.expired()) {
return cached.value();
}
@@ -84,7 +132,7 @@ public class VpdPolicyService {
buildPolicyTemplateOptions(functions, mapper.findPolicyTemplateOptions()),
List.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX")
);
formOptionsCache = new CacheEntry<>(options, System.currentTimeMillis() + CATALOG_CACHE_MILLIS);
formOptionsCache.set(new CacheEntry<>(options, System.currentTimeMillis() + CATALOG_CACHE_MILLIS));
return options;
}
@@ -108,7 +156,7 @@ public class VpdPolicyService {
String functionName = requiredIdentifier(functionNameValue, "Function name");
if (DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(functionName)) {
throw new AppException("기본 동적 권한 필터 " + DEFAULT_PERMISSION_FILTER_FUNCTION
+ "는 이 화면에서 수정할 수 없습니다. 권한체계는 사용자·그룹·역할·권한 규칙 화면에서 변경하세요.");
+ "는 이 화면에서 수정할 수 없습니다. 권한체계는 사용자·그룹·역할·행 접근 규칙 화면에서 변경하세요.");
}
String currentUser = jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
String functionOwner = functionOwnerValue == null || functionOwnerValue.isBlank()
@@ -292,7 +340,7 @@ public class VpdPolicyService {
description = null;
}
return description == null || description.isBlank()
? objectOwner + "." + objectName + "에 요청마다 현재 권한체계의 행 접근 조건을 적용하는 " + policyName + " policy입니다."
? objectOwner + "." + objectName + "에 요청마다 현재 행 접근 규칙의 조건을 적용하는 " + policyName + " policy입니다."
: description;
}
@@ -310,6 +358,18 @@ public class VpdPolicyService {
: description;
}
/**
* Retrieves all UI descriptions in one round trip. The policy screen used to
* issue one ADB query for every displayed policy and filter.
*/
public Map<String, String> findPolicyDescriptionMap() {
return descriptionMap(mapper.findPolicyDescriptions());
}
public Map<String, String> findFilterDescriptionMap() {
return descriptionMap(mapper.findFilterDescriptions());
}
/**
* Returns the literal predicate used by a simple standalone Filter function created by this UI.
* Packaged or system-managed functions intentionally return an empty string because their
@@ -414,7 +474,17 @@ public class VpdPolicyService {
public void clearCatalogCache() {
vpdTargetsCache.clear();
formOptionsCache = null;
formOptionsCache.set(null);
}
private Map<String, String> descriptionMap(List<VpdDescriptionNote> notes) {
Map<String, String> descriptions = new LinkedHashMap<>();
for (VpdDescriptionNote note : notes) {
if (note.noteKey() != null && note.description() != null && !note.description().isBlank()) {
descriptions.put(note.noteKey(), note.description());
}
}
return descriptions;
}
private List<VpdPolicyTemplateOption> buildPolicyTemplateOptions(
@@ -453,21 +523,7 @@ public class VpdPolicyService {
boolean enabled,
boolean updateCheck
) {
jdbcTemplate.update("""
BEGIN
DBMS_RLS.ADD_POLICY(
object_schema => ?,
object_name => ?,
policy_name => ?,
function_schema => ?,
policy_function => ?,
statement_types => ?,
update_check => %s,
enable => %s,
policy_type => DBMS_RLS.DYNAMIC
);
END;
""".formatted(updateCheck ? "TRUE" : "FALSE", enabled ? "TRUE" : "FALSE"),
jdbcTemplate.update(addPolicySql(enabled, updateCheck),
objectOwner,
objectName,
policyName,
@@ -476,6 +532,17 @@ public class VpdPolicyService {
statementTypes);
}
private String addPolicySql(boolean enabled, boolean updateCheck) {
if (enabled) {
return updateCheck
? ADD_POLICY_ENABLED_WITH_UPDATE_CHECK
: ADD_POLICY_ENABLED_WITHOUT_UPDATE_CHECK;
}
return updateCheck
? ADD_POLICY_DISABLED_WITH_UPDATE_CHECK
: ADD_POLICY_DISABLED_WITHOUT_UPDATE_CHECK;
}
public VpdPolicyExplanation explainPolicy(String objectOwner, String objectName, String policyName) {
VpdPolicyDetail detail = findPolicyDetail(objectOwner, objectName, policyName);
VpdPolicyView policy = detail.policy();
@@ -538,7 +605,7 @@ public class VpdPolicyService {
## 요약
- 이 policy가 무엇을 허용/차단하는지 3줄 이내로 먼저 설명한다.
- fail-closed 조건이 있으면 요약에 포함한다.
- 컬럼 마스킹/NULL 처리 판단 가능 여부를 요약에 포함한다.
- 컬럼 마스킹은 ASO/Data Redaction 별도 정책에서 판단한다. 이 VPD source만으로 알 수 있는 행 접근 범위와, 컬럼 마스킹 판단 가능 여부를 구분한다.
## 상세
@@ -557,7 +624,7 @@ public class VpdPolicyService {
### 4. 실제 접근 결과 해석
- 이 policy가 행(row)을 허용하는 조건과 제외하는 조건을 구분한다.
- 컬럼 마스킹/NULL 처리는 source에 직접 있지 않으면 "이 policy source만으로는 판단 불가"라고 쓴다.
- 컬럼 마스킹은 ASO/Data Redaction 별도 정책이다. source에 직접 있지 않으면 " VPD policy source만으로는 컬럼 마스킹 판단 불가"라고 쓴다.
### 5. 운영 확인 포인트
- 운영자가 DB에서 확인할 테이블/컬럼/컨텍스트 값을 5개 이하로 적는다.
@@ -628,6 +695,14 @@ public class VpdPolicyService {
}
private void createFilterFunction(String functionName, String filterPredicate) {
/*
* Oracle DDL cannot bind an object identifier or a function body. This
* is therefore deliberately the sole dynamic-DDL boundary in this
* service. functionName passed here has already gone through
* requiredIdentifier() ([A-Z][A-Z0-9_$#]{0,127}); filterPredicate is put
* inside one SQL literal after every quote is doubled. Those two checks
* prevent a caller from terminating the statement or adding DDL.
*/
jdbcTemplate.execute("""
CREATE OR REPLACE FUNCTION %s(
p_schema_name IN VARCHAR2,

View File

@@ -0,0 +1,9 @@
package com.cloudhandson.vpdbackoffice.service;
/** Raised when ORDS rejects a missing, invalid, expired, or unauthorized user bearer token. */
public class VpdTokenAccessDeniedException extends AppException {
public VpdTokenAccessDeniedException() {
super("사용자 Bearer Token이 없거나 유효하지 않아 이 요청을 수행할 권한이 없습니다.");
}
}

View File

@@ -1,13 +1,23 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class LoginController {
private final BackofficeProperties properties;
public LoginController(BackofficeProperties properties) {
this.properties = properties;
}
@GetMapping("/login")
public String login() {
public String login(Model model) {
model.addAttribute("rememberMeAvailable", properties.security().rememberMeConfigured());
model.addAttribute("rememberMeDays", properties.security().rememberMeDays());
return "login";
}
}

View File

@@ -0,0 +1,178 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.MaskingRuleService;
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
public class MaskingRuleController {
private final MaskingRuleService maskingRuleService;
private final ProtectedObjectService protectedObjectService;
public MaskingRuleController(
MaskingRuleService maskingRuleService,
ProtectedObjectService protectedObjectService
) {
this.maskingRuleService = maskingRuleService;
this.protectedObjectService = protectedObjectService;
}
@GetMapping("/masking-rules")
public String maskingRules(Model model) {
var objects = protectedObjectService.findEnabled();
var managedObjectNames = maskingRuleService.managedObjectNames();
var policyStatuses = maskingRuleService.findPolicyStatuses();
var columnsByObject = protectedObjectService.findColumnsByObjectIds(
objects.stream().map(object -> object.objectId()).toList()
);
var maskingTargetObjects = objects.stream()
.filter(object -> managedObjectNames.contains(object.objectName()))
.toList();
model.addAttribute("rules", maskingRuleService.findAllRules());
model.addAttribute("templates", Arrays.asList(MaskingTemplate.values()));
model.addAttribute("columnRules", maskingRuleService.findColumnRules());
model.addAttribute("policyStatuses", policyStatuses);
model.addAttribute("policyStatusByObjectName", policyStatuses.stream().collect(Collectors.toMap(
status -> status.objectName(),
status -> status,
(left, right) -> left
)));
model.addAttribute("objects", objects);
model.addAttribute("maskingTargetObjects", maskingTargetObjects);
model.addAttribute("sensitiveColumnsByObject", objects.stream().collect(Collectors.toMap(
object -> object.objectId(),
object -> columnsByObject.getOrDefault(object.objectId(), List.of()).stream()
.filter(column -> column.sensitive())
.toList()
)));
model.addAttribute("availableMaskingColumnsByObject", maskingTargetObjects.stream().collect(Collectors.toMap(
object -> object.objectId(),
object -> availableMaskingColumns(object, columnsByObject.getOrDefault(object.objectId(), List.of()))
)));
return "masking-rules";
}
@PostMapping("/masking-rules")
public String createRule(
@RequestParam String ruleCode,
@RequestParam String ruleName,
@RequestParam String templateCode,
@RequestParam(required = false) String description,
RedirectAttributes redirectAttributes
) {
try {
maskingRuleService.createRule(new MaskingRuleCreateCommand(ruleCode, ruleName, templateCode, description));
redirectAttributes.addFlashAttribute("message", "컬럼 마스킹 규칙을 등록했습니다.");
} catch (AppException | DataAccessException exception) {
redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception));
}
return "redirect:/masking-rules";
}
@PostMapping("/masking-rules/active")
public String setRuleActive(
@RequestParam long ruleId,
@RequestParam boolean active,
RedirectAttributes redirectAttributes
) {
try {
var result = maskingRuleService.setRuleActive(ruleId, active);
redirectAttributes.addFlashAttribute("message", (active ? "컬럼 마스킹 규칙을 활성화했습니다. " : "컬럼 마스킹 규칙을 비활성화했습니다. ")
+ result.summary());
} catch (AppException | DataAccessException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
}
return "redirect:/masking-rules";
}
@PostMapping("/masking-rules/columns")
public String assignColumnRule(
@RequestParam long columnId,
@RequestParam long ruleId,
RedirectAttributes redirectAttributes
) {
try {
var result = maskingRuleService.assignRuleToColumn(columnId, ruleId);
redirectAttributes.addFlashAttribute("message", "컬럼에 컬럼 마스킹 규칙을 연결했습니다. " + result.summary());
} catch (AppException | DataAccessException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
}
return "redirect:/masking-rules";
}
@PostMapping("/masking-rules/target-columns")
public String addTargetColumn(
@RequestParam String target,
RedirectAttributes redirectAttributes
) {
try {
String[] parts = target == null ? new String[0] : target.split(":", 2);
if (parts.length != 2) {
throw new AppException("추가할 보호 객체와 컬럼을 선택하세요.");
}
var result = maskingRuleService.addTargetColumn(Long.parseLong(parts[0]), parts[1]);
redirectAttributes.addFlashAttribute("message",
"마스킹 대상 컬럼으로 등록했습니다. 실제 적용하려면 아래에서 기본 규칙을 연결하세요. " + result.summary());
} catch (NumberFormatException exception) {
redirectAttributes.addFlashAttribute("errorMessage", "추가할 보호 객체와 컬럼을 선택하세요.");
} catch (AppException | DataAccessException exception) {
redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception));
}
return "redirect:/masking-rules";
}
@PostMapping("/masking-rules/columns/delete")
public String removeColumnRule(@RequestParam long columnId, RedirectAttributes redirectAttributes) {
try {
var result = maskingRuleService.removeRuleFromColumn(columnId);
redirectAttributes.addFlashAttribute("message", "컬럼 마스킹 규칙과 관련 사용자 예외를 해제했습니다. " + result.summary());
} catch (AppException | DataAccessException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
}
return "redirect:/masking-rules";
}
@PostMapping("/masking-rules/synchronize")
public String synchronizeDatabasePolicies(RedirectAttributes redirectAttributes) {
try {
redirectAttributes.addFlashAttribute("message", maskingRuleService.synchronizeDatabasePolicies().summary());
} catch (AppException | DataAccessException exception) {
redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception));
}
return "redirect:/masking-rules";
}
private String safeMessage(Exception exception) {
return exception instanceof AppException ? exception.getMessage() : "컬럼 마스킹 규칙을 저장하지 못했습니다. 입력값과 DB 상태를 확인하세요.";
}
private List<String> availableMaskingColumns(
ProtectedObject object,
List<ProtectedColumn> protectedColumns
) {
var registeredSensitiveColumns = new HashSet<String>();
protectedColumns.stream()
.filter(ProtectedColumn::sensitive)
.map(ProtectedColumn::columnName)
.forEach(registeredSensitiveColumns::add);
return protectedObjectService.findDatabaseColumns(object.owner(), object.objectName()).stream()
.filter(columnName -> !registeredSensitiveColumns.contains(columnName))
.toList();
}
}

View File

@@ -4,10 +4,9 @@ import com.cloudhandson.vpdbackoffice.domain.mcp.McpReasoningCommand;
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
import com.cloudhandson.vpdbackoffice.service.McpReasoningService;
import com.cloudhandson.vpdbackoffice.service.McpSseService;
import com.cloudhandson.vpdbackoffice.service.McpToolRegistry;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -20,17 +19,20 @@ import org.springframework.web.bind.annotation.ResponseBody;
public class McpReasoningController {
private final McpToolRegistry toolRegistry;
private final McpSseService mcpSseService;
private final McpReasoningService reasoningService;
private final BearerTokenService tokenService;
private final UserMapper userMapper;
public McpReasoningController(
McpToolRegistry toolRegistry,
McpSseService mcpSseService,
McpReasoningService reasoningService,
BearerTokenService tokenService,
UserMapper userMapper
) {
this.toolRegistry = toolRegistry;
this.mcpSseService = mcpSseService;
this.reasoningService = reasoningService;
this.tokenService = tokenService;
this.userMapper = userMapper;
@@ -53,28 +55,12 @@ public class McpReasoningController {
@GetMapping("/mcp/tools")
@ResponseBody
public Object tools() {
try {
return toolRegistry.listTools();
} catch (DataAccessException e) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(e);
Map<String, Object> response = new LinkedHashMap<>();
response.put("status", "DB_NOT_AVAILABLE");
response.put("title", message.title());
response.put("message", message.message());
response.put("tools", List.of());
return response;
}
return mcpSseService.registeredTools();
}
@GetMapping("/mcp-sse")
public String ssePage(Model model) {
try {
model.addAttribute("tools", toolRegistry.listTools());
} catch (DataAccessException e) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(e);
model.addAttribute("tools", List.of());
model.addAttribute("runtimeError", message);
}
model.addAttribute("tools", mcpSseService.registeredTools());
return "mcp-sse";
}

View File

@@ -7,6 +7,7 @@ import java.io.IOException;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
@@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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;
@@ -34,13 +36,16 @@ public class McpSseController {
* The legacy SSE endpoints remain available for existing integrations.
*/
@PostMapping(path = "/mcp", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ObjectNode> streamableMessage(@RequestBody JsonNode request) {
public ResponseEntity<ObjectNode> streamableMessage(
@RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization,
@RequestBody JsonNode request
) {
// JSON-RPC notifications never receive a response body. Current MCP
// clients send notifications/initialized immediately after initialize.
if (request != null && !request.has("id")) {
return ResponseEntity.accepted().build();
}
return ResponseEntity.ok(mcpSseService.handle("default", request));
return ResponseEntity.ok(mcpSseService.handle("default", request, bearerToken(authorization)));
}
@GetMapping(path = "/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@@ -56,18 +61,20 @@ public class McpSseController {
@PostMapping(path = "/mcp/messages", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> defaultMessage(
@RequestParam(required = false) String sessionId,
@RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization,
@RequestBody JsonNode request
) throws IOException {
return handleMessage("default", sessionId, request);
return handleMessage("default", sessionId, authorization, request);
}
@PostMapping(path = "/mcp/{contextPath}/messages", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> contextMessage(
@PathVariable String contextPath,
@RequestParam(required = false) String sessionId,
@RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization,
@RequestBody JsonNode request
) throws IOException {
return handleMessage(contextPath, sessionId, request);
return handleMessage(contextPath, sessionId, authorization, request);
}
private SseEmitter openSse(String contextPath) throws IOException {
@@ -84,9 +91,15 @@ public class McpSseController {
return emitter;
}
private ResponseEntity<?> handleMessage(String contextPath, String sessionId, JsonNode request) throws IOException {
private ResponseEntity<?> handleMessage(
String contextPath,
String sessionId,
String authorization,
JsonNode request
) throws IOException {
String normalizedContextPath = normalizeContextPath(contextPath);
ObjectNode response = mcpSseService.handle(normalizedContextPath, request);
ObjectNode response = mcpSseService.handle(
normalizedContextPath, request, bearerToken(authorization));
if (sessionId == null || sessionId.isBlank()) {
return ResponseEntity.ok(response);
}
@@ -119,6 +132,13 @@ public class McpSseController {
return normalized.toLowerCase();
}
private String bearerToken(String authorization) {
if (authorization == null || !authorization.regionMatches(true, 0, "Bearer ", 0, 7)) {
return "";
}
return authorization.substring(7).trim();
}
private record McpSseSession(String contextPath, SseEmitter emitter) {
}
}

View File

@@ -1,5 +1,6 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.MaskingRuleService;
import com.cloudhandson.vpdbackoffice.service.OperationStatusService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -9,14 +10,17 @@ import org.springframework.web.bind.annotation.GetMapping;
public class OperationStatusController {
private final OperationStatusService service;
private final MaskingRuleService maskingRuleService;
public OperationStatusController(OperationStatusService service) {
public OperationStatusController(OperationStatusService service, MaskingRuleService maskingRuleService) {
this.service = service;
this.maskingRuleService = maskingRuleService;
}
@GetMapping("/operation-status")
public String status(Model model) {
model.addAttribute("rows", service.findRows());
model.addAttribute("maskingPolicyStatuses", maskingRuleService.findPolicyStatuses());
return "operation-status";
}
}

View File

@@ -10,7 +10,6 @@ import com.cloudhandson.vpdbackoffice.service.PermissionService;
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
import com.cloudhandson.vpdbackoffice.service.UserService;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@@ -85,17 +84,20 @@ public class PermissionController {
long rolesAt = System.nanoTime();
var roleImpact = buildRoleImpact(roles);
long roleImpactAt = System.nanoTime();
var protectedColumnsByObject = protectedObjectService.findColumnsByObjectIds(
objects.stream().map(object -> object.objectId()).toList()
);
var columnsByObject = objects.stream()
.collect(Collectors.toMap(
object -> object.objectId(),
object -> protectedObjectService.findColumns(object.objectId()).stream()
object -> protectedColumnsByObject.getOrDefault(object.objectId(), List.of()).stream()
.map(column -> column.columnName())
.toList()
));
var maskableColumnsByObject = objects.stream()
.collect(Collectors.toMap(
object -> object.objectId(),
object -> protectedObjectService.findColumns(object.objectId()).stream()
object -> protectedColumnsByObject.getOrDefault(object.objectId(), List.of()).stream()
.filter(ProtectedColumn::sensitive)
.map(column -> column.columnName())
.toList()
@@ -103,7 +105,7 @@ public class PermissionController {
var maskableColumnLabelsByObject = objects.stream()
.collect(Collectors.toMap(
object -> object.objectId(),
object -> protectedObjectService.findColumns(object.objectId()).stream()
object -> protectedColumnsByObject.getOrDefault(object.objectId(), List.of()).stream()
.filter(ProtectedColumn::sensitive)
.map(column -> column.columnName() + " [" + column.policyLabel() + "]")
.toList()
@@ -112,11 +114,17 @@ public class PermissionController {
var dbObjects = protectedObjectService.findDatabaseObjects();
long dbObjectsAt = System.nanoTime();
var permissions = permissionService.findPermissionViews();
var permissionCountByObject = permissions.stream()
.filter(permission -> permission.objectId() > 0)
.collect(Collectors.groupingBy(
permission -> permission.objectId(),
Collectors.counting()
));
var lastPermissionByPermissionId = permissions.stream()
.collect(Collectors.toMap(
permission -> permission.permissionId(),
permission -> permission.objectId() > 0
&& permissionService.countPermissionsByObjectId(permission.objectId()) <= 1,
&& permissionCountByObject.getOrDefault(permission.objectId(), 0L) <= 1,
(left, right) -> left,
LinkedHashMap::new
));
@@ -201,7 +209,6 @@ public class PermissionController {
@RequestParam(required = false) List<String> ruleColumn,
@RequestParam(required = false) List<String> ruleType,
@RequestParam(required = false) List<String> ruleValue,
@RequestParam(required = false) String visibleColumns,
RedirectAttributes redirectAttributes
) {
try {
@@ -215,7 +222,7 @@ public class PermissionController {
"SELECT",
permissionEffect,
buildRules(ruleColumn, ruleType, ruleValue),
splitColumns(visibleColumns)
List.of()
));
redirectAttributes.addFlashAttribute("message", "권한을 저장했습니다.");
} catch (AppException | IllegalArgumentException exception) {
@@ -263,16 +270,6 @@ public class PermissionController {
throw new IllegalArgumentException("보호 객체 형식이 올바르지 않습니다.");
}
private List<String> splitColumns(String visibleColumns) {
if (visibleColumns == null || visibleColumns.isBlank()) {
return List.of();
}
return Arrays.stream(visibleColumns.split(","))
.map(String::trim)
.filter(value -> !value.isBlank())
.toList();
}
@PostMapping("/permissions/delete")
public String delete(
@RequestParam long permissionId,

View File

@@ -53,7 +53,7 @@ final class RuntimeErrorMessages {
if (cause instanceof SQLException sqlException) {
return trim(sqlException.getMessage());
}
return trim(cause == null ? exception.getMessage() : safeMessage(cause));
return trim(safeMessage(cause));
}
private static String safeMessage(Throwable throwable) {

View File

@@ -0,0 +1,108 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.SchemaMetadataService;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
public class SchemaMetadataController {
private final SchemaMetadataService schemaMetadataService;
public SchemaMetadataController(SchemaMetadataService schemaMetadataService) {
this.schemaMetadataService = schemaMetadataService;
}
@GetMapping("/schema-metadata")
public String schemaMetadata(@RequestParam(required = false) String table, Model model) {
String selectedKey = table == null || table.isBlank() ? schemaMetadataService.defaultKey() : table;
model.addAttribute("tables", schemaMetadataService.tables());
model.addAttribute("selectedKey", selectedKey);
try {
model.addAttribute("metadata", schemaMetadataService.find(selectedKey));
} catch (AppException | DataAccessException exception) {
model.addAttribute("errorMessage", safeMessage(exception));
}
return "schema-metadata";
}
@PostMapping("/schema-metadata/table-comment")
public String updateTableComment(
@RequestParam String table,
@RequestParam(required = false) String comment,
RedirectAttributes redirectAttributes
) {
try {
schemaMetadataService.updateTableComment(table, comment);
redirectAttributes.addFlashAttribute("message", "테이블 comment를 저장했습니다.");
} catch (AppException | DataAccessException exception) {
redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception));
}
return redirect(table);
}
@PostMapping("/schema-metadata/column-comment")
public String updateColumnComment(
@RequestParam String table,
@RequestParam String column,
@RequestParam(required = false) String comment,
RedirectAttributes redirectAttributes
) {
try {
schemaMetadataService.updateColumnComment(table, column, comment);
redirectAttributes.addFlashAttribute("message", column + " 컬럼 comment를 저장했습니다.");
} catch (AppException | DataAccessException exception) {
redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception));
}
return redirect(table);
}
@PostMapping("/schema-metadata/table-annotation")
public String updateTableAnnotation(
@RequestParam String table,
@RequestParam String annotationName,
@RequestParam(required = false) String annotationValue,
RedirectAttributes redirectAttributes
) {
try {
schemaMetadataService.updateTableAnnotation(table, annotationName, annotationValue);
redirectAttributes.addFlashAttribute("message", "테이블 annotation을 저장했습니다.");
} catch (AppException | DataAccessException exception) {
redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception));
}
return redirect(table);
}
@PostMapping("/schema-metadata/column-annotation")
public String updateColumnAnnotation(
@RequestParam String table,
@RequestParam String column,
@RequestParam String annotationName,
@RequestParam(required = false) String annotationValue,
RedirectAttributes redirectAttributes
) {
try {
schemaMetadataService.updateColumnAnnotation(table, column, annotationName, annotationValue);
redirectAttributes.addFlashAttribute("message", column + " 컬럼 annotation을 저장했습니다.");
} catch (AppException | DataAccessException exception) {
redirectAttributes.addFlashAttribute("errorMessage", safeMessage(exception));
}
return redirect(table);
}
private String redirect(String table) {
return "redirect:/schema-metadata?table=" + (table == null ? "" : table);
}
private String safeMessage(Exception exception) {
return exception instanceof AppException
? exception.getMessage()
: "DB 메타데이터를 저장하지 못했습니다. 권한, 식별자, Oracle annotation 문법을 확인하세요.";
}
}

View File

@@ -0,0 +1,28 @@
package com.cloudhandson.vpdbackoffice.web;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
@ControllerAdvice
public class SecurityModelAdvice {
@ModelAttribute("canMutate")
public boolean canMutate() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return false;
}
return authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch("ROLE_ADMIN"::equals);
}
@ModelAttribute("readOnlyMode")
public boolean readOnlyMode() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return authentication != null && authentication.isAuthenticated() && !canMutate();
}
}

View File

@@ -0,0 +1,57 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.SecuritySqlScriptExplanationService;
import com.cloudhandson.vpdbackoffice.service.SecuritySqlScriptService;
import java.util.List;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
/** Read-only page for the curated ASO, ORDS, and Select AI deployment scripts. */
@Controller
public class SecuritySqlScriptController {
private final SecuritySqlScriptService securitySqlScriptService;
private final SecuritySqlScriptExplanationService explanationService;
public SecuritySqlScriptController(
SecuritySqlScriptService securitySqlScriptService,
SecuritySqlScriptExplanationService explanationService
) {
this.securitySqlScriptService = securitySqlScriptService;
this.explanationService = explanationService;
}
@GetMapping("/security-sql-scripts")
public String scripts(@RequestParam(required = false) String script, Model model) {
List<SecuritySqlScriptSummary> scripts = securitySqlScriptService.list();
model.addAttribute("scripts", scripts);
if (scripts.isEmpty()) {
model.addAttribute("errorMessage", "표시할 보안 SQL 스크립트가 없습니다.");
return "security-sql-scripts";
}
String selectedId = script == null || script.isBlank() ? scripts.getFirst().scriptId() : script;
try {
model.addAttribute("selectedScript", securitySqlScriptService.find(selectedId));
} catch (AppException exception) {
model.addAttribute("errorMessage", exception.getMessage());
model.addAttribute("selectedScript", securitySqlScriptService.find(scripts.getFirst().scriptId()));
}
return "security-sql-scripts";
}
@PostMapping("/security-sql-scripts/explanation")
public String explain(@RequestParam String script, Model model) {
try {
model.addAttribute("explanation", explanationService.explain(script));
} catch (AppException exception) {
model.addAttribute("errorMessage", exception.getMessage());
}
return "fragments/security-sql-script-explanation :: explanation";
}
}

View File

@@ -0,0 +1,63 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.MaskingRuleService;
import com.cloudhandson.vpdbackoffice.service.UserService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
public class UserMaskingRuleController {
private final MaskingRuleService maskingRuleService;
private final UserService userService;
public UserMaskingRuleController(MaskingRuleService maskingRuleService, UserService userService) {
this.maskingRuleService = maskingRuleService;
this.userService = userService;
}
@GetMapping("/user-masking-rules")
public String userMaskingRules(Model model) {
model.addAttribute("users", userService.findAll());
model.addAttribute("columnRules", maskingRuleService.findColumnRules().stream()
.filter(columnRule -> columnRule.ruleEnabled())
.toList());
model.addAttribute("userRules", maskingRuleService.findUserRules());
return "user-masking-rules";
}
@PostMapping("/user-masking-rules")
public String assign(
@RequestParam long userId,
@RequestParam long columnId,
RedirectAttributes redirectAttributes
) {
try {
maskingRuleService.assignUserRule(userId, columnId, "UNMASK");
redirectAttributes.addFlashAttribute("message", "컬럼 원문 표시 허용 사용자를 저장했습니다. 이 설정은 행 접근 권한을 추가하지 않습니다.");
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
}
return "redirect:/user-masking-rules";
}
@PostMapping("/user-masking-rules/delete")
public String remove(
@RequestParam long userId,
@RequestParam long columnId,
RedirectAttributes redirectAttributes
) {
try {
maskingRuleService.removeUserRule(userId, columnId);
redirectAttributes.addFlashAttribute("message", "컬럼 원문 표시 허용을 해제하고 컬럼의 기본 컬럼 마스킹 규칙으로 되돌렸습니다.");
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
}
return "redirect:/user-masking-rules";
}
}

View File

@@ -1,12 +1,15 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyCreateCommand;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.VpdPolicyService;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.dao.DataAccessException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -17,6 +20,7 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
public class VpdPolicyController {
private static final Logger log = LoggerFactory.getLogger(VpdPolicyController.class);
private final VpdPolicyService vpdPolicyService;
public VpdPolicyController(VpdPolicyService vpdPolicyService) {
@@ -28,7 +32,7 @@ public class VpdPolicyController {
@RequestParam(required = false) String schemaOwner,
Model model
) {
populatePolicyModel(schemaOwner, model);
populatePolicyModel(schemaOwner, false, model);
return "vpd-policies";
}
@@ -37,41 +41,95 @@ public class VpdPolicyController {
@RequestParam(required = false) String schemaOwner,
Model model
) {
populatePolicyModel(schemaOwner, model);
populatePolicyModel(schemaOwner, true, model);
return "vpd-filter-policies";
}
private void populatePolicyModel(String schemaOwner, Model model) {
/** Read-only operational view of the default dynamic permission filter. */
@GetMapping("/vpd-filter-runtime")
public String filterRuntime(Model model) {
try {
List<VpdPolicyView> policies = vpdPolicyService.findPolicies().stream()
.filter(VpdPolicyView::permissionSystemDefault)
.toList();
model.addAttribute("policies", policies);
if (!policies.isEmpty()) {
VpdPolicyView filter = policies.get(0);
model.addAttribute("source", vpdPolicyService.findFunctionSource(
filter.functionOwner(), filter.packageName(), filter.functionName()));
}
} catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
model.addAttribute("runtimeError", message);
model.addAttribute("policies", List.of());
} catch (AppException exception) {
model.addAttribute("errorMessage", exception.getMessage());
model.addAttribute("policies", List.of());
}
return "vpd-filter-runtime";
}
private void populatePolicyModel(String schemaOwner, boolean includeFilterEditor, Model model) {
try {
long started = System.nanoTime();
String selectedSchemaOwner = schemaOwner == null ? "" : schemaOwner.trim().toUpperCase();
List<com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView> policies = vpdPolicyService.findPolicies();
long policiesAt = System.nanoTime();
Map<String, String> policyDescriptions = new LinkedHashMap<>();
policies.forEach(policy -> policyDescriptions.put(
policy.objectDisplayName() + "|" + policy.policyName(),
vpdPolicyService.findPolicyDescription(policy.objectOwner(), policy.objectName(), policy.policyName())
));
if (!includeFilterEditor) {
policyDescriptions.putAll(vpdPolicyService.findPolicyDescriptionMap());
policies.forEach(policy -> policyDescriptions.put(
policy.objectDisplayName() + "|" + policy.policyName(),
policyDescriptions.getOrDefault(
policy.objectDisplayName() + "|" + policy.policyName(),
policy.objectDisplayName() + "에 요청마다 현재 권한체계의 행 접근 조건을 적용하는 "
+ policy.policyName() + " policy입니다."
)
));
}
long policyDescriptionsAt = System.nanoTime();
model.addAttribute("policies", policies);
model.addAttribute("policyDescriptions", policyDescriptions);
model.addAttribute("vpdTargets", vpdPolicyService.findVpdTargets(selectedSchemaOwner));
model.addAttribute("vpdTargets", includeFilterEditor
? List.of()
: vpdPolicyService.findVpdTargets(selectedSchemaOwner));
long targetsAt = System.nanoTime();
model.addAttribute("selectedSchemaOwner", selectedSchemaOwner);
var formOptions = vpdPolicyService.formOptions();
long formOptionsAt = System.nanoTime();
Map<String, String> filterDescriptions = new LinkedHashMap<>();
formOptions.functions().forEach(function -> filterDescriptions.put(
function.owner() + "|" + function.functionName(),
vpdPolicyService.findFilterDescription(function.owner(), function.functionName())
));
filterDescriptions.putAll(vpdPolicyService.findFilterDescriptionMap());
policies.forEach(policy -> filterDescriptions.putIfAbsent(
policy.functionOwner() + "|" + policy.functionName(),
vpdPolicyService.findFilterDescription(policy.functionOwner(), policy.functionName())
defaultFilterDescription(policy.functionName())
));
if (includeFilterEditor) {
formOptions.functions().forEach(function -> filterDescriptions.putIfAbsent(
function.owner() + "|" + function.functionName(),
defaultFilterDescription(function.functionName())
));
}
long filterDescriptionsAt = System.nanoTime();
Map<String, String> filterPredicates = new LinkedHashMap<>();
formOptions.functions().forEach(function -> filterPredicates.put(
function.owner() + "|" + function.functionName(),
vpdPolicyService.findFilterPredicate(function.owner(), function.packageName(), function.functionName())
));
if (includeFilterEditor) {
formOptions.functions().forEach(function -> filterPredicates.put(
function.owner() + "|" + function.functionName(),
vpdPolicyService.findFilterPredicate(function.owner(), function.packageName(), function.functionName())
));
}
long predicatesAt = System.nanoTime();
model.addAttribute("formOptions", formOptions);
model.addAttribute("filterDescriptions", filterDescriptions);
model.addAttribute("filterPredicates", filterPredicates);
log.info("vpd page timings: editor={} policies={}ms policyDescriptions={}ms targets={}ms formOptions={}ms filterDescriptions={}ms predicates={}ms total={}ms",
includeFilterEditor,
elapsedMillis(started, policiesAt),
elapsedMillis(policiesAt, policyDescriptionsAt),
elapsedMillis(policyDescriptionsAt, targetsAt),
elapsedMillis(targetsAt, formOptionsAt),
elapsedMillis(formOptionsAt, filterDescriptionsAt),
elapsedMillis(filterDescriptionsAt, predicatesAt),
elapsedMillis(started, predicatesAt));
} catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
model.addAttribute("runtimeError", message);
@@ -94,6 +152,16 @@ public class VpdPolicyController {
}
}
private static String defaultFilterDescription(String functionName) {
return "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName)
? "사용자·그룹·역할·TAG 권한을 동적으로 합쳐 VPD predicate를 반환합니다."
: "이 Filter function이 반환하는 predicate로 조회 행을 제한합니다.";
}
private static long elapsedMillis(long started, long finished) {
return (finished - started) / 1_000_000;
}
@PostMapping("/vpd-policies")
public String createPolicy(
@RequestParam String objectKey,