Consolidate data access control backoffice updates
This commit is contained in:
@@ -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, "", "", "", "", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 컬럼이 다릅니다. 동기화가 필요합니다.";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.masking;
|
||||
|
||||
public record MaskingRuleCreateCommand(
|
||||
String ruleCode,
|
||||
String ruleName,
|
||||
String templateCode,
|
||||
String description
|
||||
) {
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 컴파일 오류를 확인하세요.";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 -> "아래 기술 상세의 오류 코드와 응답을 확인한 뒤 해당 단계부터 점검하세요.";
|
||||
};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.schemametadata;
|
||||
|
||||
public record SchemaAnnotation(String name, String value) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
/** Optional bridge implemented by an external authorization runtime. */
|
||||
public interface ExternalAuthorizationSynchronizer {
|
||||
|
||||
void synchronize(String reason);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 + "건";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 권한 검증 보조자입니다.
|
||||
백오피스가 제공한 도구 실행 증거만 근거로 판단하고, 토큰 원문이나 비밀 값을 재출력하지 마세요.
|
||||
""";
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("선택할 수 없는 정형 데이터 테이블입니다.");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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이 없거나 유효하지 않아 이 요청을 수행할 권한이 없습니다.");
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 문법을 확인하세요.";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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:}}
|
||||
|
||||
264
src/main/resources/mapper/MaskingRuleMapper.xml
Normal file
264
src/main/resources/mapper/MaskingRuleMapper.xml
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -2745,3 +2745,12 @@ body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.readonly-mutation-notice {
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.readonly-mutation-disabled {
|
||||
opacity: .58;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<h1>사용자별 접근 확인</h1>
|
||||
<details class="explanation-details">
|
||||
<summary>도움말</summary>
|
||||
<p>직접 역할과 그룹 상속 역할을 합쳐 최종 권한을 계산합니다. 여기의 결과는 토큰을 발급해 ORDS/VPD 조회를 검증하기 전 확인하는 설계 근거입니다.</p>
|
||||
<p>직접 역할과 그룹 상속 역할을 합쳐 최종 권한을 계산합니다. 여기의 결과는 토큰을 발급해 ORDS 행 접근 조회를 검증하기 전 확인하는 설계 근거입니다.</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 <= :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 <= :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 <= :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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
262
src/main/resources/templates/masking-rules.html
Normal file
262
src/main/resources/templates/masking-rules.html
Normal 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_<column_id> = 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_<column_id></code>를 설정하고, Data Redaction 조건식은 <code>SYS_CONTEXT('CB_AGENT_CTX', 'MR_<column_id>')</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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 <사용자 Bearer Token></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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
198
src/main/resources/templates/schema-metadata.html
Normal file
198
src/main/resources/templates/schema-metadata.html
Normal 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>
|
||||
66
src/main/resources/templates/security-sql-scripts.html
Normal file
66
src/main/resources/templates/security-sql-scripts.html
Normal 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>
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
이 화면은 관리자용 원장 미리보기입니다. 사용자별 VPD 적용 결과는 <a href="/probe">접근 검증</a>에서 확인하세요.
|
||||
이 화면은 관리자용 원장 미리보기입니다. 사용자별 행 접근 적용 결과는 <a href="/probe">접근 검증</a>에서 확인하세요.
|
||||
</div>
|
||||
|
||||
<section class="content-band">
|
||||
|
||||
@@ -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">
|
||||
|
||||
109
src/main/resources/templates/user-masking-rules.html
Normal file
109
src/main/resources/templates/user-masking-rules.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}">
|
||||
|
||||
126
src/main/resources/templates/vpd-filter-runtime.html
Normal file
126
src/main/resources/templates/vpd-filter-runtime.html
Normal 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_<column_id></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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("지원하지 않는");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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", "컬럼 원문/마스킹은 컬럼 마스킹에서 관리")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user