Consolidate data access control backoffice updates
This commit is contained in:
@@ -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("조회할 수 없는");
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,8 @@ class GuidedFlowTemplateTest {
|
||||
String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js"));
|
||||
|
||||
assertThat(html)
|
||||
.contains("th:replace=\"~{fragments/layout :: head('VPD 권한 운영')}\"")
|
||||
.contains("권한 운영 흐름")
|
||||
.contains("th:replace=\"~{fragments/layout :: head('데이터 접근 제어')}\"")
|
||||
.contains("행 접근과 컬럼 마스킹 운영 흐름")
|
||||
.contains("업무 흐름")
|
||||
.contains("dashboard-menu-flow")
|
||||
.contains("도움말: 메뉴와 권한 적용 구조 보기")
|
||||
@@ -27,7 +27,7 @@ class GuidedFlowTemplateTest {
|
||||
.contains("CB_AGENT_CTX")
|
||||
.contains("권한 데이터 모델")
|
||||
.contains("접근 검증")
|
||||
.contains("접근 규칙")
|
||||
.contains("행 접근 규칙")
|
||||
.contains("현재 상태")
|
||||
.contains("등록 대상 상세")
|
||||
.contains("<details class=\"dashboard-details\">")
|
||||
@@ -35,8 +35,8 @@ class GuidedFlowTemplateTest {
|
||||
.doesNotContain("dashboard-workflow")
|
||||
.doesNotContain(">01<", ">02<");
|
||||
assertThat(layout)
|
||||
.contains("VPD 권한 운영")
|
||||
.contains("권한 관리")
|
||||
.contains("데이터 접근 제어")
|
||||
.contains("접근 제어")
|
||||
.contains("보호·검증")
|
||||
.contains("연동 도구")
|
||||
.contains("운영")
|
||||
@@ -44,16 +44,24 @@ class GuidedFlowTemplateTest {
|
||||
.contains("조회 연동")
|
||||
.contains("정형 데이터 조회")
|
||||
.contains("MCP 서비스")
|
||||
.contains("컬럼 마스킹")
|
||||
.contains("컬럼 원문 표시 허용")
|
||||
.contains("행 접근 필터 구조")
|
||||
.contains("보안 SQL 스크립트")
|
||||
.contains("고급 접근 조건")
|
||||
.contains("시스템 설정")
|
||||
.contains("DB 준비 상태")
|
||||
.contains("data-submenu-trigger")
|
||||
.contains("data-submenu-bar")
|
||||
.contains("data-submenu-panel")
|
||||
.contains("backoffice-can-mutate")
|
||||
.contains("backoffice-read-only")
|
||||
.doesNotContain("VPD Backoffice");
|
||||
assertThat(javascript)
|
||||
.contains("data-submenu-trigger")
|
||||
.contains("data-submenu-panel")
|
||||
.contains("disableReadOnlyMutationForms")
|
||||
.contains("/vector-knowledge/search")
|
||||
.doesNotContain("closeAllMenuGroups");
|
||||
}
|
||||
|
||||
@@ -64,7 +72,8 @@ class GuidedFlowTemplateTest {
|
||||
"effective-matrix.html", "users.html", "groups.html", "roles.html", "vector-knowledge.html",
|
||||
"operation-status.html", "settings.html", "settings-database.html", "ords-handlers.html",
|
||||
"vpd-filter-policies.html", "mcp-chatbot.html", "mcp-reasoning.html", "mcp-client-demo.html",
|
||||
"mcp-sse.html", "structured-data.html")) {
|
||||
"mcp-sse.html", "structured-data.html", "masking-rules.html", "user-masking-rules.html",
|
||||
"vpd-filter-runtime.html", "security-sql-scripts.html")) {
|
||||
assertThat(template(page))
|
||||
.contains("<summary>도움말</summary>")
|
||||
.doesNotContain("context-summary")
|
||||
@@ -74,6 +83,33 @@ class GuidedFlowTemplateTest {
|
||||
.doesNotContain("1 · CONTENT", "2 · ACCESS POLICY", "3 · SEARCH");
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginOffersRememberMeOnlyThroughTheSpringSecurityParameter() throws IOException {
|
||||
assertThat(template("login.html"))
|
||||
.contains("name=\"remember-me\"")
|
||||
.contains("데이터 접근 제어 콘솔")
|
||||
.contains("<span class=\"login-mark\">접근</span>")
|
||||
.contains("로그인 유지")
|
||||
.contains("th:if=\"${rememberMeAvailable}\"")
|
||||
.doesNotContain("<span class=\"login-mark\">VPD</span>");
|
||||
}
|
||||
|
||||
@Test
|
||||
void securitySqlScriptsCanRequestExplanationWithoutExecutingSql() throws IOException {
|
||||
String page = template("security-sql-scripts.html");
|
||||
String result = template("fragments/security-sql-script-explanation.html");
|
||||
|
||||
assertThat(page)
|
||||
.contains("hx-post=\"/security-sql-scripts/explanation\"")
|
||||
.contains("LLM 전체·블록·토큰 처리 설명 생성")
|
||||
.contains("원문은 실행·수정하지 않고");
|
||||
assertThat(result)
|
||||
.contains("LLM 스크립트 설명")
|
||||
.contains("토큰 처리와 행 접근/ASO 적용 흐름")
|
||||
.contains("data-markdown-view")
|
||||
.contains("근거 Prompt 보기");
|
||||
}
|
||||
|
||||
@Test
|
||||
void productFormsUseTaskNamesInsteadOfVisibleStepNumbers() throws IOException {
|
||||
String permissions = template("permissions.html");
|
||||
@@ -148,11 +184,11 @@ class GuidedFlowTemplateTest {
|
||||
String html = template("objects.html");
|
||||
|
||||
assertThat(html)
|
||||
.contains("누가 어떤 행을 볼 수 있는지는 권한 규칙과 VPD에서 결정")
|
||||
.contains("누가 어떤 행을 볼 수 있는지는 행 접근 규칙과 Oracle VPD 정책에서 결정")
|
||||
.contains("지식 검색 연결 흐름")
|
||||
.contains("VPD context 설정과 기본 SELECT")
|
||||
.contains("행 접근 context 설정과 기본 SELECT")
|
||||
.contains("전용 벡터 Handler 사용")
|
||||
.contains("원문 표시 허용 컬럼")
|
||||
.contains("컬럼 마스킹")
|
||||
.doesNotContain("표시 보호 등급", "마스킹 방식");
|
||||
}
|
||||
|
||||
@@ -186,7 +222,7 @@ class GuidedFlowTemplateTest {
|
||||
|
||||
assertThat(roles)
|
||||
.contains("표시 보호 등급 상한")
|
||||
.contains("행 접근 권한은 권한 규칙과 VPD에서 결정합니다");
|
||||
.contains("행 접근 권한은 행 접근 규칙과 Oracle VPD 정책에서 결정합니다");
|
||||
assertThat(tokens)
|
||||
.contains("회수·만료 포함")
|
||||
.contains("현재 사용 가능한 토큰만");
|
||||
@@ -241,7 +277,7 @@ class GuidedFlowTemplateTest {
|
||||
assertThat(chatbot).contains("검증 세션 사용자").contains("태그 벡터 검색");
|
||||
assertThat(reasoning).contains("MCP tool을 고르고").contains("검증 세션 사용자").contains("MCP Tool");
|
||||
assertThat(client).contains("tool 선택과 호출은 reasoning 결과").doesNotContain("Context Path");
|
||||
assertThat(sse).contains("Instruction / parameter mapping").contains("bearerToken");
|
||||
assertThat(sse).contains("Instruction / parameter mapping").contains("Authorization");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -274,7 +310,7 @@ class GuidedFlowTemplateTest {
|
||||
.contains("RLS_INFO")
|
||||
.contains("참고용 SQL 재현")
|
||||
.contains("SQL (재현)")
|
||||
.contains("VPD 조건 (재현)")
|
||||
.contains("행 접근 조건 (VPD predicate 재현)")
|
||||
.contains("ALL: 추가 행 필터 없음")
|
||||
.contains("기본 whitelist 역할");
|
||||
assertThat(vectorSql)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule;
|
||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule;
|
||||
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.ProtectedColumn;
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
|
||||
import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.thymeleaf.context.Context;
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||
import org.thymeleaf.templateresolver.FileTemplateResolver;
|
||||
|
||||
class MaskingRuleTemplateRenderTest {
|
||||
|
||||
@Test
|
||||
void rendersRuleRegistrationAndUserExceptionPages() {
|
||||
var resolver = new FileTemplateResolver();
|
||||
resolver.setPrefix(Path.of("src/main/resources/templates").toAbsolutePath() + "/");
|
||||
resolver.setSuffix(".html");
|
||||
resolver.setTemplateMode("HTML");
|
||||
resolver.setCacheable(false);
|
||||
var engine = new SpringTemplateEngine();
|
||||
engine.setTemplateResolver(resolver);
|
||||
|
||||
var object = new ProtectedObject(7L, "POC_2", "KB_CUSTOMERS", "kb/customers", "Y");
|
||||
var contractObject = new ProtectedObject(8L, "POC_2", "KB_CONTRACTS", "kb/contracts", "Y");
|
||||
var column = new ProtectedColumn(11L, 7L, "RRN_MASKED", "Y", null, "RESTRICTED", "NULLIFY");
|
||||
var rule = new MaskingRule(1L, "KB_RRN_STANDARD", "주민번호 기본 마스킹", "RRN_PARTIAL", "테스트", "Y");
|
||||
var columnRule = new ColumnMaskingRule(11L, 7L, "POC_2", "KB_CUSTOMERS", "RRN_MASKED",
|
||||
1L, "KB_RRN_STANDARD", "주민번호 기본 마스킹", "RRN_PARTIAL", "Y");
|
||||
var context = new Context(Locale.KOREAN);
|
||||
context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token"));
|
||||
context.setVariable("templates", List.of(MaskingTemplate.values()));
|
||||
context.setVariable("rules", List.of(rule));
|
||||
context.setVariable("columnRules", List.of(columnRule));
|
||||
var status = new MaskingPolicyStatus(
|
||||
"POC_2", "KB_CUSTOMERS", "KB_CUSTOMER_PII_REDACT", "YES", 1, 1, 0, 0
|
||||
);
|
||||
context.setVariable("policyStatuses", List.of(status));
|
||||
context.setVariable("policyStatusByObjectName", Map.of("KB_CUSTOMERS", status));
|
||||
context.setVariable("objects", List.of(object, contractObject));
|
||||
context.setVariable("maskingTargetObjects", List.of(object, contractObject));
|
||||
context.setVariable("sensitiveColumnsByObject", Map.of(7L, List.of(column), 8L, List.of()));
|
||||
context.setVariable("availableMaskingColumnsByObject", Map.of(7L, List.of("CUST_NM"), 8L, List.of("PREMIUM")));
|
||||
|
||||
String rulesPage = engine.process("masking-rules", context);
|
||||
|
||||
assertThat(rulesPage)
|
||||
.contains("사전 정의 마스킹 방식")
|
||||
.contains("민감 컬럼에 기본 규칙 연결")
|
||||
.contains("DBMS_REDACT.REGEXP")
|
||||
.contains("컬럼 원문 표시 허용 사용자")
|
||||
.contains("현재 DB 정책 동기화")
|
||||
.contains("자동 동기화")
|
||||
.contains("DB Redaction 컬럼")
|
||||
.contains("레거시 VPD 컬럼 제어")
|
||||
.contains("마스킹 대상 컬럼 추가")
|
||||
.contains("POC_2.KB_CONTRACTS.PREMIUM")
|
||||
.contains("KB_CUSTOMER_PII_REDACT")
|
||||
.contains("적용됨");
|
||||
|
||||
context.setVariable("users", List.of(new AppUser(2L, "KB_VPD_ADMIN", "ADMIN", "SEC", "N", "Y")));
|
||||
context.setVariable("userRules", List.of(new UserMaskingRule(2L, "KB_VPD_ADMIN", 11L,
|
||||
"POC_2", "KB_CUSTOMERS", "RRN_MASKED", "주민번호 기본 마스킹", "RRN_PARTIAL", "UNMASK", "Y")));
|
||||
String userPage = engine.process("user-masking-rules", context);
|
||||
|
||||
assertThat(userPage)
|
||||
.contains("컬럼 원문 표시 허용 사용자 추가")
|
||||
.contains("원문 표시 허용")
|
||||
.contains("행 접근 여부는 변경하지 않습니다")
|
||||
.contains("선택한 컬럼의 실제 적용 결과")
|
||||
.contains("data-masking-exception-preview")
|
||||
.contains("MR_11");
|
||||
}
|
||||
|
||||
private record CsrfFixture(String parameterName, String token) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user