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,

View File

@@ -36,7 +36,16 @@ backoffice:
security:
admin-user: ${BACKOFFICE_ADMIN_USER:admin}
admin-password: ${BACKOFFICE_ADMIN_PASSWORD:admin}
# A stable encoded value keeps signed remember-me cookies valid across restarts.
admin-password-hash: ${BACKOFFICE_ADMIN_PASSWORD_HASH:}
guest-enabled: ${BACKOFFICE_GUEST_ENABLED:false}
guest-user: ${BACKOFFICE_GUEST_USER:guest}
guest-password: ${BACKOFFICE_GUEST_PASSWORD:}
guest-password-hash: ${BACKOFFICE_GUEST_PASSWORD_HASH:}
require-https: ${BACKOFFICE_REQUIRE_HTTPS:false}
remember-me-enabled: ${BACKOFFICE_REMEMBER_ME_ENABLED:false}
remember-me-key: ${BACKOFFICE_REMEMBER_ME_KEY:}
remember-me-days: ${BACKOFFICE_REMEMBER_ME_DAYS:14}
token:
max-days: ${BACKOFFICE_TOKEN_MAX_DAYS:365}
ords:
@@ -49,10 +58,13 @@ backoffice:
password: ${BACKOFFICE_ORDS_DB_PASSWORD:}
ai:
enabled: ${BACKOFFICE_AI_ENABLED:false}
base-url: ${BACKOFFICE_AI_BASE_URL:}
model: ${BACKOFFICE_AI_MODEL:}
provider: ${BACKOFFICE_AI_PROVIDER:openai}
base-url: ${BACKOFFICE_AI_BASE_URL:${POC3_LLM_GPT55_OCI_ENDPOINT:}}
model: ${BACKOFFICE_AI_MODEL:${POC3_LLM_GPT55_OCI_MODEL_ID:}}
embedding-model: ${BACKOFFICE_AI_EMBEDDING_MODEL:}
api-key: ${BACKOFFICE_AI_API_KEY:}
timeout: ${BACKOFFICE_AI_TIMEOUT_SECONDS:30}s
mcp:
access-token: ${BACKOFFICE_MCP_ACCESS_TOKEN:}
oci-config-file: ${BACKOFFICE_AI_OCI_CONFIG_FILE:${OCI_CONFIG_FILE:}}
oci-profile: ${BACKOFFICE_AI_OCI_PROFILE:${OCI_PROFILE:DEFAULT}}
oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}}
oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}}

View File

@@ -0,0 +1,264 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper">
<select id="findAllRules" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
FROM cb_masking_rule
ORDER BY enabled_yn DESC, rule_name, rule_id
</select>
<select id="findEnabledRules" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
FROM cb_masking_rule
WHERE enabled_yn = 'Y'
ORDER BY rule_name, rule_id
</select>
<select id="findRuleById" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
FROM cb_masking_rule
WHERE rule_id = #{ruleId}
</select>
<select id="findRuleByCode" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
FROM cb_masking_rule
WHERE rule_code = #{ruleCode}
</select>
<select id="nextRuleId" resultType="long">
SELECT NVL(MAX(rule_id), 0) + 1 FROM cb_masking_rule
</select>
<insert id="insertRule">
INSERT INTO cb_masking_rule (rule_id, rule_code, rule_name, template_code, description, enabled_yn)
VALUES (#{ruleId}, #{command.ruleCode}, #{command.ruleName}, #{command.templateCode},
#{command.description}, 'Y')
</insert>
<update id="updateRuleActive">
UPDATE cb_masking_rule
SET enabled_yn = #{enabledYn}
WHERE rule_id = #{ruleId}
</update>
<select id="findColumnRules" resultType="com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule">
SELECT c.column_id,
c.object_id,
o.owner,
o.object_name,
c.column_name,
r.rule_id,
r.rule_code,
r.rule_name,
r.template_code,
r.enabled_yn AS rule_enabled_yn
FROM cb_column_masking_rule link
JOIN cb_protected_column c ON c.column_id = link.column_id
JOIN cb_protected_object o ON o.object_id = c.object_id
JOIN cb_masking_rule r ON r.rule_id = link.rule_id
ORDER BY o.owner, o.object_name, c.column_name
</select>
<!--
Runtime truth comes from Oracle's REDACTION_POLICIES / REDACTION_COLUMNS,
while CB_* tables represent the backoffice's desired configuration.
Keep this comparison limited to the policies managed by this screen.
-->
<select id="findPolicyStatuses" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus">
WITH managed_policy AS (
SELECT 'KB_CUSTOMERS' AS object_name, 'KB_CUSTOMER_PII_REDACT' AS policy_name FROM dual
UNION ALL SELECT 'KB_CLAIMS', 'KB_CLAIM_AMOUNT_REDACT' FROM dual
UNION ALL SELECT 'KB_CONTRACTS', 'KB_CONTRACT_PREMIUM_REDACT' FROM dual
UNION ALL SELECT 'KB_EXTERNAL_HOLDINGS', 'KB_EXT_HOLDING_REDACT' FROM dual
),
configured AS (
SELECT protected_object.object_name,
COUNT(*) AS configured_column_count
FROM cb_column_masking_rule link
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id
JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
WHERE protected_object.owner = 'POC_2'
AND rule.enabled_yn = 'Y'
GROUP BY protected_object.object_name
),
database_policy AS (
SELECT policy.object_name,
policy.policy_name,
policy.enable,
COUNT(policy_column.column_name) AS applied_column_count
FROM redaction_policies policy
JOIN managed_policy managed
ON managed.object_name = policy.object_name
AND managed.policy_name = policy.policy_name
LEFT JOIN redaction_columns policy_column
ON policy_column.object_owner = policy.object_owner
AND policy_column.object_name = policy.object_name
WHERE policy.object_owner = 'POC_2'
GROUP BY policy.object_name, policy.policy_name, policy.enable
),
missing_columns AS (
SELECT protected_object.object_name,
COUNT(*) AS missing_column_count
FROM cb_column_masking_rule link
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id
JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
LEFT JOIN redaction_columns policy_column
ON policy_column.object_owner = protected_object.owner
AND policy_column.object_name = protected_object.object_name
AND policy_column.column_name = protected_column.column_name
WHERE protected_object.owner = 'POC_2'
AND rule.enabled_yn = 'Y'
AND policy_column.column_name IS NULL
GROUP BY protected_object.object_name
),
extra_columns AS (
SELECT policy_column.object_name,
COUNT(*) AS extra_column_count
FROM redaction_columns policy_column
JOIN managed_policy managed ON managed.object_name = policy_column.object_name
WHERE policy_column.object_owner = 'POC_2'
AND NOT EXISTS (
SELECT 1
FROM cb_column_masking_rule link
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id
JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
WHERE protected_object.owner = 'POC_2'
AND protected_object.object_name = policy_column.object_name
AND protected_column.column_name = policy_column.column_name
AND rule.enabled_yn = 'Y'
)
GROUP BY policy_column.object_name
),
legacy_vpd_column_policy AS (
SELECT policy.object_name,
COUNT(*) AS legacy_vpd_column_policy_count
FROM all_policies policy
WHERE policy.object_owner = 'POC_2'
AND policy.enable = 'YES'
AND policy.policy_name IN (
'KB_CUST_NM_CLS_POLICY', 'KB_RRN_CLS_POLICY',
'KB_PREMIUM_CLS_POLICY',
'KB_CLAIM_AMT_CLS_POLICY', 'KB_PAID_AMT_CLS_POLICY',
'KB_EXT_INSURER_CLS_POLICY', 'KB_EXT_PRODUCT_GRP_CLS_POLICY',
'KB_EXT_PRODUCT_TYPE_CLS_POLICY'
)
GROUP BY policy.object_name
)
SELECT 'POC_2' AS owner,
managed.object_name,
managed.policy_name,
database_policy.enable AS enabled,
NVL(configured.configured_column_count, 0) AS configured_column_count,
NVL(database_policy.applied_column_count, 0) AS applied_column_count,
NVL(missing_columns.missing_column_count, 0) + NVL(extra_columns.extra_column_count, 0)
AS mismatched_column_count,
NVL(legacy_vpd_column_policy.legacy_vpd_column_policy_count, 0)
AS legacy_vpd_column_policy_count
FROM managed_policy managed
LEFT JOIN configured ON configured.object_name = managed.object_name
LEFT JOIN database_policy ON database_policy.object_name = managed.object_name
LEFT JOIN missing_columns ON missing_columns.object_name = managed.object_name
LEFT JOIN extra_columns ON extra_columns.object_name = managed.object_name
LEFT JOIN legacy_vpd_column_policy
ON legacy_vpd_column_policy.object_name = managed.object_name
ORDER BY managed.object_name
</select>
<select id="findColumnRule" resultType="com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule">
SELECT c.column_id,
c.object_id,
o.owner,
o.object_name,
c.column_name,
r.rule_id,
r.rule_code,
r.rule_name,
r.template_code,
r.enabled_yn AS rule_enabled_yn
FROM cb_column_masking_rule link
JOIN cb_protected_column c ON c.column_id = link.column_id
JOIN cb_protected_object o ON o.object_id = c.object_id
JOIN cb_masking_rule r ON r.rule_id = link.rule_id
WHERE c.column_id = #{columnId}
</select>
<insert id="upsertColumnRule">
MERGE INTO cb_column_masking_rule dst
USING (SELECT #{columnId} column_id, #{ruleId} rule_id FROM dual) src
ON (dst.column_id = src.column_id)
WHEN MATCHED THEN UPDATE SET dst.rule_id = src.rule_id, dst.updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN INSERT (column_id, rule_id, updated_at)
VALUES (src.column_id, src.rule_id, SYSTIMESTAMP)
</insert>
<delete id="deleteColumnRule">
DELETE FROM cb_column_masking_rule
WHERE column_id = #{columnId}
</delete>
<delete id="deleteUserRulesForColumn">
DELETE FROM cb_user_masking_rule
WHERE column_id = #{columnId}
</delete>
<select id="findUserRules" resultType="com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule">
SELECT u.user_id,
u.user_name AS username,
c.column_id,
o.owner,
o.object_name,
c.column_name,
r.rule_name,
r.template_code,
assignment.decision,
assignment.active_yn
FROM cb_user_masking_rule assignment
JOIN cb_app_user u ON u.user_id = assignment.user_id
JOIN cb_protected_column c ON c.column_id = assignment.column_id
JOIN cb_protected_object o ON o.object_id = c.object_id
JOIN cb_column_masking_rule link ON link.column_id = c.column_id
JOIN cb_masking_rule r ON r.rule_id = link.rule_id
ORDER BY u.user_name, o.owner, o.object_name, c.column_name
</select>
<select id="findUserRule" resultType="com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule">
SELECT u.user_id,
u.user_name AS username,
c.column_id,
o.owner,
o.object_name,
c.column_name,
r.rule_name,
r.template_code,
assignment.decision,
assignment.active_yn
FROM cb_user_masking_rule assignment
JOIN cb_app_user u ON u.user_id = assignment.user_id
JOIN cb_protected_column c ON c.column_id = assignment.column_id
JOIN cb_protected_object o ON o.object_id = c.object_id
JOIN cb_column_masking_rule link ON link.column_id = c.column_id
JOIN cb_masking_rule r ON r.rule_id = link.rule_id
WHERE assignment.user_id = #{userId}
AND assignment.column_id = #{columnId}
</select>
<insert id="upsertUserRule">
MERGE INTO cb_user_masking_rule dst
USING (SELECT #{userId} user_id, #{columnId} column_id, #{decision} decision FROM dual) src
ON (dst.user_id = src.user_id AND dst.column_id = src.column_id)
WHEN MATCHED THEN UPDATE SET dst.decision = src.decision, dst.active_yn = 'Y', dst.updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN INSERT (user_id, column_id, decision, active_yn, updated_at)
VALUES (src.user_id, src.column_id, src.decision, 'Y', SYSTIMESTAMP)
</insert>
<delete id="deleteUserRule">
DELETE FROM cb_user_masking_rule
WHERE user_id = #{userId}
AND column_id = #{columnId}
</delete>
</mapper>

View File

@@ -100,13 +100,15 @@
WHEN pr2.rule_type = 'CHANNEL_CONTRACT' THEN
pr2.rule_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL'')'
WHEN pr2.rule_type = 'OWN_CUSTOMER' THEN
'EXISTS (SELECT 1 FROM POC_2.KB_CONTRACTS c '
|| 'WHERE c.' || pr2.rule_column || ' = [CURRENT_ROW].' || pr2.rule_column || ' '
|| 'AND c.FC_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID''))'
'EXISTS (SELECT 1 FROM (SELECT c.' || pr2.rule_column || ' AS access_key '
|| 'FROM POC_2.KB_CONTRACTS c '
|| 'WHERE c.FC_ID = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID'')) allowed_contract '
|| 'WHERE allowed_contract.access_key = ' || pr2.rule_column || ')'
WHEN pr2.rule_type = 'CHANNEL_CUSTOMER' THEN
'EXISTS (SELECT 1 FROM POC_2.KB_CONTRACTS c '
|| 'WHERE c.' || pr2.rule_column || ' = [CURRENT_ROW].' || pr2.rule_column || ' '
|| 'AND c.FC_CHANNEL = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL''))'
'EXISTS (SELECT 1 FROM (SELECT c.' || pr2.rule_column || ' AS access_key '
|| 'FROM POC_2.KB_CONTRACTS c '
|| 'WHERE c.FC_CHANNEL = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL'')) allowed_contract '
|| 'WHERE allowed_contract.access_key = ' || pr2.rule_column || ')'
WHEN pr2.rule_type = 'STATIC_SQL' THEN pr2.rule_value
ELSE pr2.rule_column || ' ' || pr2.rule_type || ' ' || pr2.rule_value
END,
@@ -117,8 +119,8 @@
) AS filter_preview,
(
SELECT CASE
WHEN COUNT(*) = 0 THEN '모든 민감 컬럼 NULL 처리'
ELSE 'NULL 제외: ' || LISTAGG(pc2.column_name, ', ') WITHIN GROUP (ORDER BY pc2.column_name)
WHEN COUNT(*) = 0 THEN '컬럼 마스킹은 ASO에서 관리'
ELSE '레거시 권한별 컬럼 예외: ' || LISTAGG(pc2.column_name, ', ') WITHIN GROUP (ORDER BY pc2.column_name)
END
FROM cb_permission_column pc2
WHERE pc2.permission_id = p.perm_id

View File

@@ -69,6 +69,47 @@
ORDER BY column_id
</select>
<select id="findColumnsByObjectIds" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn">
SELECT column_id,
object_id,
column_name,
sensitive_yn,
visible_role_id,
NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level,
NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method
FROM cb_protected_column
WHERE object_id IN
<foreach collection="objectIds" item="objectId" open="(" separator="," close=")">
#{objectId,jdbcType=NUMERIC}
</foreach>
ORDER BY object_id, column_id
</select>
<select id="findColumnById" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn">
SELECT column_id,
object_id,
column_name,
sensitive_yn,
visible_role_id,
NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level,
NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method
FROM cb_protected_column
WHERE column_id = #{columnId}
</select>
<select id="findColumnByObjectAndName" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn">
SELECT column_id,
object_id,
column_name,
sensitive_yn,
visible_role_id,
NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level,
NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method
FROM cb_protected_column
WHERE object_id = #{objectId}
AND column_name = UPPER(#{columnName,jdbcType=VARCHAR})
</select>
<select id="nextObjectId" resultType="long">
SELECT NVL(MAX(object_id), 0) + 1 FROM cb_protected_object
</select>

View File

@@ -295,6 +295,18 @@
AND function_name = UPPER(#{functionName,jdbcType=VARCHAR})
</select>
<select id="findPolicyDescriptions" resultType="com.cloudhandson.vpdbackoffice.domain.vpd.VpdDescriptionNote">
SELECT object_owner || '.' || object_name || '|' || policy_name AS note_key,
description
FROM cb_vpd_policy_note
</select>
<select id="findFilterDescriptions" resultType="com.cloudhandson.vpdbackoffice.domain.vpd.VpdDescriptionNote">
SELECT function_owner || '|' || function_name AS note_key,
description
FROM cb_vpd_filter_note
</select>
<update id="upsertPolicyDescription">
MERGE INTO cb_vpd_policy_note dst
USING (

View File

@@ -2745,3 +2745,12 @@ body {
grid-template-columns: 1fr;
}
}
.readonly-mutation-notice {
margin-bottom: .75rem;
}
.readonly-mutation-disabled {
opacity: .58;
pointer-events: none;
}

View File

@@ -5,6 +5,67 @@ document.body.addEventListener('htmx:responseError', (event) => {
}
});
const READ_ONLY_POST_ALLOWLIST = new Set([
'/login',
'/logout',
'/probe',
'/vector-knowledge/search',
'/security-sql-scripts/explanation',
'/mcp-chatbot',
'/mcp-client-demo',
'/mcp-reasoning',
]);
function normalizeActionPath(value) {
if (!value) {
return '';
}
try {
return new URL(value, window.location.origin).pathname;
} catch (_error) {
return value;
}
}
function isReadOnlyMode() {
return document.querySelector('meta[name="backoffice-read-only"]')?.content === 'true';
}
function isFormMutation(form) {
const method = (form.getAttribute('method') || 'get').toLowerCase();
const hxPost = form.getAttribute('hx-post');
if (hxPost) {
return !READ_ONLY_POST_ALLOWLIST.has(normalizeActionPath(hxPost));
}
if (method !== 'post') {
return false;
}
return !READ_ONLY_POST_ALLOWLIST.has(normalizeActionPath(form.getAttribute('action')));
}
function disableReadOnlyMutationForms() {
if (!isReadOnlyMode()) {
return;
}
document.querySelectorAll('form').forEach((form) => {
if (!isFormMutation(form)) {
return;
}
if (!form.previousElementSibling?.matches?.('[data-readonly-mutation-notice]')) {
const notice = document.createElement('div');
notice.className = 'alert alert-secondary readonly-mutation-notice';
notice.setAttribute('data-readonly-mutation-notice', 'true');
notice.textContent = '읽기 전용 계정은 이 변경 작업을 실행할 수 없습니다.';
form.parentNode?.insertBefore(notice, form);
}
form.classList.add('readonly-mutation-disabled');
form.setAttribute('aria-disabled', 'true');
form.querySelectorAll('input:not([type="hidden"]), select, textarea, button').forEach((control) => {
control.disabled = true;
});
});
}
function resetFilterToggle(button, target) {
if (!button || !target) {
return;
@@ -324,60 +385,6 @@ function renderRuleColumnOptions(columns) {
});
}
function renderMaskableColumnOptions(option) {
const list = document.querySelector('[data-maskable-column-list]');
if (!list) {
return;
}
const columns = (option?.dataset.maskableColumns || '').split(',').filter(Boolean);
const labels = (option?.dataset.maskableLabels || '').split('|').filter(Boolean);
if (!columns.length) {
list.innerHTML = '<span class="text-muted small">선택한 객체에 권한별로 허용할 마스킹 컬럼이 없습니다.</span>';
return;
}
list.innerHTML = columns.map((column, index) => (
`<button class="btn btn-sm btn-outline-secondary question-preset" type="button" data-mask-column="${column}">`
+ `${escapeHtml(labels[index] || column)}</button>`
)).join('');
list.querySelectorAll('[data-mask-column]').forEach((button) => {
button.addEventListener('click', () => {
const input = document.querySelector('input[name="visibleColumns"]');
if (!input) {
return;
}
const current = input.value.split(',').map((value) => value.trim()).filter(Boolean);
const column = button.dataset.maskColumn;
if (!current.includes(column)) {
current.push(column);
}
input.value = current.join(', ');
renderSelectedVisibleColumns();
updatePermissionWizardPreview(document);
});
});
}
function renderSelectedVisibleColumns() {
const input = document.querySelector('input[name="visibleColumns"]');
const list = document.querySelector('[data-selected-visible-columns]');
if (!input || !list) {
return;
}
const columns = input.value.split(',').map((value) => value.trim().toUpperCase()).filter(Boolean);
list.innerHTML = columns.map((column) => (
`<button type="button" class="badge text-bg-secondary border-0 me-1 mb-1" data-remove-visible-column="${column}">`
+ `${escapeHtml(column)} ×</button>`
)).join('');
list.querySelectorAll('[data-remove-visible-column]').forEach((button) => {
button.addEventListener('click', () => {
const remove = button.dataset.removeVisibleColumn;
input.value = columns.filter((column) => column !== remove).join(', ');
renderSelectedVisibleColumns();
updatePermissionWizardPreview(document);
});
});
}
function syncRuleTypeHints(root = document) {
root.querySelectorAll('.rule-row').forEach((row) => {
const typeSelect = row.querySelector('.rule-type-select');
@@ -440,7 +447,6 @@ async function syncRuleColumnOptions() {
renderRuleColumnOptions(columns);
updatePermissionWizardPreview(document);
};
renderMaskableColumnOptions(selectedOption);
applyColumns(fallbackColumns);
try {
const response = await fetch(`/permissions/object-columns?objectRef=${encodeURIComponent(objectSelect.value)}`);
@@ -700,6 +706,54 @@ function sqlLiteral(value) {
return `'${String(value || '').replaceAll("'", "''")}'`;
}
function permissionRuleBusinessLabel(type, column, value) {
const displayColumn = column || {
MY_DEPT: 'DEPT_CODE',
SELF: 'OWNER_EMP_NO',
DEPT: 'DEPT_CODE',
EMP_NO: 'OWNER_EMP_NO',
TAG: 'TECH_TAG',
TOKEN_SUBJECT: 'USER_ID',
OWN_CONTRACT: 'FC_ID',
CHANNEL_CONTRACT: 'FC_CHANNEL',
OWN_CUSTOMER: 'CUST_ID/CONTRACT_NO',
CHANNEL_CUSTOMER: 'CUST_ID/CONTRACT_NO',
STATIC_SQL: '',
ALL: ''
}[type] || '선택 컬럼';
if (type === 'ALL') {
return '전체 행';
}
if (type === 'TOKEN_SUBJECT') {
return `토큰 이해관계자 본인 행 (${displayColumn})`;
}
if (type === 'OWN_CONTRACT') {
return `담당 설계사 본인 계약 (${displayColumn})`;
}
if (type === 'CHANNEL_CONTRACT') {
return `토큰 사용자 채널 계약 (${displayColumn})`;
}
if (type === 'OWN_CUSTOMER') {
return `담당 설계사 본인 계약에 연결된 고객/청구/외부보유 (${displayColumn})`;
}
if (type === 'CHANNEL_CUSTOMER') {
return `토큰 사용자 채널 계약에 연결된 고객/청구/외부보유 (${displayColumn})`;
}
if (type === 'STATIC_SQL') {
return `정적 SQL 조건: ${value || '조건식 미입력'}`;
}
if (type === 'MY_DEPT') {
return `내 부서 행 (${displayColumn})`;
}
if (type === 'SELF') {
return `내 사번/소유자 행 (${displayColumn})`;
}
if (type === 'TAG') {
return `태그 조건 ${displayColumn} contains ${value || '-'}`;
}
return [displayColumn, type, value].filter(Boolean).join(' ');
}
function collectWizardRules(root) {
return Array.from(root.querySelectorAll('.rule-row')).map((row) => {
const column = row.querySelector('[name="ruleColumn"]')?.value || '';
@@ -721,25 +775,7 @@ function collectWizardRules(root) {
STATIC_SQL: '',
ALL: ''
}[type] || '';
if (type === 'ALL') {
return 'ALL';
}
if (type === 'STATIC_SQL') {
return `정적 SQL: ${value}`;
}
if (['MY_DEPT', 'SELF'].includes(type)) {
return `${displayColumn} ${type}`;
}
if (['STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL'].includes(type)) {
return `${displayColumn} ${type} 역할=${value}`;
}
if (['TOKEN_SUBJECT', 'OWN_CONTRACT', 'CHANNEL_CONTRACT', 'OWN_CUSTOMER', 'CHANNEL_CUSTOMER'].includes(type)) {
return `${displayColumn} ${type}`;
}
if (type === 'TAG') {
return `${displayColumn} TAG ${value.toUpperCase()}`;
}
return [displayColumn, type, value].filter(Boolean).join(' ');
return permissionRuleBusinessLabel(type, displayColumn, value);
}).filter(Boolean);
}
@@ -821,7 +857,6 @@ function updatePermissionWizardPreview(root = document) {
const roleSelect = wizard.querySelector('[name="roleId"]');
const objectSelect = wizard.querySelector('[name="objectRef"]');
const effectSelect = wizard.querySelector('[name="permissionEffect"]');
const visibleColumns = wizard.querySelector('[name="visibleColumns"]')?.value.trim() || '';
const roleOption = selectedOption(roleSelect);
const objectOption = selectedOption(objectSelect);
const effect = effectSelect?.value || 'ALLOW';
@@ -837,20 +872,15 @@ function updatePermissionWizardPreview(root = document) {
const groupUsers = splitList(roleOption?.dataset.groupUsers || '');
const objectColumns = splitList(objectOption?.dataset.columns || '', ',');
const maskableColumns = splitList(objectOption?.dataset.maskableColumns || '', ',');
const visibleColumnList = splitList(visibleColumns, ',').map((column) => column.toUpperCase());
const nullColumns = maskableColumns.filter((column) => !visibleColumnList.includes(column.toUpperCase()));
const rowPolicy = effect === 'DENY'
? `거부 규칙: ${ruleText}`
: `허용 규칙: ${ruleText}`;
const predicateText = effect === 'DENY'
? (rules.includes('ALL') ? 'DENY ALL: 1 = 0' : `DENY 후보: NOT (${predicates.join(' AND ') || '조건 없음'})`)
: `ALLOW 후보: ${predicates.join(' AND ') || '조건 없음'}`;
const columnPolicy = visibleColumns
? `이 권한에서 원문 표시 허용: ${visibleColumns}`
: '마스킹 대상 컬럼은 기본 정책대로 NULL/마스킹 처리';
const nullPolicy = !maskableColumns.length
? '마스킹 대상 컬럼 없음'
: (nullColumns.length ? `NULL 처리: ${nullColumns.join(', ')}` : '선택한 마스킹 컬럼 모두 원문 표시 허용');
const columnPolicy = maskableColumns.length
? `ASO 마스킹 후보: ${maskableColumns.join(', ')}. 실제 원문/마스킹은 컬럼 마스킹 화면에서 설정합니다.`
: '이 행 접근 규칙은 행만 제어합니다. 컬럼 원문/마스킹은 컬럼 마스킹에서 관리합니다.';
const affectedPrincipals = `직접 사용자 ${directUsers.length}명 / 그룹 ${groups.length}개 / 그룹 상속 사용자 ${groupUsers.length}`;
const readiness = !hasRole
? '역할을 선택하세요'
@@ -883,7 +913,6 @@ function updatePermissionWizardPreview(root = document) {
setWizardPreview(wizard, 'rowPolicy', rowPolicy);
setWizardPreview(wizard, 'predicatePreview', predicateText);
setWizardPreview(wizard, 'columnPolicy', columnPolicy);
setWizardPreview(wizard, 'nullPolicy', nullPolicy);
setWizardPreview(wizard, 'saveGuard', hasRole && hasObject ? saveGuard : '역할과 보호 객체를 선택하면 저장 영향을 계산합니다.');
}
@@ -1185,12 +1214,72 @@ function initVpdTargetFilters() {
applyFilters();
}
function initMaskingExceptionPreview() {
const preview = document.querySelector('[data-masking-exception-preview]');
const columnSelect = document.querySelector('select[name="columnId"]');
const userSelect = document.querySelector('select[name="userId"]');
if (!preview || !columnSelect) {
return;
}
const empty = preview.querySelector('[data-masking-preview-empty]');
const details = preview.querySelector('[data-masking-preview-details]');
const target = preview.querySelector('[data-masking-preview-target]');
const masked = preview.querySelector('[data-masking-preview-masked]');
const unmasked = preview.querySelector('[data-masking-preview-unmasked]');
const context = preview.querySelector('[data-masking-preview-context]');
const update = () => {
const option = columnSelect.options[columnSelect.selectedIndex];
if (!option?.value) {
if (empty) {
empty.hidden = false;
}
if (details) {
details.hidden = true;
}
return;
}
const selectedUser = userSelect?.options[userSelect.selectedIndex];
const userLabel = selectedUser?.dataset.userLabel || '선택한 사용자';
const columnTarget = option.dataset.target || option.textContent?.trim() || '선택한 컬럼';
const template = option.dataset.template || '컬럼 마스킹 규칙';
const result = option.dataset.result || '마스킹된 값';
const asoFunction = option.dataset.asoFunction || 'DBMS_REDACT';
const contextName = option.dataset.context || 'MR_<column_id>';
if (empty) {
empty.hidden = true;
}
if (details) {
details.hidden = false;
}
if (target) {
target.textContent = `${columnTarget} · ${template} (${asoFunction})`;
}
if (masked) {
masked.textContent = `${columnTarget} 값은 ${result}로 반환됩니다.`;
}
if (unmasked) {
unmasked.textContent = `${userLabel}은(는) 행 접근 정책이 허용한 행에서 원래 ${columnTarget} 값을 봅니다.`;
}
if (context) {
context.textContent = `CB_AGENT_CTX.${contextName} = Y이면 원문, 기본값 또는 N이면 ${asoFunction}으로 마스킹합니다.`;
}
};
columnSelect.addEventListener('change', update);
userSelect?.addEventListener('change', update);
update();
}
document.addEventListener('DOMContentLoaded', () => {
initPersistentMenus();
initQuestionPresets();
initCopyButtons();
renderMarkdownViews();
initVpdTargetFilters();
initMaskingExceptionPreview();
const master = document.getElementById('userRoleMaster');
if (master) {
master.addEventListener('change', filterUserRoleDetail);
@@ -1219,8 +1308,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
syncRuleTypeHints();
document.querySelector('input[name="visibleColumns"]')?.addEventListener('input', renderSelectedVisibleColumns);
renderSelectedVisibleColumns();
const catalog = document.getElementById('objectCatalogSelect');
if (catalog) {
catalog.addEventListener('change', syncObjectCatalogSelection);
@@ -1261,6 +1348,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
updatePermissionWizardPreview(document);
disableReadOnlyMutationForms();
});
document.body.addEventListener('htmx:beforeRequest', (event) => {
@@ -1284,4 +1372,5 @@ document.body.addEventListener('htmx:afterSwap', (event) => {
button.classList.add('active');
}
renderMarkdownViews(event.detail.target || document);
disableReadOnlyMutationForms();
});

View File

@@ -1,31 +1,31 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('VPD 권한 운영')}"></head>
<head th:replace="~{fragments/layout :: head('데이터 접근 제어')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4 dashboard-page">
<header class="dashboard-hero">
<div>
<span class="architecture-kicker">VPD 권한 운영</span>
<h1>권한 운영 흐름</h1>
<span class="architecture-kicker">데이터 접근 제어</span>
<h1>행 접근과 컬럼 마스킹 운영 흐름</h1>
<p>업무 사용자와 데이터 접근 기준을 관리하고, DB가 적용한 결과까지 확인합니다.</p>
</div>
<div class="dashboard-hero-actions" aria-label="주요 작업">
<a class="btn rw-btn-primary" href="/probe">접근 검증</a>
<a class="btn rw-btn-secondary" href="/permissions">접근 규칙</a>
<a class="btn rw-btn-secondary" href="/permissions">접근 규칙</a>
</div>
<details class="explanation-details dashboard-explanation">
<summary>도움말: 메뉴와 권한 적용 구조 보기</summary>
<div class="product-help-overview">
<section class="product-help-section">
<h2>왜 권한 테이블을 따로 관리하나요?</h2>
<p>DB 연결은 VPD/DDS 실행 계정 하나로 유지하되, 실제 업무 사용자는 <code>CB_APP_USER</code>와 역할·권한 테이블에서 찾습니다. 요청마다 토큰이 사용자를 식별하고 VPD가 그 사용자의 권한만 조건으로 계산하므로, 같은 실행 계정으로도 사용자마다 다른 행과 컬럼을 안전하게 반환할 수 있습니다.</p>
<p>DB 연결은 공용 실행 계정 하나로 유지하되, 실제 업무 사용자는 <code>CB_APP_USER</code>와 역할·권한 테이블에서 찾습니다. 요청마다 토큰이 사용자를 식별하고 행 접근 정책이 그 사용자의 조건 계산하며, 컬럼 원문/마스킹은 ASO/Data Redaction 정책이 별도로 판단합니다.</p>
</section>
<section class="product-help-section">
<h2>메뉴 안내</h2>
<dl class="menu-role-grid">
<div><dt>권한 관리</dt><dd>사용자·그룹·역할과 접근 규칙을 정하고, 최종 권한을 확인합니다.</dd></div>
<div><dt>접근 제어 관리</dt><dd>사용자·그룹·역할과 접근 규칙, 컬럼 마스킹 설정을 정하고 최종 적용 결과를 확인합니다.</dd></div>
<div><dt>보호·검증</dt><dd>보호 정책을 연결하고 토큰으로 실제 DB 조회 결과를 검증합니다.</dd></div>
<div><dt>연동 도구</dt><dd>조회 대상, ORDS 연동, 지식 검색과 MCP 연결을 관리합니다.</dd></div>
<div><dt>운영</dt><dd>현재 연결과 실행 상태를 확인합니다.</dd></div>
@@ -38,8 +38,8 @@
<p>권한을 먼저 만들고 보호 대상을 연결한 뒤, 실제 사용자 토큰으로 결과를 검증합니다. 권한은 토큰에 복사되지 않아 이후 변경도 다음 요청부터 반영됩니다.</p>
<div class="diagram-canvas">
<svg class="product-flow-diagram setup-flow-diagram" viewBox="0 0 1120 245" role="img" aria-labelledby="setup-flow-title setup-flow-desc">
<title id="setup-flow-title">VPD 권한 설정과 검증 순서</title>
<desc id="setup-flow-desc">사용자와 그룹, 역할, 접근 규칙, 보호 대상을 차례로 설정하고 유효 권한과 실제 조회 결과를 검증하는 흐름</desc>
<title id="setup-flow-title">데이터 접근 제어 설정과 검증 순서</title>
<desc id="setup-flow-desc">사용자와 그룹, 역할, 접근 규칙, 컬럼 마스킹, 보호 대상을 차례로 설정하고 실제 조회 결과를 검증하는 흐름</desc>
<defs><marker id="setup-flow-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z"/></marker></defs>
<path class="diagram-link" marker-end="url(#setup-flow-arrow)" d="M205 100 H238"/>
<path class="diagram-link" marker-end="url(#setup-flow-arrow)" d="M415 100 H448"/>
@@ -47,11 +47,11 @@
<path class="diagram-link" marker-end="url(#setup-flow-arrow)" d="M835 100 H868"/>
<g class="diagram-node diagram-node-step"><rect x="30" y="52" width="175" height="96" rx="12"/><circle cx="57" cy="77" r="15"/><text x="57" y="82" class="diagram-step-number">1</text><text x="117" y="85" class="diagram-node-title">사용자·그룹</text><text x="117" y="113" class="diagram-node-detail">업무 대상을 등록</text></g>
<g class="diagram-node diagram-node-step"><rect x="240" y="52" width="175" height="96" rx="12"/><circle cx="267" cy="77" r="15"/><text x="267" y="82" class="diagram-step-number">2</text><text x="327" y="85" class="diagram-node-title">역할</text><text x="327" y="113" class="diagram-node-detail">직접·그룹 역할 부여</text></g>
<g class="diagram-node diagram-node-step diagram-node-accent"><rect x="450" y="52" width="175" height="96" rx="12"/><circle cx="477" cy="77" r="15"/><text x="477" y="82" class="diagram-step-number">3</text><text x="537" y="85" class="diagram-node-title">접근 규칙</text><text x="537" y="113" class="diagram-node-detail">객체·행·컬럼 설정</text></g>
<g class="diagram-node diagram-node-step diagram-node-accent"><rect x="660" y="52" width="175" height="96" rx="12"/><circle cx="687" cy="77" r="15"/><text x="687" y="82" class="diagram-step-number">4</text><text x="747" y="85" class="diagram-node-title">보호·연결</text><text x="747" y="113" class="diagram-node-detail">VPD·ORDS 대상 확인</text></g>
<g class="diagram-node diagram-node-step diagram-node-accent"><rect x="450" y="52" width="175" height="96" rx="12"/><circle cx="477" cy="77" r="15"/><text x="477" y="82" class="diagram-step-number">3</text><text x="537" y="85" class="diagram-node-title">접근 규칙</text><text x="537" y="113" class="diagram-node-detail">객체·행 조건 설정</text></g>
<g class="diagram-node diagram-node-step diagram-node-accent"><rect x="660" y="52" width="175" height="96" rx="12"/><circle cx="687" cy="77" r="15"/><text x="687" y="82" class="diagram-step-number">4</text><text x="747" y="85" class="diagram-node-title">보호·연결</text><text x="747" y="113" class="diagram-node-detail">정책·ORDS 대상 확인</text></g>
<g class="diagram-node diagram-node-step diagram-node-data"><rect x="870" y="52" width="220" height="96" rx="12"/><circle cx="897" cy="77" r="15"/><text x="897" y="82" class="diagram-step-number">5</text><text x="977" y="85" class="diagram-node-title">유효 권한·접근 검증</text><text x="977" y="113" class="diagram-node-detail">토큰으로 실제 결과 확인</text></g>
<path class="setup-flow-return" d="M980 166 V192 H117 V166"/>
<text x="548" y="218" class="setup-flow-note">규칙 변경은 다음 요청의 VPD 조건 계산에 동적으로 반영됩니다.</text>
<text x="548" y="218" class="setup-flow-note">규칙 변경은 다음 요청의 조건 계산에 동적으로 반영됩니다.</text>
</svg>
</div>
</section>
@@ -60,8 +60,8 @@
<h2>요청마다 권한이 적용되는 흐름</h2>
<div class="diagram-canvas">
<svg class="product-flow-diagram" viewBox="0 0 1120 270" role="img" aria-labelledby="vpd-flow-title vpd-flow-desc">
<title id="vpd-flow-title">VPD 권한 적용 흐름</title>
<desc id="vpd-flow-desc">Bearer Token 요청이 공용 실행 계정과 사용자 컨텍스트를 거쳐 권한 테이블에서 계산한 VPD 조건으로 보호 데이터를 조회하는 흐름</desc>
<title id="vpd-flow-title">행 접근 적용 흐름</title>
<desc id="vpd-flow-desc">Bearer Token 요청이 공용 실행 계정과 사용자 컨텍스트를 거쳐 권한 테이블에서 계산한 조건으로 보호 데이터를 조회하는 흐름</desc>
<defs><marker id="flow-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z"/></marker></defs>
<path class="diagram-link" marker-end="url(#flow-arrow)" d="M190 92 H245"/>
<path class="diagram-link" marker-end="url(#flow-arrow)" d="M395 92 H450"/>
@@ -71,8 +71,8 @@
<g class="diagram-node"><rect x="30" y="48" width="160" height="88" rx="12"/><text x="110" y="81" class="diagram-node-title">요청·토큰</text><text x="110" y="108" class="diagram-node-detail">Bearer Token</text></g>
<g class="diagram-node"><rect x="245" y="48" width="150" height="88" rx="12"/><text x="320" y="78" class="diagram-node-title">공용 실행 계정</text><text x="320" y="105" class="diagram-node-detail">CB_ORDS</text><text x="320" y="123" class="diagram-node-note">한 개의 DB 연결</text></g>
<g class="diagram-node"><rect x="450" y="48" width="150" height="88" rx="12"/><text x="525" y="78" class="diagram-node-title">사용자 컨텍스트</text><text x="525" y="105" class="diagram-node-detail">CB_AGENT_CTX</text><text x="525" y="123" class="diagram-node-note">사용자별로 설정</text></g>
<g class="diagram-node diagram-node-accent"><rect x="655" y="48" width="150" height="88" rx="12"/><text x="730" y="78" class="diagram-node-title">VPD 조건 계산</text><text x="730" y="105" class="diagram-node-detail">ALLOW · DENY · 행 · 열</text></g>
<g class="diagram-node diagram-node-accent"><rect x="860" y="48" width="210" height="88" rx="12"/><text x="965" y="78" class="diagram-node-title">보호 데이터 조회</text><text x="965" y="105" class="diagram-node-detail">허용된 행·컬럼만 반환</text></g>
<g class="diagram-node diagram-node-accent"><rect x="655" y="48" width="150" height="88" rx="12"/><text x="730" y="78" class="diagram-node-title"> 조건 계산</text><text x="730" y="105" class="diagram-node-detail">ALLOW · DENY · predicate</text></g>
<g class="diagram-node diagram-node-accent"><rect x="860" y="48" width="210" height="88" rx="12"/><text x="965" y="78" class="diagram-node-title">보호 데이터 조회</text><text x="965" y="105" class="diagram-node-detail">허용된 행 반환</text></g>
<g class="diagram-node diagram-node-data"><rect x="570" y="180" width="300" height="62" rx="12"/><text x="720" y="207" class="diagram-node-title">사용자 · 역할 · 권한 테이블</text><text x="720" y="229" class="diagram-node-detail">요청 시 동적으로 조회</text></g>
</svg>
</div>
@@ -82,7 +82,7 @@
<h2>권한 데이터 모델</h2>
<div class="diagram-canvas">
<svg class="permission-erd-diagram" viewBox="0 0 1180 355" role="img" aria-labelledby="erd-title erd-desc">
<title id="erd-title">VPD 권한 ERD</title>
<title id="erd-title">접근 제어 ERD</title>
<desc id="erd-desc">사용자와 그룹에서 역할을 얻고 역할에서 권한과 권한 규칙을 거쳐 보호 대상으로 연결되는 데이터 모델</desc>
<defs><marker id="erd-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z"/></marker></defs>
<path class="diagram-link" marker-end="url(#erd-arrow)" d="M175 90 H230"/><path class="diagram-link" marker-end="url(#erd-arrow)" d="M390 90 H445"/><path class="diagram-link" marker-end="url(#erd-arrow)" d="M605 90 H660"/><path class="diagram-link" marker-end="url(#erd-arrow)" d="M820 90 H875"/>
@@ -92,7 +92,7 @@
<g class="erd-entity"><rect x="230" y="50" width="160" height="80" rx="10"/><text x="310" y="80">CB_USER_ROLE</text><text x="310" y="105">직접 역할 연결</text></g>
<g class="erd-entity"><rect x="445" y="50" width="160" height="80" rx="10"/><text x="525" y="80">CB_APP_ROLE</text><text x="525" y="105">역할</text></g>
<g class="erd-entity"><rect x="660" y="50" width="160" height="80" rx="10"/><text x="740" y="80">CB_PERMISSION</text><text x="740" y="105">객체 접근</text></g>
<g class="erd-entity"><rect x="875" y="50" width="160" height="80" rx="10"/><text x="955" y="80">CB_PERMISSION_RULE</text><text x="955" y="105">·열 조건</text></g>
<g class="erd-entity"><rect x="875" y="50" width="160" height="80" rx="10"/><text x="955" y="80">CB_PERMISSION_RULE</text><text x="955" y="105">행 조건 매핑</text></g>
<g class="erd-entity erd-entity-accent"><rect x="1045" y="50" width="115" height="80" rx="10"/><text x="1102" y="80">보호 대상</text><text x="1102" y="105">TABLE / VIEW</text></g>
<g class="erd-entity"><rect x="25" y="205" width="150" height="80" rx="10"/><text x="100" y="235">CB_USER_GROUP</text><text x="100" y="260">그룹 소속</text></g>
<g class="erd-entity"><rect x="230" y="205" width="160" height="80" rx="10"/><text x="310" y="235">CB_GROUP</text><text x="310" y="260">사용자 그룹</text></g>
@@ -119,10 +119,10 @@
<section class="dashboard-menu-flow" aria-labelledby="menu-flow-title">
<div class="dashboard-section-heading">
<span class="architecture-kicker">업무 흐름</span>
<h2 id="menu-flow-title">설정은 권한 관리에서, 적용 확인은 접근 검증에서</h2>
<h2 id="menu-flow-title">설정은 접근 제어에서, 적용 확인은 접근 검증에서</h2>
</div>
<div class="dashboard-flow-grid">
<a class="dashboard-flow-card" href="/permissions"><strong>권한 관리</strong><span>사용자·역할·접근 규칙</span></a>
<a class="dashboard-flow-card" href="/permissions"><strong>행 접근 규칙</strong><span>역할별 객체·행 조건</span></a>
<a class="dashboard-flow-card" href="/vpd-policies"><strong>보호·검증</strong><span>보호 연결·검증 세션·접근 확인</span></a>
<a class="dashboard-flow-card" href="/objects"><strong>연동 도구</strong><span>조회 대상·지식 검색·MCP 연동</span></a>
<a class="dashboard-flow-card" href="/operation-status"><strong>운영</strong><span>상태와 실행 결과 확인</span></a>
@@ -134,7 +134,7 @@
<h2>바로 시작</h2>
<div class="dashboard-action-grid">
<a class="dashboard-action" href="/permissions">
<span><strong>접근 규칙</strong><small>역할별 객체·행·컬럼 접근을 설정합니다.</small></span>
<span><strong>접근 규칙</strong><small>역할별 객체·행 조건을 설정합니다. 컬럼은 ASO 마스킹에서 관리합니다.</small></span>
<span class="dashboard-action-arrow" aria-hidden="true"></span>
</a>
<a class="dashboard-action dashboard-action-emphasis" href="/probe">
@@ -161,7 +161,7 @@
<div class="section-heading">
<div>
<h2>현재 검증 가능한 데이터</h2>
<p class="section-subtitle">권한 규칙과 ORDS 경로가 등록된 보호 객체입니다.</p>
<p class="section-subtitle">행 접근 규칙과 ORDS 경로가 등록된 보호 객체입니다.</p>
</div>
<a class="btn btn-sm rw-btn-primary" href="/probe">접근 검증</a>
</div>
@@ -174,7 +174,7 @@
<td><code th:text="${object.ordsPath()}">path</code></td>
<td><span class="badge text-bg-success">사용 가능</span></td>
</tr>
<tr th:if="${#lists.isEmpty(objects)}"><td colspan="3" class="text-muted">아직 검증할 보호 객체가 없습니다. 권한 규칙에서 객체 권한을 먼저 등록하세요.</td></tr>
<tr th:if="${#lists.isEmpty(objects)}"><td colspan="3" class="text-muted">아직 검증할 보호 객체가 없습니다. 행 접근 규칙에서 객체 접근을 먼저 등록하세요.</td></tr>
</tbody>
</table>
</div>

View File

@@ -8,7 +8,7 @@
<h1>사용자별 접근 확인</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>직접 역할과 그룹 상속 역할을 합쳐 최종 권한을 계산합니다. 여기의 결과는 토큰을 발급해 ORDS/VPD 조회를 검증하기 전 확인하는 설계 근거입니다.</p>
<p>직접 역할과 그룹 상속 역할을 합쳐 최종 권한을 계산합니다. 여기의 결과는 토큰을 발급해 ORDS 행 접근 조회를 검증하기 전 확인하는 설계 근거입니다.</p>
</details>
</div>

View File

@@ -3,7 +3,9 @@
<head th:fragment="head(title)">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${title == 'VPD 권한 운영' ? title : title + ' · VPD 권한 운영'}">VPD 권한 운영</title>
<meta name="backoffice-can-mutate" th:content="${canMutate}">
<meta name="backoffice-read-only" th:content="${readOnlyMode}">
<title th:text="${title == '데이터 접근 제어' ? title : title + ' · 데이터 접근 제어'}">데이터 접근 제어</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/css/app.css" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
@@ -13,15 +15,15 @@
<body>
<nav th:fragment="nav" class="navbar rw-nav navbar-expand-lg" data-product-nav>
<div class="container rw-nav-top">
<a class="navbar-brand" href="/">VPD 권한 운영</a>
<a class="navbar-brand" href="/">데이터 접근 제어</a>
<div class="rw-menu" aria-label="주요 메뉴">
<button class="rw-menu-trigger" type="button" data-submenu-trigger="access" aria-controls="submenu-access" aria-expanded="false">권한 관리</button>
<button class="rw-menu-trigger" type="button" data-submenu-trigger="access" aria-controls="submenu-access" aria-expanded="false">접근 제어</button>
<button class="rw-menu-trigger" type="button" data-submenu-trigger="protection" aria-controls="submenu-protection" aria-expanded="false">보호·검증</button>
<button class="rw-menu-trigger" type="button" data-submenu-trigger="integration" aria-controls="submenu-integration" aria-expanded="false">연동 도구</button>
<button class="rw-menu-trigger" type="button" data-submenu-trigger="operations" aria-controls="submenu-operations" aria-expanded="false">운영</button>
</div>
<div class="rw-nav-utility ms-auto">
<a class="rw-quick-link" href="/permissions">권한 설정</a>
<a class="rw-quick-link" href="/permissions">행 접근 규칙</a>
<a class="rw-quick-link" href="/tokens">토큰 발급</a>
<button class="rw-admin-trigger" type="button" data-submenu-trigger="admin" aria-controls="submenu-admin" aria-expanded="false">관리자</button>
<form method="post" action="/logout">
@@ -32,15 +34,17 @@
</div>
<div class="rw-submenu-bar" data-submenu-bar hidden>
<div class="container">
<div id="submenu-access" class="rw-submenu" data-submenu-panel="access" role="navigation" aria-label="권한 관리 메뉴" hidden>
<div id="submenu-access" class="rw-submenu" data-submenu-panel="access" role="navigation" aria-label="접근 제어 메뉴" hidden>
<a class="nav-link" href="/users">사용자 관리</a>
<a class="nav-link" href="/groups">그룹 관리</a>
<a class="nav-link" href="/roles">역할 관리</a>
<a class="nav-link" href="/permissions">권한 설정</a>
<a class="nav-link" href="/permissions">행 접근 규칙</a>
<a class="nav-link" href="/user-masking-rules">컬럼 원문 표시 허용</a>
<a class="nav-link" href="/effective-matrix">권한 현황</a>
</div>
<div id="submenu-protection" class="rw-submenu" data-submenu-panel="protection" role="navigation" aria-label="보호 및 검증 메뉴" hidden>
<a class="nav-link" href="/vpd-policies">보호 상태</a>
<a class="nav-link" href="/masking-rules">컬럼 마스킹</a>
<a class="nav-link" href="/tokens">토큰 발급</a>
<a class="nav-link" href="/probe">접근 검증</a>
</div>
@@ -58,6 +62,9 @@
<a class="nav-link" href="/operation-status">운영 현황</a>
</div>
<div id="submenu-admin" class="rw-submenu" data-submenu-panel="admin" role="navigation" aria-label="관리자 메뉴" hidden>
<a class="nav-link" href="/vpd-filter-runtime">행 접근 필터 구조</a>
<a class="nav-link" href="/schema-metadata">DB 메타데이터</a>
<a class="nav-link" href="/security-sql-scripts">보안 SQL 스크립트</a>
<a class="nav-link" href="/vpd-filter-policies">고급 접근 조건</a>
<a class="nav-link" href="/settings">시스템 설정</a>
<a class="nav-link" href="/settings/database">DB 준비 상태</a>

View File

@@ -48,24 +48,41 @@
<section class="result-section">
<div class="section-heading compact-heading">
<h3>VPD가 적용된 실제 결과</h3>
<h3>행 접근 정책이 적용된 실제 결과</h3>
<span th:if="${selectedObject}"><code th:text="${selectedObject.displayName()}">ADMIN.OBJECT</code></span>
</div>
<div class="result-metrics">
<div><span>보이는</span><strong th:text="${result.rowCount()}">0</strong></div>
<div><span>행 접근 후 남은</span><strong th:text="${result.rowCount()}">0</strong></div>
<div>
<span>NULL/마스킹으로 확인 컬럼</span>
<span>ASO 마스킹 확인 컬럼</span>
<strong th:if="${!#lists.isEmpty(result.maskedColumns())}" th:text="${#strings.listJoin(result.maskedColumns(), ', ')}">column</strong>
<strong th:if="${#lists.isEmpty(result.maskedColumns())}">없음</strong>
</div>
</div>
<div class="effective-preview mt-3">
<dl>
<div>
<dt>행 접근 판단</dt>
<dd th:text="${result.rowCount() == 0 ? '토큰 context와 접근 규칙 기준으로 반환 가능한 행이 없습니다.' : '토큰 context와 접근 규칙 기준으로 반환 가능한 행이 있습니다.'}">반환 가능</dd>
</div>
<div>
<dt>ASO 컬럼 표시 판단</dt>
<dd th:if="${!#lists.isEmpty(result.maskedColumns())}" th:text="${'마스킹 또는 NULL로 반환된 민감 컬럼: ' + #strings.listJoin(result.maskedColumns(), ', ')}">마스킹 컬럼</dd>
<dd th:if="${#lists.isEmpty(result.maskedColumns())}">응답 기준으로 전체 NULL/마스킹 처리된 민감 컬럼을 찾지 못했습니다. 원문 허용이거나 해당 컬럼이 응답에 없을 수 있습니다.</dd>
</div>
<div>
<dt>확인 기준</dt>
<dd>행 개수는 행 접근 정책 결과이고, 컬럼 값은 ASO/Data Redaction 결과입니다. 두 판단은 별도로 봐야 합니다.</dd>
</div>
</dl>
</div>
<div class="vector-result-panel mt-3"
th:if="${vectorSearch and !#lists.isEmpty(result.rows())}">
<div class="section-heading compact-heading">
<div>
<h3>벡터 검색 Top-K</h3>
<p class="section-subtitle">거리(SCORE)가 낮은 순서로 VPD를 통과한 검색 단위를 반환했습니다.</p>
<p class="section-subtitle">거리(SCORE)가 낮은 순서로 행 접근 정책을 통과한 검색 단위를 반환했습니다.</p>
</div>
<span class="badge text-bg-light" th:text="${'K=' + result.rowCount()}">K=0</span>
</div>
@@ -145,8 +162,8 @@
<div>
<h3>참고용 SQL 재현</h3>
<p class="section-subtitle"
th:text="${vectorSearch ? 'VECTOR_DISTANCE 검색과 권한 규칙을 조합한 참고용 표현입니다.' : '현재 권한 규칙을 조합한 참고용 표현입니다.'}">
현재 권한 규칙을 조합한 참고용 표현입니다.
th:text="${vectorSearch ? 'VECTOR_DISTANCE 검색과 행 접근 규칙을 조합한 참고용 표현입니다.' : '현재 행 접근 규칙을 조합한 참고용 표현입니다.'}">
현재 행 접근 규칙을 조합한 참고용 표현입니다.
</p>
</div>
<span class="badge text-bg-light">참고용</span>
@@ -154,7 +171,7 @@
<div class="probe-exchange-grid mt-3">
<section class="probe-exchange" data-sql-trace-field="vpd_context" th:if="${tokenContext}">
<h3>set_vpd_context 사용자 컨텍스트</h3>
<p class="form-hint">이 컨텍스트로 권한 규칙을 계산한 뒤 아래 VPD predicate와 effective SQL을 만들었습니다.</p>
<p class="form-hint">이 컨텍스트로 행 접근 규칙을 계산한 뒤 아래 VPD predicate와 effective SQL을 만들었습니다.</p>
<dl class="mb-0">
<div><dt>CB_AGENT_CTX.USER_ID</dt><dd th:text="${tokenContext.userId()}">103</dd></div>
<div><dt>사용자</dt><dd th:text="${tokenContext.username()}">김어드민</dd></div>
@@ -180,7 +197,7 @@ ROWNUM &lt;= :row_limit</pre>
<p class="form-hint mb-0">이 부분이 검색어 벡터와 저장 벡터의 거리 계산입니다. 아래에는 실제로 등록된 역할 기반 행 접근 조건만 표시됩니다.</p>
</section>
<section class="probe-exchange" data-sql-trace-field="vpd_predicate">
<h3 th:text="${vectorSearch ? '역할 기반 권한 필터 (재현)' : 'VPD 조건 (재현)'}">VPD 조건 (재현)</h3>
<h3 th:text="${vectorSearch ? '역할 기반 권한 필터 (재현)' : '행 접근 조건 (VPD predicate 재현)'}">행 접근 조건 (VPD predicate 재현)</h3>
<pre th:if="${result.vpdPredicate() == '1 = 1'}">1 = 1 (ALL: 추가 행 필터 없음)</pre>
<pre th:unless="${result.vpdPredicate() == '1 = 1'}" th:text="${result.vpdPredicate()}">(DEPT_CODE = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE'))</pre>
<p class="form-hint mb-0" th:if="${vectorSearch}">선택한 사용자의 직접 역할·그룹 상속 역할에 연결된 permission rule에서 계산됩니다. 기본 whitelist 역할의 ALL은 추가 행 필터 없이 조회를 허용하고, 실제로 TAG·부서 조건을 등록한 역할만 그 조건이 SQL에 들어갑니다. 권한이 없으면 <code>1 = 0</code>입니다.</p>
@@ -191,7 +208,7 @@ ROWNUM &lt;= :row_limit</pre>
</section>
</div>
<p class="form-hint mt-2 mb-0">
이 영역은 권한 규칙을 읽기 쉽게 재현한 표현일 뿐, 실행 증적이 아닙니다. 실제 SQL과 VPD predicate는 위의 DB 감사 실행 증적(FGA)으로 확인합니다. 컬럼 마스킹은 별도 Redaction 정책입니다.
이 영역은 행 접근 규칙을 읽기 쉽게 재현한 표현일 뿐, 실행 증적이 아닙니다. 실제 SQL과 VPD predicate는 위의 DB 감사 실행 증적(FGA)으로 확인합니다. 컬럼 마스킹은 별도 Redaction 정책입니다.
</p>
</section>
@@ -209,7 +226,7 @@ ROWNUM &lt;= :row_limit</pre>
<p th:text="${result.nextAction()}">다음 행동</p>
<div class="action-stack">
<a class="btn btn-sm rw-btn-secondary" href="/effective-matrix">사용자별 최종 권한 보기</a>
<a class="btn btn-sm rw-btn-secondary" href="/permissions">권한 규칙 보기</a>
<a class="btn btn-sm rw-btn-secondary" href="/permissions">행 접근 규칙 보기</a>
<a class="btn btn-sm rw-btn-secondary" href="/tokens" th:if="${result.status().name() == 'TOKEN_NOT_FOUND' || result.status().name() == 'TOKEN_INACTIVE'}">새 토큰 발급</a>
</div>
</section>

View File

@@ -0,0 +1,25 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="explanation">
<section class="alert alert-warning mb-3" th:if="${errorMessage}" th:text="${errorMessage}">설명을 생성할 수 없습니다.</section>
<section th:if="${explanation}" class="ai-answer">
<div class="section-heading mb-2">
<div>
<h2>LLM 스크립트 설명</h2>
<p class="section-subtitle" th:text="${explanation.script().fileName() + ' · 토큰 처리와 행 접근/ASO 적용 흐름 포함'}">script.sql</p>
</div>
<div class="action-stack">
<span class="badge text-bg-secondary" th:text="${explanation.modelName()} ?: 'model not configured'">model</span>
<span class="badge" th:classappend="${explanation.status() == 'SUCCESS'} ? ' text-bg-success' : ' text-bg-warning'" th:text="${explanation.status()}">SUCCESS</span>
</div>
</div>
<div class="markdown-view" data-markdown-view th:text="${explanation.answer()}">설명</div>
<details class="explanation-details mt-3">
<summary>LLM에 전달한 근거 Prompt 보기</summary>
<pre th:text="${explanation.prompt()}">prompt</pre>
</details>
</section>
</div>
</body>
</html>

View File

@@ -1,13 +1,13 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('VPD 권한 운영')}"></head>
<head th:replace="~{fragments/layout :: head('데이터 접근 제어')}"></head>
<body class="login-body">
<main class="login-shell">
<section class="login-panel">
<div class="login-brand">
<span class="login-mark">VPD</span>
<span class="login-mark">접근</span>
<div>
<h1>VPD 권한 운영 콘솔</h1>
<h1>데이터 접근 제어 콘솔</h1>
</div>
</div>
@@ -28,6 +28,13 @@
Admin Password
<input class="form-control" name="password" type="password" autocomplete="current-password" required>
</label>
<label class="form-check" th:if="${rememberMeAvailable}">
<!-- Persistent login is opt-out for this internal HTTPS backoffice.
The server issues only a signed HttpOnly/Secure cookie; no password
or bearer token is stored in browser Web Storage. -->
<input class="form-check-input" id="remember-me" name="remember-me" type="checkbox" value="true" checked>
<span class="form-check-label" for="remember-me">로그인 유지 <small class="text-muted" th:text="${'이 기기에서 ' + rememberMeDays + '일간 유지'}">이 기기에서 14일간 유지</small></span>
</label>
<button class="btn rw-btn-primary w-100" type="submit">로그인</button>
</form>
</section>

View File

@@ -0,0 +1,262 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('컬럼 마스킹')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>컬럼 마스킹</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>마스킹 방법을 사전 정의 템플릿으로 등록하고, 민감 컬럼마다 하나의 기본 규칙을 연결합니다. 컬럼 연결 목록은 <strong>마스킹 대상 블랙리스트</strong>이며, 연결하지 않은 컬럼은 ASO 마스킹 대상이 아닙니다. 행 접근은 행 접근 정책이 계속 담당하며 이 화면은 반환값 표시 방식만 다룹니다.</p>
<p>연결된 컬럼은 행 접근 정책이 조회를 허용한 사용자에게도 기본적으로 마스킹됩니다. <a href="/user-masking-rules">컬럼 원문 표시 허용 사용자</a>에 등록한 사용자만 원문을 봅니다. 이 설정은 행 접근 권한을 추가하지 않습니다. 컬럼 연결·해제 또는 연결된 규칙의 활성 상태 변경 시 DBMS_REDACT 정책도 자동 동기화됩니다.</p>
<section class="content-band mt-3 mb-0">
<h2>ASO 마스킹 적용 흐름</h2>
<p><strong>ASO/Data Redaction은 권한 테이블을 직접 조회하지 않습니다.</strong> 토큰 처리 중 <code>set_masking_rule_context(user_id)</code>가 사용자별 마스킹 예외를 세션 context로 만들고, ASO 정책은 그 context만 읽습니다.</p>
<div class="diagram-canvas">
<svg class="product-flow-diagram" viewBox="0 0 1120 410" role="img" aria-labelledby="aso-flow-title aso-flow-desc">
<title id="aso-flow-title">Bearer 토큰 기반 ASO 마스킹 적용 흐름</title>
<desc id="aso-flow-desc">Bearer 토큰으로 사용자 컨텍스트와 컬럼별 MR context를 만들고, Oracle Data Redaction이 MR context 값에 따라 원문 또는 마스킹값을 반환한다.</desc>
<defs>
<marker id="aso-flow-arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto" markerUnits="strokeWidth"><path d="M0,0 L0,6 L9,3 z" fill="currentColor"/></marker>
</defs>
<path class="diagram-link" marker-end="url(#aso-flow-arrow)" d="M185 92 H225"/>
<path class="diagram-link" marker-end="url(#aso-flow-arrow)" d="M385 92 H425"/>
<path class="diagram-link" marker-end="url(#aso-flow-arrow)" d="M585 92 H625"/>
<path class="diagram-link" marker-end="url(#aso-flow-arrow)" d="M785 92 H825"/>
<path class="diagram-link diagram-link-secondary" marker-end="url(#aso-flow-arrow)" d="M705 212 V144"/>
<path class="diagram-link" marker-end="url(#aso-flow-arrow)" d="M910 136 V205"/>
<path class="diagram-link" marker-end="url(#aso-flow-arrow)" d="M810 248 H850"/>
<path class="diagram-link" marker-end="url(#aso-flow-arrow)" d="M930 291 V315"/>
<path class="diagram-link" marker-end="url(#aso-flow-arrow)" d="M850 248 H720 V315"/>
<g class="diagram-node"><rect x="25" y="48" width="160" height="88" rx="12"/><text x="105" y="79" class="diagram-node-title">Bearer Token</text><text x="105" y="106" class="diagram-node-detail">요청 주체 식별</text></g>
<g class="diagram-node"><rect x="225" y="48" width="160" height="88" rx="12"/><text x="305" y="76" class="diagram-node-title">토큰 검증</text><text x="305" y="103" class="diagram-node-detail">set_user_by_bearer</text><text x="305" y="120" class="diagram-node-note">실패 시 context 정리</text></g>
<g class="diagram-node"><rect x="425" y="48" width="160" height="88" rx="12"/><text x="505" y="76" class="diagram-node-title">사용자 context</text><text x="505" y="103" class="diagram-node-detail">CB_AGENT_CTX.USER_ID</text><text x="505" y="120" class="diagram-node-note">EMP_NO · DEPT_CODE</text></g>
<g class="diagram-node diagram-node-accent"><rect x="625" y="48" width="160" height="88" rx="12"/><text x="705" y="76" class="diagram-node-title">마스킹 context</text><text x="705" y="103" class="diagram-node-detail">set_masking_rule_</text><text x="705" y="120" class="diagram-node-detail">context(user_id)</text></g>
<g class="diagram-node diagram-node-accent"><rect x="825" y="48" width="245" height="88" rx="12"/><text x="947" y="76" class="diagram-node-title">컬럼별 세션 변수</text><text x="947" y="103" class="diagram-node-detail">MR_&lt;column_id&gt; = Y / N</text><text x="947" y="120" class="diagram-node-note">현재 DB 세션에만 설정</text></g>
<g class="diagram-node diagram-node-data"><rect x="525" y="212" width="280" height="72" rx="12"/><text x="665" y="240" class="diagram-node-title">마스킹 예외·규칙 메타데이터</text><text x="665" y="263" class="diagram-node-detail">cb_user_masking_rule · cb_column_masking_rule</text></g>
<g class="diagram-node diagram-node-accent"><rect x="850" y="205" width="160" height="86" rx="12"/><text x="930" y="235" class="diagram-node-title">DBMS_REDACT</text><text x="930" y="260" class="diagram-node-detail">SYS_CONTEXT로 MR 읽음</text></g>
<g class="diagram-node diagram-node-data"><rect x="850" y="315" width="160" height="64" rx="12"/><text x="930" y="341" class="diagram-node-title">MR = Y</text><text x="930" y="363" class="diagram-node-detail">원문 표시</text></g>
<g class="diagram-node"><rect x="640" y="315" width="160" height="64" rx="12"/><text x="720" y="341" class="diagram-node-title">MR = N / NULL</text><text x="720" y="363" class="diagram-node-detail">마스킹 적용</text></g>
</svg>
</div>
<p class="form-hint mb-0">연결 고리: 토큰 검증 후 <code>set_masking_rule_context(v_user_id)</code><code>MR_&lt;column_id&gt;</code>를 설정하고, Data Redaction 조건식은 <code>SYS_CONTEXT('CB_AGENT_CTX', 'MR_&lt;column_id&gt;')</code>만 평가합니다. 자세한 SQL은 <a href="/security-sql-scripts?script=aso-masking-runtime">ASO 마스킹 런타임 스크립트</a>에서 확인하세요.</p>
</section>
</details>
</div>
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
<section class="content-band">
<div class="section-heading">
<div>
<span class="architecture-kicker">행 접근과 분리된 컬럼 제어</span>
<h2>ASO 마스킹 설정 순서</h2>
<p class="section-subtitle">행 접근은 행 접근 규칙과 Oracle VPD 정책이 결정합니다. 이 화면은 허용된 행 안에서 컬럼 값을 원문으로 줄지, 마스킹해서 줄지만 관리합니다.</p>
</div>
</div>
<div class="effective-preview">
<dl>
<div>
<dt>대상 컬럼 등록</dt>
<dd>마스킹 후보 블랙리스트에 DB 컬럼을 올립니다. 이 단계만으로는 값이 마스킹되지 않습니다.</dd>
</div>
<div>
<dt>기본 규칙 연결</dt>
<dd>컬럼에 활성 컬럼 마스킹 규칙을 연결하면 DBMS_REDACT 정책이 동기화됩니다.</dd>
</div>
<div>
<dt>원문 표시 허용</dt>
<dd>예외 사용자는 <a href="/user-masking-rules">컬럼 원문 표시 허용 사용자</a>에서 지정합니다.</dd>
</div>
</dl>
</div>
</section>
<section class="content-band">
<h2>DB ASO 정책 동기화</h2>
<p class="section-subtitle">이제 컬럼 기본 규칙을 연결·해제하거나 연결된 규칙을 활성·비활성화하면 DB 정책도 자동으로 반영됩니다. 아래 버튼은 자동 동기화 도입 전의 기존 설정을 한 번에 보정하거나, 운영 점검 시 현재 설정을 다시 반영할 때만 사용하세요.</p>
<form method="post" action="/masking-rules/synchronize" class="inline-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<button class="btn btn-outline-primary" type="submit">현재 DB 정책 동기화</button>
</form>
<div class="table-responsive mt-3">
<table class="table table-sm align-middle">
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>레거시 VPD 컬럼 제어</th><th>DB 정책 활성</th><th>상태 판단</th></tr></thead>
<tbody>
<tr th:each="status : ${policyStatuses}">
<td><code th:text="${status.targetLabel()}">POC_2.KB_CUSTOMERS</code></td>
<td><code th:text="${status.policyName()}">KB_CUSTOMER_PII_REDACT</code></td>
<td th:text="${status.configuredColumnCount()}">0</td>
<td th:text="${status.appliedColumnCount()}">0</td>
<td><span class="badge" th:classappend="${status.legacyVpdColumnPolicyCount() == 0} ? ' text-bg-secondary' : ' text-bg-danger'" th:text="${status.legacyVpdColumnPolicyCount() == 0} ? '없음' : ${status.legacyVpdColumnPolicyCount() + '건 활성'}">없음</span></td>
<td><span class="badge" th:classappend="${status.policyEnabled()} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${status.policyEnabled()} ? 'DB 적용 ON' : 'DB 적용 OFF'">DB 적용 OFF</span></td>
<td><span class="badge" th:classappend="${' ' + status.badgeClass()}" th:text="${status.statusLabel()}">미적용</span><br><small class="text-muted" th:text="${status.detail()}">설명</small></td>
</tr>
<tr th:if="${#lists.isEmpty(policyStatuses)}"><td colspan="7" class="text-muted">조회할 ASO 정책이 없습니다.</td></tr>
</tbody>
</table>
</div>
</section>
<section class="content-band">
<h2>사전 정의 마스킹 방식</h2>
<p class="section-subtitle">임의 SQL이나 정규식을 직접 입력하지 않고 검증된 템플릿만 선택합니다.</p>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead><tr><th>템플릿</th><th>방식</th><th>동작</th><th>ASO 구현</th></tr></thead>
<tbody>
<tr th:each="template : ${templates}">
<td><code th:text="${template.code()}">NULLIFY</code></td>
<td th:text="${template.label()}">값 숨김(NULL)</td>
<td th:text="${template.description()}">설명</td>
<td><code th:text="${template.asoFunction()}">DBMS_REDACT.NULLIFY</code></td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="content-band">
<h2>컬럼 마스킹 규칙 등록</h2>
<p class="section-subtitle">업무용 이름을 붙여 템플릿을 재사용합니다. 예: <code>KB_RRN_STANDARD</code>.</p>
<form method="post" action="/masking-rules" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
규칙 코드
<input class="form-control" name="ruleCode" maxlength="64" pattern="[A-Za-z][A-Za-z0-9_]{2,63}" required placeholder="KB_RRN_STANDARD">
<span class="form-hint">영문·숫자·밑줄만 사용합니다.</span>
</label>
<label>
규칙명
<input class="form-control" name="ruleName" maxlength="100" required placeholder="주민번호 기본 마스킹">
</label>
<label>
마스킹 템플릿
<select class="form-select" name="templateCode" required>
<option th:each="template : ${templates}" th:value="${template.code()}"
th:text="${template.label()}">값 숨김(NULL)</option>
</select>
</label>
<label>
설명
<input class="form-control" name="description" maxlength="400" placeholder="적용 목적 또는 업무 기준">
</label>
<button class="btn btn-primary" type="submit">규칙 등록</button>
</form>
</section>
<section class="content-band">
<h2>등록된 컬럼 마스킹 규칙</h2>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead><tr><th>코드</th><th>규칙명</th><th>템플릿</th><th>설명</th><th>상태</th><th></th></tr></thead>
<tbody>
<tr th:each="rule : ${rules}">
<td><code th:text="${rule.ruleCode()}">KB_RRN_STANDARD</code></td>
<td th:text="${rule.ruleName()}">주민번호 기본 마스킹</td>
<td th:text="${rule.templateLabel()}">값 숨김(NULL)</td>
<td th:text="${rule.description() ?: '-'}">설명</td>
<td><span class="badge" th:classappend="${rule.enabled()} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${rule.enabledYn()}">Y</span></td>
<td>
<form method="post" action="/masking-rules/active" class="inline-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="ruleId" th:value="${rule.ruleId()}">
<input type="hidden" name="active" th:value="${!rule.enabled()}">
<button class="btn btn-sm btn-outline-secondary" type="submit" th:text="${rule.enabled()} ? '비활성화' : '활성화'">변경</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(rules)}"><td colspan="6" class="text-muted">등록된 컬럼 마스킹 규칙이 없습니다.</td></tr>
</tbody>
</table>
</div>
</section>
<section class="content-band">
<h2>마스킹 대상 컬럼 추가</h2>
<p class="section-subtitle">DB 실제 컬럼을 ASO 마스킹 후보 블랙리스트에 등록합니다. 이 단계는 컬럼을 선택 목록에 올리는 작업이며, 실제 마스킹 적용은 아래에서 기본 규칙을 연결할 때 수행됩니다.</p>
<form method="post" action="/masking-rules/target-columns" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
대상 객체 / 컬럼
<select class="form-select" name="target" required>
<option value="">선택하세요</option>
<th:block th:each="object : ${maskingTargetObjects}">
<optgroup th:label="${object.displayName()}" th:if="${!#lists.isEmpty(availableMaskingColumnsByObject[object.objectId()])}">
<option th:each="columnName : ${availableMaskingColumnsByObject[object.objectId()]}"
th:value="|${object.objectId()}:${columnName}|"
th:text="${object.displayName() + '.' + columnName}">POC_2.KB_CONTRACTS.PREMIUM</option>
</optgroup>
</th:block>
</select>
<span class="form-hint">현재 관리 대상 ASO 정책이 있는 객체만 표시됩니다. 예: <code>POC_2.KB_CONTRACTS.PREMIUM</code>.</span>
</label>
<button class="btn btn-outline-primary" type="submit">대상 컬럼 추가</button>
</form>
</section>
<section class="content-band">
<h2>민감 컬럼에 기본 규칙 연결</h2>
<p class="section-subtitle">이 목록이 마스킹 대상 블랙리스트입니다. 컬럼 하나에는 활성 규칙 하나만 연결할 수 있으며, 컬럼 원문 표시 허용 사용자는 별도 화면에서 지정합니다. 오른쪽 DB 상태는 컬럼이 속한 객체의 실제 Redaction 정책 상태입니다.</p>
<form method="post" action="/masking-rules/columns" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
민감 컬럼
<select class="form-select" name="columnId" required>
<option value="">선택하세요</option>
<th:block th:each="object : ${objects}">
<optgroup th:label="${object.displayName()}" th:if="${!#lists.isEmpty(sensitiveColumnsByObject[object.objectId()])}">
<option th:each="column : ${sensitiveColumnsByObject[object.objectId()]}"
th:value="${column.columnId()}"
th:text="${column.columnName() + ' [' + column.displayPolicyLabel() + ']'}">RRN_MASKED</option>
</optgroup>
</th:block>
</select>
</label>
<label>
적용할 컬럼 마스킹 규칙
<select class="form-select" name="ruleId" required>
<option value="">선택하세요</option>
<option th:each="rule : ${rules}" th:if="${rule.enabled()}" th:value="${rule.ruleId()}"
th:text="${rule.ruleName() + ' · ' + rule.templateLabel()}">주민번호 기본 마스킹</option>
</select>
</label>
<button class="btn btn-primary" type="submit">컬럼에 연결</button>
</form>
<div class="table-responsive mt-3">
<table class="table table-sm align-middle">
<thead><tr><th>대상 컬럼</th><th>규칙</th><th>템플릿</th><th>백오피스 설정</th><th>DB ASO 적용 상태</th><th></th></tr></thead>
<tbody>
<tr th:each="columnRule : ${columnRules}">
<td><code th:text="${columnRule.targetLabel()}">POC_2.KB_CUSTOMERS.RRN_MASKED</code></td>
<td th:text="${columnRule.ruleName()}">주민번호 기본 마스킹</td>
<td th:text="${columnRule.template().label()}">주민등록번호 부분 마스킹</td>
<td><span class="badge" th:classappend="${columnRule.ruleEnabled()} ? ' text-bg-success' : ' text-bg-warning'" th:text="${columnRule.ruleEnabled()} ? '기본 규칙 연결됨' : '규칙 비활성'">기본 규칙 연결됨</span></td>
<td th:with="policyStatus=${policyStatusByObjectName[columnRule.objectName()]}">
<span class="badge"
th:if="${policyStatus}"
th:classappend="${' ' + policyStatus.badgeClass()}"
th:text="${policyStatus.statusLabel()}">적용됨</span>
<span class="badge text-bg-secondary" th:unless="${policyStatus}">관리 정책 없음</span>
<div class="text-muted small" th:if="${policyStatus}" th:text="${policyStatus.detail()}">상세</div>
</td>
<td>
<form method="post" action="/masking-rules/columns/delete" class="inline-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="columnId" th:value="${columnRule.columnId()}">
<button class="btn btn-sm btn-outline-danger" type="submit">연결 해제</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(columnRules)}"><td colspan="6" class="text-muted">민감 컬럼에 연결된 컬럼 마스킹 규칙이 없습니다.</td></tr>
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>

View File

@@ -8,7 +8,7 @@
<h1 class="h3">대화형 검색</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>질문을 MCP tool로 라우팅하고, 권한 태그·VPD 행 제한·ORDS 결과를 답변으로 정리합니다.</p>
<p>질문을 MCP tool로 라우팅하고, 권한 태그·행 접근 제한·ORDS 결과를 답변으로 정리합니다.</p>
</details>
</section>
@@ -23,7 +23,7 @@
<p>추천 질문을 선택하거나 질문을 직접 입력하세요.</p>
<details class="explanation-details">
<summary>세션과 조회 결과 설명 보기</summary>
<p>Bearer Token 원문을 붙여 넣거나 사용자를 선택해 10분 검증 세션으로 실제 ORDS/VPD 결과를 조회할 수 있습니다.</p>
<p>Bearer Token 원문을 붙여 넣거나 사용자를 선택해 10분 검증 세션으로 실제 ORDS 행 접근 결과를 조회할 수 있습니다.</p>
</details>
</div>
@@ -56,7 +56,7 @@
<label>
질문
<textarea class="form-control" id="mcp-chat-question" name="question" rows="3"
placeholder="예: BOARD_POSTS에서 이 토큰으로 보이는 행과 NULL 처리 컬럼을 요약해줘." required></textarea>
placeholder="예: BOARD_POSTS에서 이 토큰으로 보이는 행과 ASO 마스킹 컬럼을 요약해줘." required></textarea>
</label>
<div class="question-presets" aria-label="질문 예시">
<button class="btn rw-btn-secondary question-preset" type="button"
@@ -64,15 +64,15 @@
문서 조회 요약
</button>
<button class="btn rw-btn-secondary question-preset" type="button"
data-question="CB_V_SEARCH_DOCUMENTS에서 VPD가 제외한 행과 contents 표시 보호 여부를 구분하고, 보이는 dept_code와 owner_emp_no를 정리해줘.">
data-question="CB_V_SEARCH_DOCUMENTS에서 행 접근 정책이 제외한 행과 contents 표시 보호 여부를 구분하고, 보이는 dept_code와 owner_emp_no를 정리해줘.">
민감 컬럼 확인
</button>
<button class="btn rw-btn-secondary question-preset" type="button"
data-question="CB_VECTOR_SEARCH_DOCUMENTS에서 SPRING_BOOT 또는 ORACLE_VPD 태그 권한으로 검색 가능한 검색 단위와 VPD 결과를 요약해줘.">
data-question="CB_VECTOR_SEARCH_DOCUMENTS에서 SPRING_BOOT 또는 ORACLE_VPD 태그 권한으로 검색 가능한 검색 단위와 행 접근 결과를 요약해줘.">
태그 벡터 검색
</button>
<button class="btn rw-btn-secondary question-preset" type="button"
data-question="질문과 가장 가까운 ORDS/VPD MCP tool을 선택하고, 선택 근거와 필요한 bearer token 흐름만 보여줘.">
data-question="질문과 가장 가까운 ORDS 행 접근 MCP tool을 선택하고, 선택 근거와 필요한 bearer token 흐름만 보여줘.">
라우팅만 확인
</button>
</div>

View File

@@ -8,7 +8,7 @@
<h1>검색 해석</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>질문을 근거로 MCP tool을 고르고, Bearer Token으로 VPD/ORDS 결과를 조회한 뒤 모델이 설명합니다.</p>
<p>질문을 근거로 MCP tool을 고르고, Bearer Token으로 ORDS 행 접근 결과를 조회한 뒤 모델이 설명합니다.</p>
</details>
</div>
@@ -55,11 +55,11 @@
<label class="span-2">
질문
<textarea class="form-control" id="mcp-reasoning-question" name="question" rows="3"
placeholder="비워도 조회 행, NULL 처리, 권한 범위, 다음 조치를 요약합니다."></textarea>
placeholder="비워도 조회 행, ASO 마스킹 컬럼, 권한 범위, 다음 조치를 요약합니다."></textarea>
</label>
<div class="question-presets span-2" aria-label="질문 예시">
<button class="btn rw-btn-secondary question-preset" type="button"
data-question="CB_VECTOR_SEARCH_DOCUMENTS에서 이 토큰이 볼 수 있는 기술 태그 검색 단위 수와 VPD가 제외한 범위를 요약해줘.">
data-question="CB_VECTOR_SEARCH_DOCUMENTS에서 이 토큰이 볼 수 있는 기술 태그 검색 단위 수와 행 접근 정책이 제외한 범위를 요약해줘.">
기본 분석
</button>
<button class="btn rw-btn-secondary question-preset" type="button"
@@ -71,7 +71,7 @@
태그 권한 범위 판단
</button>
<button class="btn rw-btn-secondary question-preset" type="button"
data-question="ORDS path, VPD policy, TAG permission, token 상태 중 이상 징후를 먼저 bullet로 요약하고 다음 확인 순서를 제시해줘.">
data-question="ORDS path, 행 접근 policy, TAG permission, token 상태 중 이상 징후를 먼저 bullet로 요약하고 다음 확인 순서를 제시해줘.">
운영 점검 요약
</button>
</div>
@@ -97,7 +97,7 @@
<tr th:each="tool : ${tools}">
<td><code th:text="${tool.name()}">ords.query.admin.board_posts</code></td>
<td><code th:text="${tool.ordsPath()}">path</code></td>
<td th:text="${tool.description()}">VPD/ORDS 조회 도구</td>
<td th:text="${tool.description()}">ORDS 행 접근 조회 도구</td>
</tr>
<tr th:if="${#lists.isEmpty(tools)}">
<td colspan="3" class="text-muted">등록된 보호 객체 도구가 없습니다.</td>

View File

@@ -8,7 +8,7 @@
<h1>MCP 연동</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>보호 객체 ORDS 조회를 외부 MCP client에서 호출할 수 있도록 context path별 SSE transport로 제공합니다.</p>
<p>사용자 Bearer Token을 전달해 GPT-5.4-mini Select AI 자연어 조회를 실행하는 단일 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하고, DB에서는 토큰 기준 행 접근 context가 적용됩니다.</p>
</details>
</section>
@@ -67,7 +67,7 @@
</tr>
<tr>
<th>Auth</th>
<td><code>Basic Auth</code> / <code>BACKOFFICE_ADMIN_USER</code>, <code>BACKOFFICE_ADMIN_PASSWORD</code></td>
<td><code>Authorization: Bearer &lt;사용자 Bearer Token&gt;</code> — 이 토큰 하나로 행 접근 컨텍스트를 설정합니다.</td>
</tr>
<tr>
<th>Methods</th>
@@ -97,12 +97,9 @@
<td th:text="${tool.displayName()}">ADMIN.BOARD_POSTS</td>
<td><code th:text="${tool.ordsPath()}">cb-ords/cb-object-query/admin/board_posts</code></td>
<td>
<div th:text="${tool.description()}">VPD/ORDS 조회 도구 설명</div>
<div th:text="${tool.description()}">ORDS 행 접근 조회 도구 설명</div>
<small class="text-muted">
<code>bearerToken</code>Authorization · <code>limit</code>ORDS limit · object → 이 도구에 고정
<span th:if="${tool.displayName().toUpperCase().endsWith('CB_VECTOR_SEARCH_DOCUMENTS')}">
· <code>embedding[]</code> → 검색 JSON 본문
</span>
HTTP <code>Authorization</code>행 접근 컨텍스트 · <code>prompt</code>GPT-5.4-mini Select AI 자연어 질의 · <code>limit</code> → 최대 반환 행 수
</small>
</td>
</tr>
@@ -119,10 +116,10 @@
<summary>tools/call parameter 예시 보기</summary>
<h2>tools/call Arguments</h2>
<pre class="code-block">{
"bearerToken": "vpd_live_xxx",
"prompt": "KB_CLAIMS의 전체 청구 건수를 조회해 줘.",
"limit": 50
}</pre>
<p class="form-hint">벡터 검색 tool <code>CB_VECTOR_SEARCH_DOCUMENTS</code>는 여기에 외부 임베딩 모델이 만든 <code>embedding</code> 숫자 배열을 추가합니다. 토큰·limit은 모든 tool에 공통이고, embedding은 벡터 tool에만 필요합니다.</p>
<p class="form-hint">등록 tool <code>ords.query.kb_select_ai_vpd</code> 하나입니다. HTTP Authorization의 사용자 Bearer Token으로 컨텍스트를 설정한 뒤 GPT-5.4-mini가 comment, annotation, constraint를 참고해 생성한 검증된 읽기 전용 KB 원장 SQL만 실행합니다.</p>
</details>
</section>
@@ -156,9 +153,9 @@
"id": 3,
"method": "tools/call",
"params": {
"name": "ords.query.admin.board_posts",
"name": "ords.query.kb_select_ai_vpd",
"arguments": {
"bearerToken": "vpd_live_xxx",
"prompt": "KB_CLAIMS의 전체 청구 건수를 조회해 줘.",
"limit": 50
}
}

View File

@@ -8,7 +8,7 @@
<h1>조회 대상</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>이 화면은 DB 객체를 HTTP로 연결할 경로만 등록합니다. 누가 어떤 행을 볼 수 있는지는 권한 규칙과 VPD에서 결정합니다.</p>
<p>이 화면은 DB 객체를 HTTP로 연결할 경로만 등록합니다. 누가 어떤 행을 볼 수 있는지는 행 접근 규칙과 Oracle VPD 정책에서 결정합니다.</p>
</details>
</div>
@@ -16,7 +16,7 @@
<strong>지식 검색 연결:</strong> 문서 검색 단위와 기술 태그를 등록한 뒤 전용 ORDS 검색을 연결합니다.
<details class="explanation-details mt-2">
<summary>지식 검색 연결 흐름 보기</summary>
<p>문서를 검색 단위로 나누고 기술 태그를 붙인 뒤 전용 ORDS 검색을 등록합니다. 그 다음 <a href="/permissions">권한 관리</a>에서 <code>특정 기술 태그</code>를 여러 개 추가하면 태그 중 하나라도 맞는 검색 단위만 검색됩니다.</p>
<p>문서를 검색 단위로 나누고 기술 태그를 붙인 뒤 전용 ORDS 검색을 등록합니다. 그 다음 <a href="/permissions">행 접근 규칙</a>에서 <code>특정 기술 태그</code>를 여러 개 추가하면 태그 중 하나라도 맞는 검색 단위만 검색됩니다.</p>
</details>
</div>
@@ -28,7 +28,7 @@
<p class="section-subtitle">DB 객체 하나를 하나의 조회 Handler로 연결하는 기본 구성입니다.</p>
<details class="explanation-details">
<summary>대상 추가 후 생성되는 내용 보기</summary>
<p>대상 추가 후 Handler를 생성하면 VPD context 설정과 기본 SELECT가 포함된 PL/SQL이 만들어집니다. 소스 보기에서 실제 업무에 맞게 수정할 수 있습니다.</p>
<p>대상 추가 후 Handler를 생성하면 행 접근 context 설정과 기본 SELECT가 포함된 PL/SQL이 만들어집니다. 소스 보기에서 실제 업무에 맞게 수정할 수 있습니다.</p>
</details>
<form method="post" action="/objects" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
@@ -66,7 +66,7 @@
</form>
<details class="explanation-details explanation-warning mt-3">
<summary>기본 Handler 구성과 수정 가이드 보기</summary>
<p><code>cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id)</code>로 토큰의 사용자·역할 컨텍스트와 검증 요청 식별자를 넣고, 선택한 한 테이블에 <code>SELECT ... FROM OWNER.TABLE</code>을 실행한 뒤 JSON으로 반환합니다. 이것은 유일한 사용 방식이 아니라 시작점이며, Handler 소스와 ORDS 메타데이터에서 수정할 수 있습니다. 컬럼 민감도·마스킹은 이 화면에서 다루지 않고 <a href="/permissions">권한 관리의 원문 표시 허용 컬럼</a>에서 별도로 설정합니다.</p>
<p><code>cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id)</code>로 토큰의 사용자·역할 컨텍스트와 검증 요청 식별자를 넣고, 선택한 한 테이블에 <code>SELECT ... FROM OWNER.TABLE</code>을 실행한 뒤 JSON으로 반환합니다. 이것은 유일한 사용 방식이 아니라 시작점이며, Handler 소스와 ORDS 메타데이터에서 수정할 수 있습니다. 컬럼 민감도·마스킹은 이 화면에서 다루지 않고 <a href="/masking-rules">컬럼 마스킹</a>에서 별도로 설정합니다.</p>
</details>
</section>
@@ -141,8 +141,8 @@
<details class="explanation-details">
<summary>이 대상의 자동 연결 흐름 보기</summary>
<div class="object-handler-explainer">
<strong>토큰 헤더 → VPD context 설정 → 선택한 단일 테이블의 기본 SELECT → JSON 응답</strong>
<small>행 접근은 권한 규칙/VPD가 담당합니다. 원문 표시 예외는 권한 관리 Step 4에서 여러 컬럼을 등록하세요.</small>
<strong>토큰 헤더 → 행 접근 context 설정 → 선택한 단일 테이블의 기본 SELECT → JSON 응답</strong>
<small>행 접근은 행 접근 규칙과 Oracle VPD 정책이 담당합니다. 컬럼 원문/마스킹은 컬럼 마스킹의 ASO/Data Redaction 설정에서 관리하세요.</small>
</div>
</details>
<div th:if="${object.objectName() != 'CB_VECTOR_SEARCH_DOCUMENTS'}"

View File

@@ -8,11 +8,39 @@
<h1>운영 현황</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>보호 객체별 ORDS handler, VPD policy, 권한 rule, 최근 검증 상태를 확인합니다. 이상이 있을 때는 해당 객체의 권한·VPD·ORDS 순서로 점검하세요.</p>
<p>보호 객체별 ORDS handler, 행 접근 정책(VPD), 행 접근 rule, 최근 검증 상태를 확인합니다. 이상이 있을 때는 해당 객체의 행 접근 규칙·VPD 정책·ORDS 순서로 점검하세요.</p>
<p>컬럼 마스킹은 ASO/Data Redaction 정책 상태로 별도 확인합니다. 행 접근 정책이 정상이더라도 ASO 정책이 불일치하면 민감 컬럼이 예상과 다르게 원문 또는 마스킹값으로 반환될 수 있습니다.</p>
</details>
</div>
<section class="content-band">
<div class="section-heading">
<div>
<span class="architecture-kicker">운영 판단 기준</span>
<h2>행 접근 정책(VPD)과 컬럼 마스킹 정책(ASO)을 분리해서 봅니다</h2>
<p class="section-subtitle">VPD는 행 접근, ASO/Data Redaction은 컬럼 원문/마스킹을 담당합니다. 둘 중 하나만 정상이어도 전체 권한 결과가 정상이라고 판단하면 안 됩니다.</p>
</div>
</div>
<div class="effective-preview">
<dl>
<div>
<dt>행 접근 정책(VPD)</dt>
<dd>아래 보호 객체 표에서 policy, function, 최근 접근 검증 상태를 확인합니다.</dd>
</div>
<div>
<dt>ASO 컬럼 정책</dt>
<dd>아래 ASO 표에서 백오피스 활성 컬럼 수와 DB Redaction 컬럼 수가 일치하는지 확인합니다.</dd>
</div>
<div>
<dt>실제 결과 검증</dt>
<dd>상태가 정상이어도 최종 판단은 <a href="/probe">접근 검증</a>에서 토큰 기준 응답으로 확인합니다.</dd>
</div>
</dl>
</div>
</section>
<section class="content-band">
<h2>행 접근 정책(VPD) · ORDS 검증 상태</h2>
<div class="table-responsive">
<table class="table table-sm align-middle operation-status-table">
<thead>
@@ -75,6 +103,34 @@
</table>
</div>
</section>
<section class="content-band">
<div class="section-heading">
<div>
<h2>ASO 컬럼 마스킹 정책 상태</h2>
<p class="section-subtitle">컬럼 마스킹 화면의 백오피스 설정과 실제 Oracle Data Redaction 정책을 비교한 결과입니다.</p>
</div>
<a class="btn btn-sm rw-btn-secondary" href="/masking-rules">컬럼 마스킹 열기</a>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>상태</th><th>확인 결과</th></tr></thead>
<tbody>
<tr th:each="status : ${maskingPolicyStatuses}">
<td><code th:text="${status.targetLabel()}">POC_2.KB_CLAIMS</code></td>
<td><code th:text="${status.policyName()}">KB_CLAIM_AMOUNT_REDACT</code></td>
<td th:text="${status.configuredColumnCount()}">0</td>
<td th:text="${status.appliedColumnCount()}">0</td>
<td><span class="badge" th:classappend="${' ' + status.badgeClass()}" th:text="${status.statusLabel()}">적용됨</span></td>
<td th:text="${status.detail()}">활성 규칙과 DB 정책 컬럼이 일치합니다.</td>
</tr>
<tr th:if="${#lists.isEmpty(maskingPolicyStatuses)}">
<td colspan="6" class="text-muted">조회할 ASO 정책 상태가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>

View File

@@ -8,12 +8,12 @@
<h1>조회 연동</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>Bearer Token을 DB 컨텍스트로 변환한 뒤 VPD 권한체계를 적용합니다. 아래 소스는 등록된 ORDS Handler가 실제로 실행하는 기술 세부 내용입니다. 실행 결과의 SQL_ID 증적과 권한 조건 재현은 접근 검증 메뉴에서 확인할 수 있습니다.</p>
<p>Bearer Token을 DB 컨텍스트로 변환한 뒤 행 접근 정책(VPD)이 권한체계를 적용합니다. 아래 소스는 등록된 ORDS Handler가 실제로 실행하는 기술 세부 내용입니다. 실행 결과의 SQL_ID 증적과 조건 재현은 접근 검증 메뉴에서 확인할 수 있습니다.</p>
</details>
</div>
<div class="alert alert-info">
권한별로 VPD가 붙인 실제 행 조건을 확인하려면 <a href="/probe">접근 검증</a>에서 이 Handler의 보호 객체를 실행하세요. SQL trace를 반환하도록 갱신된 Handler는 같은 ORDS 세션의 <code>CB_AGENT_DOC_VPD_FILTER</code> predicate를 함께 표시합니다.
사용자별로 적용된 실제 행 조건을 확인하려면 <a href="/probe">접근 검증</a>에서 이 Handler의 보호 객체를 실행하세요. SQL trace를 반환하도록 갱신된 Handler는 같은 ORDS 세션의 <code>CB_AGENT_DOC_VPD_FILTER</code> predicate를 함께 표시합니다.
</div>
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>

View File

@@ -1,14 +1,15 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('접근 규칙')}"></head>
<head th:replace="~{fragments/layout :: head('접근 규칙')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>접근 규칙</h1>
<h1>접근 규칙</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>VPD policy function이 참조할 사용자, 역할, 행 규칙, 컬럼 원문 허용 규칙을 저장합니다. KB 조건 코드는 토큰 context 값으로 치환되고, 정적 SQL 조건은 대상 객체의 WHERE 절에 그대로 추가됩니다. 한 권한의 규칙은 AND, 서로 다른 ALLOW 권한은 OR로 합치며 DENY는 허용 결과에서 제외합니다.</p>
<p>이 화면은 <strong>행 접근 규칙</strong>만 저장합니다. 저장값은 최종 SQL이 아니라 <code>CB_AGENT_DOC_VPD_FILTER</code>가 읽어서 대상 테이블의 WHERE predicate로 바꾸는 매핑 데이터입니다.</p>
<p>컬럼 원문/마스킹은 이 화면에서 처리하지 않습니다. 주민번호, 보험료, 청구금액 같은 컬럼 표시는 <a href="/masking-rules">컬럼 마스킹</a>의 ASO/Data Redaction 정책에서 관리합니다.</p>
</details>
</div>
@@ -18,15 +19,14 @@
<section class="content-band">
<div class="section-heading">
<div>
<h2>새 접근 규칙</h2>
<p class="section-description">저장 전 적용 대상과 행 범위를 검토한 뒤 VPD 권한을 반영합니다.</p>
<h2> 접근 규칙</h2>
<p class="section-description">저장 전 적용 대상과 행 범위를 검토한 뒤 행 접근 규칙을 반영합니다.</p>
</div>
<div class="wizard-progress" aria-label="권한 추가 단계">
<button class="wizard-step-indicator active" type="button" data-wizard-target="1">역할</button>
<button class="wizard-step-indicator" type="button" data-wizard-target="2">대상</button>
<button class="wizard-step-indicator" type="button" data-wizard-target="3">접근 범위</button>
<button class="wizard-step-indicator" type="button" data-wizard-target="4">표시 예외</button>
<button class="wizard-step-indicator" type="button" data-wizard-target="5">검토</button>
<button class="wizard-step-indicator" type="button" data-wizard-target="4">검토</button>
</div>
</div>
<form method="post" action="/permissions" class="permission-wizard" data-permission-wizard>
@@ -138,7 +138,7 @@
<div class="wizard-panel-heading">
<div>
<h3>권한 효과와 행 규칙</h3>
<p>허용/거부 방향과 VPD 행 필터 조건을 설정합니다.</p>
<p>허용/거부 방향과 행 필터 조건을 설정합니다.</p>
</div>
</div>
<label>
@@ -179,40 +179,17 @@
</div>
<details class="explanation-details">
<summary>행 규칙의 두 가지 적용 방식 보기</summary>
<p class="wizard-hint"><strong>조건 코드</strong>는 토큰 context로 치환됩니다. 예를 들어 <code>본인 담당 고객 / CUST_ID</code>는 계약원장에서 토큰 사용자 ID의 담당 고객을 찾아 현재 객체의 <code>CUST_ID</code>에 적용합니다. <strong>정적 SQL 조건식</strong><code>CONTRACT_STATUS = '정상'</code>처럼 현재 객체 컬럼을 사용한 WHERE 절을 그대로 추가합니다. 한 권한 안의 규칙은 모두 AND로 좁혀지고, 서로 다른 역할의 ALLOW 권한은 OR로 합쳐집니다. 역할명은 VPD WHERE 절에 직접 들어가지 않습니다.</p>
<p class="wizard-hint"><strong>조건 코드</strong>는 토큰 context로 치환됩니다. 예를 들어 <code>본인 담당 고객 / CUST_ID</code>는 계약원장에서 토큰 사용자 ID의 담당 고객을 찾아 현재 객체의 <code>CUST_ID</code>에 적용합니다. <strong>정적 SQL 조건식</strong><code>CONTRACT_STATUS = '정상'</code>처럼 현재 객체 컬럼을 사용한 WHERE 절을 그대로 추가합니다. 한 권한 안의 규칙은 모두 AND로 좁혀지고, 서로 다른 역할의 ALLOW 권한은 OR로 합쳐집니다. 역할명은 최종 WHERE 절에 직접 들어가지 않습니다.</p>
<p class="wizard-hint"><strong>컬럼 원문/마스킹은 제외했습니다.</strong> 행 접근 필터는 행만 남기고, ASO/Data Redaction이 허용된 행 안에서 컬럼을 원문 또는 마스킹으로 반환합니다.</p>
</details>
</div>
</div>
<div class="wizard-panel" data-wizard-step="4">
<div class="wizard-panel-heading">
<div>
<h3>권한별 원문 표시 예외</h3>
<p>이 역할이 이미 볼 수 있는 행에서 마스킹을 제외할 컬럼을 여러 개 선택합니다.</p>
</div>
</div>
<div class="masking-column-picker">
<div class="field-block-title">선택 가능한 표시 보호 컬럼</div>
<div class="question-presets" data-maskable-column-list>
<span class="text-muted small">보호 객체를 선택하면 마스킹 대상 컬럼이 표시됩니다.</span>
</div>
</div>
<label>
원문 표시 허용(마스킹 제외) 컬럼 · 여러 개 가능
<input class="form-control" name="visibleColumns" placeholder="예: CONTENTS, SOURCE_URI">
<span class="selected-column-list" data-selected-visible-columns aria-live="polite"></span>
</label>
<details class="explanation-details">
<summary>원문 표시 허용 컬럼 안내 보기</summary>
<p class="wizard-hint">쉼표로 여러 컬럼을 등록하거나 위 버튼을 여러 번 누르세요. 등록하지 않은 민감 컬럼은 기본 표시 보호를 유지합니다. 행을 볼 수 있는지는 이 목록이 아니라 권한 규칙과 VPD가 결정합니다.</p>
</details>
</div>
<div class="wizard-panel" data-wizard-step="5">
<div class="wizard-panel-heading">
<div>
<h3>저장 전 검토</h3>
<p>저장될 권한과 예상 effective policy를 확인합니다.</p>
<p>저장될 행 접근 규칙과 VPD predicate 변환 예시를 확인합니다. 컬럼 마스킹은 ASO 화면에서 별도로 설정합니다.</p>
</div>
</div>
<div class="policy-preview" data-policy-preview>
@@ -222,10 +199,9 @@
<div><dt>보호 객체</dt><dd data-preview="object">-</dd></div>
<div><dt>적용 대상</dt><dd data-preview="affectedPrincipals">-</dd></div>
<div><dt>객체 컬럼</dt><dd data-preview="objectColumnsFinal">-</dd></div>
<div><dt>행 접근</dt><dd data-preview="rowPolicy">-</dd></div>
<div><dt>행 접근 업무 의미</dt><dd data-preview="rowPolicy">-</dd></div>
<div><dt>VPD predicate 예상</dt><dd data-preview="predicatePreview">-</dd></div>
<div><dt>권한별 컬럼 마스킹</dt><dd data-preview="columnPolicy">-</dd></div>
<div><dt>NULL 처리 예상</dt><dd data-preview="nullPolicy">-</dd></div>
<div><dt>컬럼 마스킹</dt><dd data-preview="columnPolicy">-</dd></div>
<div class="policy-preview-emphasis"><dt>저장 영향</dt><dd data-preview="saveGuard">-</dd></div>
<div><dt>되돌리기</dt><dd>저장 후 아래 권한 목록에서 삭제할 수 있습니다. 이 객체의 마지막 권한을 삭제하면 보호 객체가 비활성화될 수 있습니다.</dd></div>
</dl>
@@ -241,8 +217,8 @@
</section>
<section class="content-band">
<h2>접근 규칙 목록</h2>
<p class="section-subtitle">한 접근 규칙 안에서는 조건 코드와 정적 SQL 조건을 AND로 합쳐 범위를 좁힙니다. 같은 보호 대상의 서로 다른 ALLOW 권한은 OR, DENY 권한은 허용 결과에서 제외됩니다.</p>
<h2>접근 규칙 목록</h2>
<p class="section-subtitle">업무 의미를 먼저 보고, 세부 저장값과 VPD predicate 변환 예시는 행의 상세에서 확인하세요. 컬럼 원문/마스킹은 컬럼 마스킹에서 관리합니다.</p>
<div class="table-responsive">
<table class="table table-sm align-middle permission-list-table">
<thead>
@@ -250,8 +226,8 @@
<th>역할</th>
<th>보호 대상</th>
<th>효과</th>
<th>접근 범위</th>
<th>표시 예외</th>
<th>업무 접근 범위</th>
<th>컬럼 마스킹</th>
<th>관리</th>
</tr>
</thead>
@@ -265,11 +241,20 @@
th:classappend="${permission.permissionEffect() == 'DENY'} ? ' text-bg-danger' : ' text-bg-success'"
th:text="${permission.permissionEffect()}">ALLOW</span>
</td>
<td><pre class="table-pre" th:text="${permission.filterPreview() ?: permission.rules() ?: '-'}">ALL ROWS</pre></td>
<td th:text="${permission.visibleColumns()} ?: '-'">CONTENTS</td>
<td><pre class="table-pre" th:text="${permission.businessRuleSummary()}">전체 행</pre></td>
<td>
<span class="badge text-bg-light">ASO 화면에서 관리</span>
<div class="text-muted small" th:text="${permission.columnControlSummary()}">컬럼 원문/마스킹은 컬럼 마스킹에서 관리</div>
</td>
<td>
<details class="row-management">
<summary>관리</summary>
<div class="delete-impact">
<span>저장 규칙</span>
<pre class="table-pre mt-1" th:text="${permission.storedRuleSummary()}">CUST_ID OWN_CUSTOMER</pre>
<span>VPD predicate 변환 예시</span>
<pre class="table-pre mt-1" th:text="${permission.vpdMappingSummary()}">EXISTS (...)</pre>
</div>
<div class="delete-impact">
<span>이 역할은 이 대상의 SELECT 권한을 잃습니다.</span>
<strong th:text="${permission.roleName() + ' → ' + permission.objectName()}">ROLE → OBJECT</strong>
@@ -289,7 +274,7 @@
</td>
</tr>
<tr th:if="${#lists.isEmpty(permissions)}">
<td colspan="6" class="text-muted">등록된 접근 규칙이 없습니다.</td>
<td colspan="6" class="text-muted">등록된 접근 규칙이 없습니다.</td>
</tr>
</tbody>
</table>

View File

@@ -19,7 +19,7 @@
<p>토큰과 대상 객체를 입력하면 실제 조회 결과를 보여줍니다.</p>
<details class="explanation-details">
<summary>권한 판정 과정 보기</summary>
<p>토큰은 사용자를 찾는 열쇠입니다. 서버가 직접 역할과 그룹 상속 역할을 합치고, VPD 저장된 행·열 규칙을 적용한 결과를 보여줍니다.</p>
<p>토큰은 사용자를 찾는 열쇠입니다. 서버가 직접 역할과 그룹 상속 역할을 합치고, VPD 저장된 행 규칙을 적용합니다. 컬럼 원문/마스킹은 ASO/Data Redaction 결과로 별도 확인합니다.</p>
</details>
</div>
<a class="btn rw-btn-secondary" href="/tokens">검증 세션이 없나요? 먼저 발급하기</a>

View File

@@ -8,8 +8,7 @@
<h1>역할</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p th:if="${backofficeTrack == 'DDS'}">역할은 사용자와 접근 태그 규칙을 연결하는 기준입니다. 실제 접근 범위는 접근 규칙에서 관리합니다.</p>
<p th:unless="${backofficeTrack == 'DDS'}">사용자에게 부여할 역할을 관리합니다. 표시 보호 등급 상한은 허용된 행에서 원문으로 볼 수 있는 컬럼 등급의 참고값이며, 행 접근 권한은 권한 규칙과 VPD에서 결정합니다.</p>
<p>사용자에게 부여할 역할을 관리합니다. 표시 보호 등급 상한은 허용된 행에서 원문으로 볼 수 있는 컬럼 등급의 참고값이며, 행 접근 권한은 행 접근 규칙과 Oracle VPD 정책에서 결정합니다.</p>
</details>
</div>
@@ -20,8 +19,7 @@
<h2>역할 추가</h2>
<form method="post" action="/roles" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input th:if="${backofficeTrack == 'DDS'}" type="hidden" name="maxSensitivityLevel" value="PUBLIC">
<label th:unless="${backofficeTrack == 'DDS'}">
<label>
역할명
<input class="form-control" name="roleName" placeholder="HR_DEPT_ROLE" required>
</label>
@@ -37,7 +35,7 @@
<option value="CONFIDENTIAL">CONFIDENTIAL</option>
<option value="RESTRICTED">RESTRICTED</option>
</select>
<span class="form-hint">이 역할이 허용된 행에서 원문으로 볼 수 있는 컬럼 등급의 참고 상한입니다. 행을 볼 수 있는지는 권한 규칙과 VPD가 결정하며, 이 값은 행 권한을 부여하지 않습니다.</span>
<span class="form-hint">이 역할이 허용된 행에서 원문으로 볼 수 있는 컬럼 등급의 참고 상한입니다. 행을 볼 수 있는지는 행 접근 규칙과 Oracle VPD 정책이 결정하며, 이 값은 행 권한을 부여하지 않습니다.</span>
</label>
<button class="btn rw-btn-primary" type="submit">추가</button>
</form>
@@ -51,7 +49,7 @@
<tr>
<th>ID</th>
<th>역할명</th>
<th th:unless="${backofficeTrack == 'DDS'}">표시 보호 등급 상한</th>
<th>표시 보호 등급 상한</th>
<th>삭제 영향</th>
<th></th>
</tr>
@@ -62,7 +60,7 @@
hasDependencies=${impact != null && (!#lists.isEmpty(impact.directUsers()) || !#lists.isEmpty(impact.groups()) || impact.permissionCount() > 0)}">
<td th:text="${role.roleId()}">10</td>
<td th:text="${role.roleName()}">HR_DEPT_ROLE</td>
<td th:unless="${backofficeTrack == 'DDS'}">
<td>
<span class="badge text-bg-light" th:title="${'허용된 행의 컬럼 표시 상한: ' + role.maxSensitivityLevel()}"
th:text="${role.maxSensitivityLevel()}">PUBLIC</span>
<form method="post" action="/roles/max-sensitivity" class="inline-form">
@@ -113,7 +111,7 @@
</td>
</tr>
<tr th:if="${#lists.isEmpty(roles)}">
<td th:colspan="${backofficeTrack == 'DDS'} ? 4 : 5" class="text-muted">등록된 역할이 없습니다.</td>
<td colspan="5" class="text-muted">등록된 역할이 없습니다.</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,198 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('DB 메타데이터')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<section class="page-title">
<span class="badge text-bg-primary">Select AI 근거 메타데이터</span>
<h1 class="mt-2">DB 메타데이터</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>
<code>POC_2</code> KB 업무 테이블의 table/column comment와 Oracle annotation을 조회·수정합니다.
Select AI profile의 <code>comments=true</code>, <code>annotations=true</code> 설정에서는 이 값들이 SQL 생성 근거로 들어갑니다.
</p>
<p class="mb-0">임의 스키마나 임의 테이블은 수정하지 않고, 백오피스가 승인한 7개 업무 테이블만 대상으로 합니다.</p>
</details>
</section>
<section class="alert alert-success" th:if="${message}" th:text="${message}">저장 완료</section>
<section class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}">오류</section>
<section class="content-band">
<div class="section-heading">
<div>
<h2>테이블 선택</h2>
<p class="section-subtitle">정형 MCP/Select AI가 참조하는 KB 업무 원장 7개만 표시합니다.</p>
</div>
<span class="badge text-bg-secondary" th:text="${#lists.size(tables)}">7</span>
</div>
<div class="structured-table-grid">
<a th:each="entry : ${tables}"
class="structured-table-card"
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
th:href="@{/schema-metadata(table=${entry.key()})}">
<strong th:text="${entry.businessName()}">고객원장</strong>
<code th:text="${entry.tableName()}">KB_CUSTOMERS</code>
<small th:text="${entry.description()}">고객 기본정보</small>
</a>
</div>
</section>
<section class="content-band" th:if="${metadata}">
<div class="section-heading">
<div>
<span class="badge text-bg-secondary">TABLE</span>
<h2 class="mt-2" th:text="${metadata.table().businessName()}">계약원장</h2>
<p class="section-subtitle">
<code th:text="${'POC_2.' + metadata.table().tableName()}">POC_2.KB_CONTRACTS</code>
<span th:text="${' · ' + metadata.table().description()}"> · 설명</span>
</p>
</div>
<a class="btn btn-sm rw-btn-secondary" th:href="@{/structured-data(table=${metadata.table().key()})}">데이터 미리보기</a>
</div>
<form method="post" action="/schema-metadata/table-comment" class="mb-4">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="table" th:value="${metadata.table().key()}">
<label class="form-label fw-semibold">Table comment</label>
<textarea class="form-control" rows="3" name="comment" maxlength="4000"
th:text="${metadata.tableComment()}"></textarea>
<div class="form-hint mt-1">Oracle <code>COMMENT ON TABLE</code>로 즉시 반영됩니다. 빈 값으로 저장하면 comment를 비웁니다.</div>
<button class="btn rw-btn-primary mt-2" type="submit">테이블 comment 저장</button>
</form>
<div class="section-heading">
<div>
<h3>Table annotations</h3>
<p class="section-subtitle">기존 annotation은 값 수정 또는 빈 값 저장으로 삭제할 수 있습니다.</p>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead><tr><th style="width: 220px;">Annotation</th><th>Value</th><th style="width: 110px;"></th></tr></thead>
<tbody>
<tr th:each="annotation : ${metadata.tableAnnotations()}">
<td><code th:text="${annotation.name()}">DISPLAY_NAME</code></td>
<td>
<form method="post" action="/schema-metadata/table-annotation" class="metadata-inline-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="table" th:value="${metadata.table().key()}">
<input type="hidden" name="annotationName" th:value="${annotation.name()}">
<textarea class="form-control" rows="2" name="annotationValue" maxlength="4000"
th:text="${annotation.value()}"></textarea>
<div class="form-hint mt-1">저장 전 기존 같은 key는 DROP 후 다시 ADD합니다.</div>
<button class="btn btn-sm rw-btn-primary mt-2" type="submit">저장</button>
</form>
</td>
<td class="text-muted">기존</td>
</tr>
<tr th:if="${#lists.isEmpty(metadata.tableAnnotations())}">
<td colspan="3" class="text-muted">등록된 table annotation이 없습니다.</td>
</tr>
<tr>
<td colspan="3">
<form method="post" action="/schema-metadata/table-annotation" class="row g-2 align-items-start">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="table" th:value="${metadata.table().key()}">
<div class="col-md-3">
<input class="form-control" name="annotationName" placeholder="예: QUERY_HINT" maxlength="128">
</div>
<div class="col-md-7">
<textarea class="form-control" rows="2" name="annotationValue" maxlength="4000"
placeholder="Select AI가 참고할 업무 의미, 조인 힌트, 값 도메인 등을 입력"></textarea>
</div>
<div class="col-md-2">
<button class="btn rw-btn-secondary w-100" type="submit">추가</button>
</div>
</form>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="content-band" th:if="${metadata}">
<div class="section-heading">
<div>
<h2>Column comments / annotations</h2>
<p class="section-subtitle">컬럼 comment와 컬럼 annotation을 수정합니다. 저장 즉시 DB dictionary에 반영됩니다.</p>
</div>
<span class="badge text-bg-secondary" th:text="${#lists.size(metadata.columns()) + '개 컬럼'}">0개 컬럼</span>
</div>
<div class="metadata-column-stack">
<details class="content-band nested-content-band"
th:each="column : ${metadata.columns()}"
th:open="${!#lists.isEmpty(column.annotations())}">
<summary class="d-flex justify-content-between align-items-center gap-3">
<span>
<code th:text="${column.columnName()}">CONTRACT_NO</code>
<small class="text-muted ms-2" th:text="${column.dataType()}">VARCHAR2(30)</small>
<span class="badge text-bg-light ms-2" th:text="${column.nullable()} ? 'NULL 허용' : 'NOT NULL'">NOT NULL</span>
</span>
<span class="text-muted" th:text="${#lists.size(column.annotations()) + ' annotations'}">0 annotations</span>
</summary>
<form method="post" action="/schema-metadata/column-comment" class="mt-3">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="table" th:value="${metadata.table().key()}">
<input type="hidden" name="column" th:value="${column.columnName()}">
<label class="form-label fw-semibold">Column comment</label>
<textarea class="form-control" rows="2" name="comment" maxlength="4000"
th:text="${column.comment()}"></textarea>
<button class="btn btn-sm rw-btn-primary mt-2" type="submit">컬럼 comment 저장</button>
</form>
<div class="table-responsive mt-3">
<table class="table table-sm align-middle">
<thead><tr><th style="width: 220px;">Annotation</th><th>Value</th><th style="width: 110px;"></th></tr></thead>
<tbody>
<tr th:each="annotation : ${column.annotations()}">
<td><code th:text="${annotation.name()}">DISPLAY_NAME</code></td>
<td>
<form method="post" action="/schema-metadata/column-annotation">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="table" th:value="${metadata.table().key()}">
<input type="hidden" name="column" th:value="${column.columnName()}">
<input type="hidden" name="annotationName" th:value="${annotation.name()}">
<textarea class="form-control" rows="2" name="annotationValue" maxlength="4000"
th:text="${annotation.value()}"></textarea>
<button class="btn btn-sm rw-btn-primary mt-2" type="submit">저장</button>
</form>
</td>
<td class="text-muted">기존</td>
</tr>
<tr th:if="${#lists.isEmpty(column.annotations())}">
<td colspan="3" class="text-muted">등록된 column annotation이 없습니다.</td>
</tr>
<tr>
<td colspan="3">
<form method="post" action="/schema-metadata/column-annotation" class="row g-2 align-items-start">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="table" th:value="${metadata.table().key()}">
<input type="hidden" name="column" th:value="${column.columnName()}">
<div class="col-md-3">
<input class="form-control" name="annotationName" placeholder="예: VALUE_DOMAIN" maxlength="128">
</div>
<div class="col-md-7">
<textarea class="form-control" rows="2" name="annotationValue" maxlength="4000"
placeholder="컬럼 표시명, 값 범위, 조인 대상, 민감도 등을 입력"></textarea>
</div>
<div class="col-md-2">
<button class="btn rw-btn-secondary w-100" type="submit">추가</button>
</div>
</form>
</td>
</tr>
</tbody>
</table>
</div>
</details>
</div>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,66 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('보안 SQL 스크립트')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<section class="page-title">
<span class="badge text-bg-primary">형상 기준</span>
<h1 class="mt-2">보안 SQL 스크립트</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>ASO 마스킹, ORDS, Select AI 행 접근에 사용하는 Git 형상 기준 SQL을 읽기 전용으로 표시합니다. 이 화면은 SQL을 실행하거나 DB의 현재 source를 바꾸지 않습니다.</p>
</details>
</section>
<section class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}">조회할 수 없습니다.</section>
<section class="content-band">
<div class="section-heading">
<div>
<h2>등록된 스크립트</h2>
<p class="section-subtitle">배포 JAR에는 아래 Git 추적 파일만 포함됩니다.</p>
</div>
<span class="badge text-bg-secondary" th:text="${#lists.size(scripts)}">0</span>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead><tr><th>구분</th><th>파일</th><th>용도</th><th></th></tr></thead>
<tbody>
<tr th:each="item : ${scripts}" th:classappend="${selectedScript != null and item.scriptId() == selectedScript.scriptId()} ? ' table-primary'">
<td><span class="badge text-bg-light" th:text="${item.category()}">ASO / 마스킹</span></td>
<td><code th:text="${item.fileName()}">62_kb_aso_masking_backoffice_metadata.sql</code></td>
<td>
<strong th:text="${item.title()}">컬럼 마스킹 규칙 메타데이터</strong>
<div class="form-hint" th:text="${item.description()}">설명</div>
</td>
<td><a class="btn btn-sm rw-btn-secondary" th:href="@{/security-sql-scripts(script=${item.scriptId()})}">원문 보기</a></td>
</tr>
<tr th:if="${#lists.isEmpty(scripts)}"><td colspan="4" class="text-muted">표시할 스크립트가 없습니다.</td></tr>
</tbody>
</table>
</div>
</section>
<section class="content-band" th:if="${selectedScript}">
<div class="section-heading">
<div>
<span class="badge text-bg-secondary" th:text="${selectedScript.category()}">ASO / 마스킹</span>
<h2 class="mt-2" th:text="${selectedScript.title()}">컬럼 마스킹 규칙 메타데이터</h2>
<p class="section-subtitle" th:text="${selectedScript.description()}">설명</p>
</div>
<code th:text="${selectedScript.fileName()}">62_kb_aso_masking_backoffice_metadata.sql</code>
</div>
<p class="form-hint">Git source: <code th:text="${'sql/adb/' + selectedScript.fileName()}">sql/adb/62_kb_aso_masking_backoffice_metadata.sql</code>. 실제 DB 배포본은 <a href="/vpd-filter-runtime">행 접근 필터 구조</a> 및 DB 배포 이력과 함께 확인하세요.</p>
<form hx-post="/security-sql-scripts/explanation" hx-target="#security-sql-explanation" hx-swap="innerHTML" class="mb-3">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="script" th:value="${selectedScript.scriptId()}">
<button class="btn rw-btn-primary" type="submit">LLM 전체·블록·토큰 처리 설명 생성</button>
<span class="form-hint ms-2">원문은 실행·수정하지 않고 설명을 만들 때만 AI에 전달합니다.</span>
</form>
<section id="security-sql-explanation" class="mb-3"></section>
<section class="probe-exchange"><pre th:text="${selectedScript.source()}">-- SQL source</pre></section>
</section>
</main>
</body>
</html>

View File

@@ -13,7 +13,7 @@
</div>
<div class="alert alert-warning">
이 화면은 관리자용 원장 미리보기입니다. 사용자별 VPD 적용 결과는 <a href="/probe">접근 검증</a>에서 확인하세요.
이 화면은 관리자용 원장 미리보기입니다. 사용자별 행 접근 적용 결과는 <a href="/probe">접근 검증</a>에서 확인하세요.
</div>
<section class="content-band">

View File

@@ -8,7 +8,7 @@
<h1>검증 세션</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>이해당사자 원장에 등록된 업무 사용자를 토큰에 연결합니다. 토큰 원문은 한 번만 표시하며, DB에는 해시와 식별용 prefix만 보관합니다. 역할·채널·행 권한은 토큰이 아니라 접근 규칙에서 동적으로 계산합니다.</p>
<p>이해당사자 원장에 등록된 업무 사용자를 토큰에 연결합니다. 토큰 원문은 한 번만 표시하며, DB에는 해시와 식별용 prefix만 보관합니다. 역할·채널·행 권한은 토큰이 아니라 접근 규칙에서 동적으로 계산합니다.</p>
</details>
</div>
@@ -28,7 +28,7 @@
<div class="section-heading">
<div>
<h2>이해당사자 토큰 발급</h2>
<p class="section-subtitle">모든 KB 이해당사자에게 토큰을 발급할 수 있습니다. 지점장·설계사 등의 실제 행 범위는 접근 규칙에 연결된 역할로 결정되므로, 토큰을 다시 만들지 않아도 권한 변경이 반영됩니다.</p>
<p class="section-subtitle">모든 KB 이해당사자에게 토큰을 발급할 수 있습니다. 지점장·설계사 등의 실제 행 범위는 접근 규칙에 연결된 역할로 결정되므로, 토큰을 다시 만들지 않아도 권한 변경이 반영됩니다.</p>
</div>
</div>
<form method="post" action="/tokens" class="form-grid">

View File

@@ -0,0 +1,109 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('컬럼 원문 표시 허용 사용자')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>컬럼 원문 표시 허용 사용자</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>이 화면은 <a href="/masking-rules">컬럼 마스킹</a>에서 연결한 기본 컬럼 마스킹 규칙에 대해 <strong>원문 표시 허용(UNMASK)</strong> 사용자만 관리합니다. 이 목록에 없는 사용자는 행 접근 정책이 조회를 허용하더라도 해당 컬럼의 마스킹값을 받습니다.</p>
<p>행 접근 여부는 변경하지 않습니다. 행 접근 권한이 없으면 행을 볼 수 없고, 행 접근 권한이 있으며 이 목록에 없으면 마스킹값을 봅니다. 행 접근 권한이 있고 이 목록에 있으면 그 허용 범위 안에서만 원문을 봅니다. 저장한 변경은 DBMS_REDACT가 읽는 세션 context에 반영됩니다.</p>
</details>
</div>
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
<section class="content-band">
<h2>컬럼 원문 표시 허용 사용자 추가</h2>
<p class="section-subtitle">먼저 컬럼 마스킹 화면에서 민감 컬럼에 활성 규칙을 연결하세요. 이 등록은 원문 표시만 허용하며 행 접근 권한은 부여하지 않습니다.</p>
<form method="post" action="/user-masking-rules" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
원문 표시를 허용할 사용자
<select class="form-select" name="userId" required>
<option value="">선택하세요</option>
<option th:each="user : ${users}" th:value="${user.userId()}" th:attr="data-user-label=${user.username()}"
th:text="${user.username() + ' (' + user.empNo() + ')'}">KB_VPD_ADMIN</option>
</select>
</label>
<label>
원문 표시 허용 대상 컬럼
<select class="form-select" name="columnId" required>
<option value="">선택하세요</option>
<option th:each="columnRule : ${columnRules}" th:value="${columnRule.columnId()}"
th:attr="data-target=${columnRule.targetLabel()},
data-template=${columnRule.template().label()},
data-result=${columnRule.template().previewResult()},
data-aso-function=${columnRule.template().asoFunction()},
data-context=${'MR_' + columnRule.columnId()}"
th:text="${columnRule.targetLabel() + ' · ' + columnRule.ruleLabel()}">KB_CUSTOMERS.RRN_MASKED</option>
</select>
</label>
<div>
<span class="form-label">적용 결과</span>
<div class="form-control bg-light" aria-label="적용 결과">행 접근 정책이 허용한 행에서만 원문 표시 · UNMASK</div>
</div>
<button class="btn btn-primary" type="submit">컬럼 원문 표시 허용 등록</button>
</form>
<div class="policy-preview mt-3" data-masking-exception-preview>
<h3>선택한 컬럼의 실제 적용 결과</h3>
<p class="section-description" data-masking-preview-empty>원문 표시 허용 대상 컬럼을 선택하면 일반 사용자와 허용 사용자의 반환값을 보여줍니다.</p>
<dl data-masking-preview-details hidden>
<div>
<dt>대상 · DB 마스킹 방식</dt>
<dd data-masking-preview-target>-</dd>
</div>
<div>
<dt>행 접근 권한이 없는 사용자</dt>
<dd>행 자체가 반환되지 않습니다.</dd>
</div>
<div>
<dt>행 접근 허용 · 원문 표시 허용 없음</dt>
<dd data-masking-preview-masked>-</dd>
</div>
<div class="policy-preview-emphasis">
<dt>행 접근 허용 · 원문 표시 허용 사용자</dt>
<dd data-masking-preview-unmasked>-</dd>
</div>
<div>
<dt>DB 판정 기준</dt>
<dd data-masking-preview-context>-</dd>
</div>
</dl>
</div>
</section>
<section class="content-band">
<h2>컬럼 원문 표시 허용 사용자 목록</h2>
<p class="section-subtitle">목록에 없는 사용자는 행 접근 정책이 허용한 행에서 기본 마스킹값을 봅니다.</p>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead><tr><th>사용자</th><th>대상 컬럼</th><th>기본 규칙</th><th>원문 표시 상태</th><th>상태</th><th></th></tr></thead>
<tbody>
<tr th:each="userRule : ${userRules}">
<td th:text="${userRule.username()}">KB_VPD_ADMIN</td>
<td><code th:text="${userRule.targetLabel()}">POC_2.KB_CUSTOMERS.RRN_MASKED</code></td>
<td th:text="${userRule.ruleLabel()}">주민번호 기본 마스킹 · 주민등록번호 부분 마스킹</td>
<td><span class="badge" th:classappend="${userRule.unmasked()} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${userRule.decisionLabel()}">원문 표시 예외</span></td>
<td th:text="${userRule.activeYn()}">Y</td>
<td>
<form method="post" action="/user-masking-rules/delete" class="inline-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="userId" th:value="${userRule.userId()}">
<input type="hidden" name="columnId" th:value="${userRule.columnId()}">
<button class="btn btn-sm btn-outline-danger" type="submit">원문 표시 허용 해제</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(userRules)}"><td colspan="6" class="text-muted">원문 표시 허용 사용자가 없습니다. 행 접근 정책이 허용한 사용자에게 기본 컬럼 마스킹 규칙이 적용됩니다.</td></tr>
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>

View File

@@ -83,9 +83,9 @@
<div class="section-heading">
<div>
<h2>접근 정책 설정</h2>
<p class="section-subtitle">역할별 기술 태그 접근 규칙은 권한 관리에서 관리합니다.</p>
<p class="section-subtitle">역할별 기술 태그 접근 규칙은 행 접근 규칙 화면에서 관리합니다.</p>
</div>
<a class="btn btn-sm rw-btn-primary" href="/permissions">권한 관리 열기</a>
<a class="btn btn-sm rw-btn-primary" href="/permissions">행 접근 규칙 열기</a>
</div>
<div class="macro-micro-grid">
<div>
@@ -121,7 +121,7 @@ AND NOT (DENY TAG C OR DENY TAG D)</pre>
<ol>
<li>검색어를 같은 임베딩 방식으로 벡터화합니다.</li>
<li>임시 Bearer Token으로 전용 ORDS Handler를 호출합니다.</li>
<li>VPD가 TECH_TAG 권한에 맞지 않는 검색 단위를 먼저 제거합니다.</li>
<li>행 접근 정책이 TECH_TAG 권한에 맞지 않는 검색 단위를 먼저 제거합니다.</li>
<li>남은 검색 단위를 관련도 순으로 반환합니다.</li>
</ol>
</details>

View File

@@ -124,7 +124,7 @@
<td colspan="4" class="policy-source-cell">
<div th:id="${'filter-source-' + iter.index}" class="text-muted small">Source 보기를 누르면 현재 함수 내용을 표시합니다.</div>
<div class="alert alert-light mt-3 mb-0" th:if="${function.permissionSystemDefault()}">
이 함수는 권한체계의 핵심 실행 경로이므로 직접 수정할 수 없습니다. 권한 규칙을 변경하세요.
이 함수는 권한체계의 핵심 실행 경로이므로 직접 수정할 수 없습니다. 행 접근 규칙을 변경하세요.
</div>
<form method="post" action="/vpd-filter-policies/filters" class="filter-edit-form mt-3"
th:unless="${function.permissionSystemDefault()}">

View File

@@ -0,0 +1,126 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('행 접근 필터 구조')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<section class="page-title">
<span class="badge text-bg-primary">운영 기준</span>
<h1 class="mt-2">행 접근 필터 구조</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>기본 동적 필터 <code>CB_AGENT_DOC_VPD_FILTER</code>의 연결 상태와 현재 DB에 배포된 함수를 읽기 전용으로 보여줍니다. 행 접근 변경은 <a href="/permissions">행 접근 규칙</a>에서 수행합니다.</p>
<section class="content-band mt-3 mb-0">
<h2>행 필터(VPD) 적용 흐름</h2>
<p><strong>Oracle VPD 정책 함수는 Bearer Token을 직접 해석하지 않습니다.</strong> 앞 단계의 <code>cb_agent_ctx_pkg.set_user_by_bearer</code>가 만든 <code>CB_AGENT_CTX</code>를 읽어, 현재 SELECT에 붙일 WHERE predicate를 생성합니다.</p>
<div class="diagram-canvas">
<svg class="product-flow-diagram" viewBox="0 0 1120 410" role="img" aria-labelledby="vpd-help-flow-title vpd-help-flow-desc">
<title id="vpd-help-flow-title">Bearer 토큰 기반 행 필터 적용 흐름</title>
<desc id="vpd-help-flow-desc">토큰으로 설정된 사용자 컨텍스트를 행 접근 함수가 읽고 역할과 행 접근 규칙을 WHERE 조건으로 변환해, 허용된 행만 SELECT 결과에 남긴다.</desc>
<defs>
<marker id="flow-arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto" markerUnits="strokeWidth"><path d="M0,0 L0,6 L9,3 z"/></marker>
</defs>
<path class="diagram-link" marker-end="url(#flow-arrow)" d="M185 92 H225"/>
<path class="diagram-link" marker-end="url(#flow-arrow)" d="M405 92 H445"/>
<path class="diagram-link" marker-end="url(#flow-arrow)" d="M625 92 H665"/>
<path class="diagram-link" marker-end="url(#flow-arrow)" d="M845 92 H885"/>
<path class="diagram-link diagram-link-secondary" marker-end="url(#flow-arrow)" d="M745 212 V144"/>
<path class="diagram-link" marker-end="url(#flow-arrow)" d="M977 136 V315"/>
<path class="diagram-link" marker-end="url(#flow-arrow)" d="M900 136 V280 H755 V315"/>
<g class="diagram-node"><rect x="25" y="48" width="160" height="88" rx="12"/><text x="105" y="79" class="diagram-node-title">Bearer Token</text><text x="105" y="106" class="diagram-node-detail">ORDS 요청</text></g>
<g class="diagram-node"><rect x="225" y="48" width="180" height="88" rx="12"/><text x="315" y="76" class="diagram-node-title">토큰 검증·해석</text><text x="315" y="103" class="diagram-node-detail">set_user_by_bearer</text><text x="315" y="120" class="diagram-node-note">실패 시 context 정리</text></g>
<g class="diagram-node"><rect x="445" y="48" width="180" height="88" rx="12"/><text x="535" y="76" class="diagram-node-title">CB_AGENT_CTX</text><text x="535" y="103" class="diagram-node-detail">USER_ID · DEPT_CODE · EMP_NO</text><text x="535" y="120" class="diagram-node-note">STAKEHOLDER_USER_ID · CHANNEL</text></g>
<g class="diagram-node diagram-node-accent"><rect x="665" y="48" width="180" height="88" rx="12"/><text x="755" y="76" class="diagram-node-title">행 접근 함수 호출</text><text x="755" y="103" class="diagram-node-detail">CB_AGENT_DOC_VPD_FILTER</text><text x="755" y="120" class="diagram-node-note">p_object = 조회 대상</text></g>
<g class="diagram-node diagram-node-accent"><rect x="885" y="48" width="185" height="88" rx="12"/><text x="977" y="76" class="diagram-node-title">WHERE predicate</text><text x="977" y="103" class="diagram-node-detail">ALLOW / DENY 결합</text><text x="977" y="120" class="diagram-node-note">원래 SELECT에 자동 추가</text></g>
<g class="diagram-node diagram-node-data"><rect x="590" y="212" width="310" height="72" rx="12"/><text x="745" y="239" class="diagram-node-title">역할·권한·규칙 메타데이터</text><text x="745" y="263" class="diagram-node-detail">cb_user_role · cb_group_role · cb_permission_rule</text></g>
<g class="diagram-node"><rect x="665" y="315" width="180" height="64" rx="12"/><text x="755" y="341" class="diagram-node-title">ALLOW 없음 / USER_ID 없음</text><text x="755" y="363" class="diagram-node-detail">RETURN 1 = 0 · 행 없음</text></g>
<g class="diagram-node diagram-node-data"><rect x="885" y="315" width="185" height="64" rx="12"/><text x="977" y="341" class="diagram-node-title">ALLOW 조건 충족</text><text x="977" y="363" class="diagram-node-detail">조건에 맞는 행만 반환</text></g>
</svg>
</div>
<p class="form-hint mb-0"><code>rule_type</code>별 WHERE 조각은 권한 내부에서 AND, ALLOW 권한끼리는 OR, DENY 권한은 최종적으로 <code>AND NOT (...)</code>으로 결합됩니다. ASO는 같은 context의 <code>MR_&lt;column_id&gt;</code><em>컬럼 표시</em>를 정하고, 행 접근 필터는 <code>USER_ID</code>·<code>DEPT_CODE</code>·<code>EMP_NO</code>·<code>STAKEHOLDER_*</code><em>보이는 행</em>을 정합니다.</p>
</section>
</details>
</section>
<section class="alert alert-warning" th:if="${runtimeError}">
<strong th:text="${runtimeError.title()}">DB 연결 설정이 필요합니다.</strong>
<span th:text="${runtimeError.message()}">DB 연결을 확인할 수 없습니다.</span>
</section>
<section class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}">조회할 수 없습니다.</section>
<section class="content-band">
<h2>실행 흐름</h2>
<div class="macro-micro-grid">
<div>
<h3>1. 신뢰 컨텍스트 설정</h3>
<p>ORDS가 Bearer Token을 검증하고 <code>CB_AGENT_CTX</code>에 사용자·이해관계자·채널 정보를 설정합니다.</p>
</div>
<div>
<h3>2. 권한과 규칙 계산</h3>
<p>직접 역할과 활성 그룹 역할의 SELECT 권한을 읽습니다. 한 권한의 규칙은 AND, 여러 ALLOW 권한은 OR로 결합합니다.</p>
</div>
<div>
<h3>3. DB WHERE 절 적용</h3>
<p>Oracle VPD 정책이 반환 predicate를 원래 SELECT에 자동 적용합니다. 권한이 없으면 <code>1 = 0</code>으로 행 접근을 막는 것이 기준입니다.</p>
</div>
</div>
<p class="form-hint mt-3 mb-0">행 접근은 이 행 필터(VPD)가 담당합니다. 컬럼 마스킹과 원문 표시 허용 사용자는 <a href="/masking-rules">컬럼 마스킹</a>의 ASO/Data Redaction에서만 제어합니다.</p>
</section>
<section class="content-band">
<div class="section-heading">
<div>
<h2>현재 연결된 행 접근 정책(VPD)</h2>
<p class="section-subtitle">기본 Filter를 실제로 호출하는 보호 객체 정책입니다.</p>
</div>
<span class="badge text-bg-secondary" th:text="${#lists.size(policies)}">0</span>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr><th>Object</th><th>Policy</th><th>Statement</th><th>상태</th><th>Filter</th></tr>
</thead>
<tbody>
<tr th:each="policy : ${policies}">
<td><code th:text="${policy.objectDisplayName()}">POC_2.KB_CONTRACTS</code></td>
<td><code th:text="${policy.policyName()}">KB_KB_CONTRACTS_ROW_POLICY</code></td>
<td th:text="${policy.statementTypes()}">SELECT</td>
<td><span class="badge" th:classappend="${policy.enabled() == 'YES'} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${policy.enabled() == 'YES'} ? '적용됨' : '중지됨'">적용됨</span></td>
<td><code th:text="${policy.functionDisplayName()}">ADMIN.CB_AGENT_DOC_VPD_FILTER</code></td>
</tr>
<tr th:if="${#lists.isEmpty(policies)}">
<td colspan="5" class="text-muted">기본 행 접근 Filter가 연결된 보호 객체를 찾지 못했습니다.</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="content-band">
<h2>운영 점검 기준</h2>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead><tr><th>점검 항목</th><th>확인 기준</th><th>조치 위치</th></tr></thead>
<tbody>
<tr><td>토큰 신뢰 경계</td><td>Bearer Token은 ORDS에서 검증하고 요청 종료 시 컨텍스트를 정리합니다.</td><td>ORDS Handler / <code>CB_AGENT_CTX_PKG</code></td></tr>
<tr><td>권한 결합</td><td>권한 내부 규칙은 AND, ALLOW 권한은 OR, DENY 조건은 최종적으로 제외합니다.</td><td><a href="/permissions">행 접근 규칙</a></td></tr>
<tr><td>오류·미권한</td><td>유효한 컨텍스트나 ALLOW 권한이 없으면 행 접근은 차단돼야 합니다.</td><td><a href="/probe">접근 검증</a></td></tr>
<tr><td>컬럼 보호</td><td>행 필터(VPD)와 보험료·주민번호 등의 ASO/Data Redaction 마스킹을 분리해 확인합니다.</td><td><a href="/masking-rules">컬럼 마스킹</a></td></tr>
</tbody>
</table>
</div>
</section>
<section class="content-band">
<h2>배포된 함수 소스</h2>
<p class="section-subtitle">현재 DB의 <code>ALL_SOURCE</code>에서 읽은 소스입니다. 형상 기준 SQL과 다르면 배포 이력을 확인하세요.</p>
<section class="probe-exchange" th:if="${source}">
<h3><span th:text="${source.owner() + '.' + source.objectName()}">ADMIN.CB_AGENT_DOC_VPD_FILTER</span> <span class="text-muted" th:text="${' / ' + source.objectType()}"> / FUNCTION</span></h3>
<pre th:if="${source.found()}" th:text="${source.source()}"></pre>
<pre th:unless="${source.found()}">ALL_SOURCE에서 조회 가능한 소스가 없습니다. DB 연결 계정의 source 조회 권한을 확인하세요.</pre>
</section>
<div th:unless="${source}" class="text-muted">기본 Filter source를 조회할 연결 정보를 찾지 못했습니다.</div>
</section>
</main>
</body>
</html>

View File

@@ -8,7 +8,8 @@
<h1>보호 상태</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>권한 화면에서 만든 사용자·그룹·역할·행·열 규칙을 Oracle VPD가 실제 TABLE/VIEW에 적용하도록 연결합니다. Policy는 어느 객체의 어떤 SQL에 어떤 Filter function을 붙일지 정하고, 실제 허용 조건은 권한 규칙과 Filter가 계산합니다.</p>
<p>행 접근 규칙 화면에서 만든 사용자·그룹·역할·행 규칙을 Oracle VPD가 실제 TABLE/VIEW에 적용하도록 연결합니다. Policy는 어느 객체의 어떤 SQL에 어떤 Filter function을 붙일지 정하고, 실제 허용 조건은 행 접근 규칙과 Filter가 계산합니다.</p>
<p>이 화면은 <strong>행 접근 정책</strong>만 확인합니다. 컬럼 원문/마스킹 적용 상태는 <a href="/masking-rules">컬럼 마스킹</a>의 ASO/Data Redaction 상태에서 확인하세요.</p>
</details>
</div>
@@ -23,10 +24,10 @@
<div class="section-heading">
<div>
<h2>보호 대상</h2>
<p class="section-subtitle">VPD 적용과 검증 준비 상태를 확인합니다.</p>
<p class="section-subtitle">행 접근 정책 적용과 검증 준비 상태를 확인합니다.</p>
<details class="explanation-details">
<summary>객체 상태 항목 설명 보기</summary>
<p>VPD가 붙었는지, 권한 규칙과 검증 경로가 준비됐는지 한곳에서 확인합니다. 아래 기본 적용에서는 객체만 선택하면 됩니다.</p>
<p>Oracle VPD 정책이 붙었는지, 행 접근 규칙과 검증 경로가 준비됐는지 한곳에서 확인합니다. 아래 기본 적용에서는 객체만 선택하면 됩니다.</p>
</details>
</div>
<span class="badge text-bg-secondary" th:text="${#lists.size(vpdTargets)}">0</span>

View File

@@ -4,6 +4,10 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -28,6 +32,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.filter.ForwardedHeaderFilter;
@@ -45,7 +50,13 @@ class TransportSecurityTest {
TestPropertyValues.of(
"backoffice.security.admin-user=admin",
"backoffice.security.admin-password=test-password",
"backoffice.security.guest-enabled=true",
"backoffice.security.guest-user=guest",
"backoffice.security.guest-password=guest-password",
"backoffice.security.require-https=true",
"backoffice.security.remember-me-enabled=true",
"backoffice.security.remember-me-key=test-only-remember-me-key",
"backoffice.security.remember-me-days=14",
"backoffice.token.max-days=365",
"backoffice.ords.base-url=https://ords.example.test",
"backoffice.ords.timeout=10s",
@@ -95,6 +106,69 @@ class TransportSecurityTest {
.andExpect(redirectedUrl("https://admin.example.test/login"));
}
@Test
void rememberMeIsIssuedOnlyWhenTheLoginCheckboxIsSelected() throws Exception {
mockMvc.perform(post("/login")
.with(csrf())
.param("username", "admin")
.param("password", "test-password")
.param("remember-me", "true")
.header("X-Forwarded-Proto", "https")
.header("X-Forwarded-Host", "admin.example.test"))
.andExpect(status().is3xxRedirection())
.andExpect(cookie().exists("VPD_REMEMBER_ME"))
.andExpect(cookie().secure("VPD_REMEMBER_ME", true))
.andExpect(cookie().httpOnly("VPD_REMEMBER_ME", true));
}
@Test
void guestLoginSucceedsWhenConfigured() throws Exception {
mockMvc.perform(post("/login")
.with(csrf())
.param("username", "guest")
.param("password", "guest-password")
.header("X-Forwarded-Proto", "https")
.header("X-Forwarded-Host", "admin.example.test"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("https://admin.example.test/"));
}
@Test
void viewerCanReadButCannotPostMutations() throws Exception {
mockMvc.perform(get("/")
.with(user("guest").roles("VIEWER"))
.header("X-Forwarded-Proto", "https")
.header("X-Forwarded-Host", "admin.example.test"))
.andExpect(status().isOk());
mockMvc.perform(post("/mutation")
.with(csrf())
.with(user("guest").roles("VIEWER"))
.header("X-Forwarded-Proto", "https")
.header("X-Forwarded-Host", "admin.example.test"))
.andExpect(status().isForbidden());
}
@Test
void viewerCanPostReadOnlyExecutionForms() throws Exception {
mockMvc.perform(post("/probe")
.with(csrf())
.with(user("guest").roles("VIEWER"))
.header("X-Forwarded-Proto", "https")
.header("X-Forwarded-Host", "admin.example.test"))
.andExpect(status().isOk());
}
@Test
void adminCanPostMutations() throws Exception {
mockMvc.perform(post("/mutation")
.with(csrf())
.with(user("admin").roles("ADMIN"))
.header("X-Forwarded-Proto", "https")
.header("X-Forwarded-Host", "admin.example.test"))
.andExpect(status().isOk());
}
@Test
void productionSessionCookieSettingsAreBound() throws Exception {
var environment = new StandardEnvironment();
@@ -138,5 +212,15 @@ class TransportSecurityTest {
String home() {
return "home";
}
@PostMapping("/mutation")
String mutation() {
return "changed";
}
@PostMapping("/probe")
String probe() {
return "queried";
}
}
}

View File

@@ -0,0 +1,26 @@
package com.cloudhandson.vpdbackoffice.domain.masking;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
class MaskingTemplateTest {
@Test
void exposesOnlyCuratedTemplates() {
assertThat(MaskingTemplate.from("rrn_partial"))
.isEqualTo(MaskingTemplate.RRN_PARTIAL);
assertThat(MaskingTemplate.FULL.asoFunction())
.isEqualTo("DBMS_REDACT.FULL");
assertThat(MaskingTemplate.NULLIFY.previewResult())
.isEqualTo("NULL");
}
@Test
void rejectsUnknownTemplateCode() {
assertThatThrownBy(() -> MaskingTemplate.from("RAW_SQL"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("지원하지 않는");
}
}

View File

@@ -26,7 +26,7 @@ class ProbeResultTest {
assertThat(result.successLike()).isTrue();
assertThat(result.title()).contains("데이터를 볼 수 있습니다");
assertThat(result.plainSummary()).contains("2개").contains("VPD");
assertThat(result.plainSummary()).contains("2개").contains("행 접근 정책");
assertThat(result.nextAction()).contains("예상한 범위");
}
@@ -82,7 +82,7 @@ class ProbeResultTest {
assertThat(inactive.title()).contains("만료되었거나 회수");
assertThat(inactive.nextAction()).contains("활성 토큰");
assertThat(unavailable.title()).contains("ORDS");
assertThat(unavailable.nextAction()).contains("권한 설정을 바꾸지 말고");
assertThat(unavailable.nextAction()).contains("행 접근 규칙을 바꾸지 말고");
}
@Test
@@ -93,9 +93,9 @@ class ProbeResultTest {
"ORA-28110"
);
assertThat(result.title()).contains("VPD Filter");
assertThat(result.title()).contains("행 접근 Filter");
assertThat(result.plainSummary()).contains("토큰과 사용자 권한은 확인");
assertThat(result.nextAction()).contains("토큰이나 권한을 바꾸지 말고").contains("자동 Filter");
assertThat(result.nextAction()).contains("토큰이나 행 접근 규칙을 바꾸지 말고").contains("표준 행 접근 Filter");
}
@Test

View File

@@ -1,21 +0,0 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.mockito.Mockito.verify;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
class DdsAuthorizationChangeNotifierTest {
@Test
void invokesTheOptionalDdsSynchronizerWhenTheDedicatedApplicationProvidesOne() {
DdsAuthorizationSynchronizer synchronizer = org.mockito.Mockito.mock(DdsAuthorizationSynchronizer.class);
DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
factory.registerSingleton("ddsSynchronizer", synchronizer);
new DdsAuthorizationChangeNotifier(factory.getBeanProvider(DdsAuthorizationSynchronizer.class))
.changed("PERMISSION_SAVED");
verify(synchronizer).synchronize("PERMISSION_SAVED");
}
}

View File

@@ -57,8 +57,8 @@ class EffectiveMatrixServiceTest {
@Override
public List<PermissionView> findPermissionViews() {
return List.of(
new PermissionView(1000L, 10L, "DIRECT_ROLE", 1L, "BOARD_POSTS", "SELECT", "ALLOW", "ALL", null, "ALL ROWS", "모든 민감 컬럼 NULL 처"),
new PermissionView(1001L, 20L, "GROUP_ROLE", 2L, "BOARD_ASSIGNMENTS", "SELECT", "ALLOW", "ALL", null, "ALL ROWS", "모든 민감 컬럼 NULL 처")
new PermissionView(1000L, 10L, "DIRECT_ROLE", 1L, "BOARD_POSTS", "SELECT", "ALLOW", "ALL", null, "ALL ROWS", "컬럼 원문/마스킹은 컬럼 마스킹에서 관"),
new PermissionView(1001L, 20L, "GROUP_ROLE", 2L, "BOARD_ASSIGNMENTS", "SELECT", "ALLOW", "ALL", null, "ALL ROWS", "컬럼 원문/마스킹은 컬럼 마스킹에서 관")
);
}
}

View File

@@ -0,0 +1,21 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.mockito.Mockito.verify;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
class ExternalAuthorizationChangeNotifierTest {
@Test
void invokesTheOptionalExternalSynchronizerWhenOneIsConfigured() {
ExternalAuthorizationSynchronizer synchronizer = org.mockito.Mockito.mock(ExternalAuthorizationSynchronizer.class);
DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
factory.registerSingleton("externalAuthorizationSynchronizer", synchronizer);
new ExternalAuthorizationChangeNotifier(factory.getBeanProvider(ExternalAuthorizationSynchronizer.class))
.changed("PERMISSION_SAVED");
verify(synchronizer).synchronize("PERMISSION_SAVED");
}
}

View File

@@ -0,0 +1,30 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class MaskingPolicySynchronizerTest {
@Test
void maskingExpressionIsAParseableSqlConditionForRedactionExpressionBindValue() {
assertThat(MaskingPolicySynchronizer.maskingExpression(11))
.isEqualTo(
"SYS_CONTEXT('CB_AGENT_CTX', 'MR_11') IS NULL "
+ "OR SYS_CONTEXT('CB_AGENT_CTX', 'MR_11') <> 'Y'");
}
@Test
void maskingExpressionDoesNotUsePlsqlLiteralEscapingBecauseJdbcBindsTheValue() {
assertThat(MaskingPolicySynchronizer.maskingExpression(11))
.doesNotContain("''CB_AGENT_CTX''")
.doesNotContain("''MR_11''")
.doesNotContain("''Y''");
}
@Test
void kbContractsPremiumPolicyIsManagedByBackoffice() {
assertThat(MaskingPolicySynchronizer.managedPolicyName("KB_CONTRACTS"))
.isEqualTo("KB_CONTRACT_PREMIUM_REDACT");
}
}

View File

@@ -4,7 +4,6 @@ import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.List;
import org.junit.jupiter.api.Test;
class McpSseServiceTest {
@@ -12,50 +11,65 @@ class McpSseServiceTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final SelectAiAgentOrdsService selectAiAgentOrdsService = new CapturingSelectAiAgentOrdsService();
private final McpSseService service = new McpSseService(
new EmptyToolRegistry(),
null,
selectAiAgentOrdsService,
objectMapper
);
@Test
void listsSelectAiRouterToolWithPromptAndBearerInputs() {
void listsOnlyVpdSelectAiToolWithPromptInput() {
ObjectNode response = service.handle("default", request(1, "tools/list"));
var tools = response.path("result").path("tools");
var tool = tools.findValuesAsText("name").indexOf("ords.agent.kb_select_ai_router");
assertThat(tool).isGreaterThanOrEqualTo(0);
var router = tools.get(tool);
assertThat(router.path("inputSchema").path("required"))
assertThat(tools).hasSize(1);
var selectAi = tools.get(0);
assertThat(selectAi.path("name").asText()).isEqualTo("ords.query.kb_select_ai_vpd");
assertThat(selectAi.path("inputSchema").path("required"))
.extracting(node -> node.asText())
.contains("bearerToken", "prompt");
assertThat(router.path("inputSchema").path("properties").has("conversationId")).isTrue();
.contains("prompt");
assertThat(selectAi.path("inputSchema").path("properties").has("bearerToken")).isFalse();
assertThat(selectAi.path("inputSchema").path("properties").has("limit")).isTrue();
assertThat(selectAi.path("inputSchema").path("properties").has("conversationId")).isFalse();
}
@Test
void callsSelectAiRouterThroughOrdsService() {
void callsVpdSelectAiThroughOrdsService() {
ObjectNode request = request(2, "tools/call");
ObjectNode params = (ObjectNode) request.putObject("params");
params.put("name", "ords.agent.kb_select_ai_router");
params.put("name", "ords.query.kb_select_ai_vpd");
ObjectNode arguments = params.putObject("arguments");
arguments.put("bearerToken", "user-bearer");
arguments.put("prompt", "고객 수를 세는 SQL을 만들어줘");
arguments.put("conversationId", "smoke-1");
arguments.put("prompt", "고객 수를 조회해 줘");
arguments.put("limit", 25);
ObjectNode response = service.handle("default", request);
ObjectNode response = service.handle("default", request, "user-bearer");
CapturingSelectAiAgentOrdsService agentService =
(CapturingSelectAiAgentOrdsService) selectAiAgentOrdsService;
assertThat(agentService.bearerToken).isEqualTo("user-bearer");
assertThat(agentService.prompt).isEqualTo("고객 수를 세는 SQL을 만들어");
assertThat(agentService.conversationId).isEqualTo("smoke-1");
assertThat(agentService.prompt).isEqualTo("고객 수를 조회해 ");
assertThat(agentService.limit).isEqualTo(25);
assertThat(response.path("error").isMissingNode()).isTrue();
assertThat(response.path("result").path("isError").asBoolean()).isFalse();
assertThat(response.path("result").path("content").get(0).path("text").asText())
.contains("KB_SELECT_AI_ROUTER_TEAM")
.contains("KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1")
.contains("SELECT COUNT(*) FROM KB_CUSTOMERS");
}
@Test
void returnsToolLevelDeniedResultWhenVpdTokenIsMissing() {
ObjectNode request = request(3, "tools/call");
ObjectNode params = (ObjectNode) request.putObject("params");
params.put("name", "ords.query.kb_select_ai_vpd");
params.putObject("arguments").put("prompt", "고객 수를 조회해 줘");
ObjectNode response = service.handle("default", request, "");
assertThat(response.path("error").isMissingNode()).isTrue();
assertThat(response.path("result").path("isError").asBoolean()).isTrue();
assertThat(response.path("result").path("content").get(0).path("text").asText())
.contains("VPD_TOKEN_DENIED")
.contains("권한이 없습니다");
}
private ObjectNode request(int id, String method) {
ObjectNode request = objectMapper.createObjectNode();
request.put("jsonrpc", "2.0");
@@ -64,36 +78,24 @@ class McpSseServiceTest {
return request;
}
private static final class EmptyToolRegistry extends McpToolRegistry {
private EmptyToolRegistry() {
super(null);
}
@Override
public List<com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView> listTools() {
return List.of();
}
}
private static final class CapturingSelectAiAgentOrdsService extends SelectAiAgentOrdsService {
private String bearerToken;
private String prompt;
private String conversationId;
private int limit;
private CapturingSelectAiAgentOrdsService() {
super(null, null, new ObjectMapper());
}
@Override
public JsonNode run(String bearerToken, String prompt, String conversationId) {
public JsonNode run(String bearerToken, String prompt, int limit) {
this.bearerToken = bearerToken;
this.prompt = prompt;
this.conversationId = conversationId;
this.limit = limit;
return new ObjectMapper().createObjectNode()
.put("team", "KB_SELECT_AI_ROUTER_TEAM")
.put("answer", "SELECT COUNT(*) FROM KB_CUSTOMERS");
.put("profile", "KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1")
.put("generatedSql", "SELECT COUNT(*) FROM KB_CUSTOMERS");
}
}
}

View File

@@ -319,7 +319,7 @@ class PermissionServiceTest {
}
@Test
void acceptsMultipleVisibleColumns() {
void ignoresVisibleColumnsBecauseAsoHandlesColumnMasking() {
var command = new PermissionSetCommand(
10L,
1L,
@@ -332,7 +332,7 @@ class PermissionServiceTest {
permissionService.savePermissionSet(command);
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
assertThat(mapper.insertedVisibleColumns).containsExactly("DEPT_CODE", "OWNER_EMP_NO");
assertThat(mapper.insertedVisibleColumns).isEmpty();
}
@Test

View File

@@ -0,0 +1,35 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
class SecuritySqlScriptServiceTest {
private final SecuritySqlScriptService service = new SecuritySqlScriptService();
@Test
void exposesOnlyTheCuratedGitTrackedSecurityScripts() {
assertThat(service.list())
.extracting(item -> item.fileName())
.containsExactly(
"62_kb_aso_masking_backoffice_metadata.sql",
"63_kb_aso_masking_rule_runtime.sql",
"64_kb_aso_masking_default_column_rules.sql",
"65_kb_select_ai_vpd_query_api.sql",
"66_kb_select_ai_vpd_query_ords.sql"
);
assertThat(service.find("select-ai-vpd-ords").source())
.contains("POST /ords/cb-ords/kb-select-ai-vpd/query")
.contains("Authorization: Bearer <VPD token>");
}
@Test
void rejectsUnknownScriptIdsInsteadOfResolvingAPathFromRequestInput() {
assertThatThrownBy(() -> service.find("../../etc/passwd"))
.isInstanceOf(AppException.class)
.hasMessageContaining("조회할 수 없는");
}
}

Some files were not shown because too many files have changed in this diff Show More