From 1766696ab4aaff3acaa36dd9624194dd12ed3e95 Mon Sep 17 00:00:00 2001 From: devmrko Date: Tue, 30 Jun 2026 20:31:31 +0900 Subject: [PATCH] feat: add DDS protection status dashboard --- .../ddsbackoffice/config/DdsProperties.java | 17 +- .../domain/DdsGrantInventoryEntry.java | 9 + .../domain/DdsGrantInventorySnapshot.java | 27 +++ .../domain/DdsProtectionOverview.java | 19 ++ .../domain/DdsProtectionPathStatus.java | 37 +++ .../service/DdsGrantInventory.java | 9 + .../service/DdsGrantPublisher.java | 2 +- .../service/DdsProtectionStatusService.java | 214 ++++++++++++++++++ .../service/DdsProvisionPlanProvider.java | 10 + .../service/JdbcDdsGrantInventory.java | 44 ++++ .../web/DdsProtectionController.java | 47 ++-- .../src/main/resources/application.yml | 1 + .../src/main/resources/static/css/dds.css | 31 ++- .../main/resources/templates/dds-home.html | 2 +- .../resources/templates/dds-provision.html | 2 +- .../fragments/dds-protection-direct.html | 26 +++ .../resources/templates/fragments/layout.html | 4 +- .../resources/templates/vpd-policies.html | 161 +++++++------ .../config/DdsPropertiesTest.java | 4 +- .../DdsProtectionStatusServiceTest.java | 92 ++++++++ scripts/deploy-dds-backoffice-local.sh | 2 +- 21 files changed, 637 insertions(+), 123 deletions(-) create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsGrantInventoryEntry.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsGrantInventorySnapshot.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsProtectionOverview.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsProtectionPathStatus.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsGrantInventory.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionStatusService.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProvisionPlanProvider.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/JdbcDdsGrantInventory.java create mode 100644 dds-backoffice/src/main/resources/templates/fragments/dds-protection-direct.html create mode 100644 dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionStatusServiceTest.java diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsProperties.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsProperties.java index 10753c5..d0837af 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsProperties.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsProperties.java @@ -81,10 +81,21 @@ public record DdsProperties( new Token("dds_demo_token", "")); } - public record Token(String username, String password) { + public record Token(String username, String password, String dataRole) { + + @ConstructorBinding + public Token { + username = username == null ? "" : username.trim(); + password = password == null ? "" : password; + dataRole = dataRole == null || dataRole.isBlank() ? "cb_dds_token_role" : dataRole.trim(); + } + + public Token(String username, String password) { + this(username, password, "cb_dds_token_role"); + } + public boolean configured() { - return username != null && !username.isBlank() - && password != null && !password.isBlank(); + return !username.isBlank() && !password.isBlank(); } } diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsGrantInventoryEntry.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsGrantInventoryEntry.java new file mode 100644 index 0000000..cd3b000 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsGrantInventoryEntry.java @@ -0,0 +1,9 @@ +package com.cloudhandson.ddsbackoffice.domain; + +/** Safe subset of a DDS Data Grant inventory row used by the protection dashboard. */ +public record DdsGrantInventoryEntry( + String grantName, + String objectName, + String grantee +) { +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsGrantInventorySnapshot.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsGrantInventorySnapshot.java new file mode 100644 index 0000000..81541ae --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsGrantInventorySnapshot.java @@ -0,0 +1,27 @@ +package com.cloudhandson.ddsbackoffice.domain; + +import java.time.Instant; +import java.util.List; + +/** Inventory observation. An unavailable observation must never be presented as a healthy grant. */ +public record DdsGrantInventorySnapshot( + boolean available, + List grants, + Instant observedAt, + String safeIssue +) { + + public DdsGrantInventorySnapshot { + grants = grants == null ? List.of() : List.copyOf(grants); + observedAt = observedAt == null ? Instant.now() : observedAt; + safeIssue = safeIssue == null ? "" : safeIssue; + } + + public boolean hasGrant(String grantName) { + return grants.stream().anyMatch(grant -> grant.grantName().equalsIgnoreCase(grantName)); + } + + public boolean hasGrantee(String dataRole) { + return grants.stream().anyMatch(grant -> grant.grantee().equalsIgnoreCase(dataRole)); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsProtectionOverview.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsProtectionOverview.java new file mode 100644 index 0000000..4331220 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsProtectionOverview.java @@ -0,0 +1,19 @@ +package com.cloudhandson.ddsbackoffice.domain; + +import java.util.List; + +/** Dashboard model separated into the product service path and advanced direct comparison. */ +public record DdsProtectionOverview( + DdsProtectionPathStatus servicePath, + List directPaths +) { + + public DdsProtectionOverview { + directPaths = directPaths == null ? List.of() : List.copyOf(directPaths); + } + + public int actionRequiredCount() { + int serviceActions = servicePath != null && servicePath.needsAction() ? 1 : 0; + return serviceActions + (int) directPaths.stream().filter(DdsProtectionPathStatus::needsAction).count(); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsProtectionPathStatus.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsProtectionPathStatus.java new file mode 100644 index 0000000..8f6b05c --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsProtectionPathStatus.java @@ -0,0 +1,37 @@ +package com.cloudhandson.ddsbackoffice.domain; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +/** One protected-object status for one enforcement path, safe for the operator UI. */ +public record DdsProtectionPathStatus( + String pathKey, + String pathLabel, + String subjectLabel, + String objectName, + boolean advanced, + String primaryLabel, + String primaryTone, + String primaryDescription, + String observationLabel, + String observationDetail, + String synchronizationLabel, + String synchronizationDetail, + String verificationLabel, + String verificationDetail, + Instant observedAt, + String actionLabel, + String actionHref +) { + + public boolean needsAction() { + return "danger".equals(primaryTone); + } + + public String observedAtLabel() { + return DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + .withZone(ZoneId.of("Asia/Seoul")) + .format(observedAt); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsGrantInventory.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsGrantInventory.java new file mode 100644 index 0000000..ced6dca --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsGrantInventory.java @@ -0,0 +1,9 @@ +package com.cloudhandson.ddsbackoffice.service; + +import com.cloudhandson.ddsbackoffice.domain.DdsGrantInventorySnapshot; + +/** Reads only the DDS Grant metadata needed to render protection status. */ +public interface DdsGrantInventory { + + DdsGrantInventorySnapshot observe(String protectedObject); +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsGrantPublisher.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsGrantPublisher.java index 7cd2d09..40a59a0 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsGrantPublisher.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsGrantPublisher.java @@ -30,7 +30,7 @@ import org.springframework.stereotype.Service; * the bearer token. */ @Service -public class DdsGrantPublisher { +public class DdsGrantPublisher implements DdsProvisionPlanProvider { private static final Pattern IDENTIFIER = Pattern.compile("[A-Z][A-Z0-9_$#]*"); private static final Pattern OBJECT = Pattern.compile( diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionStatusService.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionStatusService.java new file mode 100644 index 0000000..219588e --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionStatusService.java @@ -0,0 +1,214 @@ +package com.cloudhandson.ddsbackoffice.service; + +import com.cloudhandson.ddsbackoffice.config.DdsProperties; +import com.cloudhandson.ddsbackoffice.domain.DdsGrantInventorySnapshot; +import com.cloudhandson.ddsbackoffice.domain.DdsGrantPlan; +import com.cloudhandson.ddsbackoffice.domain.DdsProtectionOverview; +import com.cloudhandson.ddsbackoffice.domain.DdsProtectionPathStatus; +import java.util.List; +import java.util.Locale; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Service; + +/** + * Produces safe, evidence-based protection status for the DDS console. + * + *

The status deliberately distinguishes a catalog observation from a full predicate comparison: + * the deployed catalog query confirms grant/object/grantee, but not the complete predicate or + * column expression. The UI must therefore never call this observation "fully protected".

+ */ +@Service +public class DdsProtectionStatusService { + + private final DdsProperties properties; + private final DdsGrantInventory inventory; + private final DdsProvisionPlanProvider planProvider; + + public DdsProtectionStatusService( + DdsProperties properties, + DdsGrantInventory inventory, + DdsProvisionPlanProvider planProvider + ) { + this.properties = properties; + this.inventory = inventory; + this.planProvider = planProvider; + } + + public DdsProtectionOverview overview() { + DdsGrantInventorySnapshot snapshot = inventory.observe(properties.vectorObject()); + return new DdsProtectionOverview(servicePath(snapshot), List.of()); + } + + /** + * Direct comparison can calculate a user-by-user publishing plan. It is intentionally loaded + * only after an operator opens advanced verification, not while loading the normal dashboard. + */ + public List directComparison() { + DdsGrantInventorySnapshot snapshot = inventory.observe(properties.vectorObject()); + return directPaths(snapshot); + } + + private DdsProtectionPathStatus servicePath(DdsGrantInventorySnapshot snapshot) { + var token = properties.token(); + if (!snapshot.available()) { + return unavailable("토큰 기반 서비스 경로", "기술 사용자 · " + token.username(), false, snapshot); + } + if (!token.configured()) { + return status( + "service", "토큰 기반 서비스 경로", "기술 사용자 설정 필요", false, + "조치 필요", "danger", "기술 사용자 자격증명이 없어 이 경로를 검증할 수 없습니다.", + "일부 확인", partialObservationDetail(snapshot), + "판정 불가", "기술 사용자 설정 뒤 객체별 DATA GRANT를 확인합니다.", + "검증 불가", "Bearer 토큰 검색을 실행할 수 없습니다.", snapshot, + "지식 검색 설정 확인", "/vector-knowledge" + ); + } + + if (!snapshot.hasGrantee(token.dataRole())) { + return status( + "service", "토큰 기반 서비스 경로", "기술 사용자 · " + token.username(), false, + "조치 필요", "danger", "이 보호 객체에 토큰용 DATA ROLE Grant가 관측되지 않았습니다.", + "일부 확인", partialObservationDetail(snapshot), + "반영 필요", "기대 DATA ROLE " + token.dataRole() + "에 연결된 Grant가 없습니다.", + "검증 대기", "Grant가 반영된 뒤 Bearer 토큰으로 검색을 확인하세요.", snapshot, + "지식 검색으로 검증", "/vector-knowledge" + ); + } + + return status( + "service", "토큰 기반 서비스 경로", "기술 사용자 · " + token.username(), false, + "재검증 필요", "warning", "Grant 존재는 확인했지만 최근 권한 결과 검증 기록이 없습니다.", + "일부 확인", partialObservationDetail(snapshot), + "선언 관측", "Grant 이름·보호 객체·DATA ROLE이 catalog에서 확인되었습니다.", + "검증 기록 없음", "허용·거부 TAG fixture를 사용해 Bearer 토큰 검색을 실행하세요.", snapshot, + "토큰 권한으로 검색", "/vector-knowledge" + ); + } + + private List directPaths(DdsGrantInventorySnapshot snapshot) { + if (!snapshot.available()) { + return List.of(unavailable("DDS END USER 직접 비교", "직접 비교 상태를 읽지 못했습니다.", true, snapshot)); + } + + try { + List plans = planProvider.preview().grants().stream() + .filter(plan -> properties.vectorObject().equalsIgnoreCase(plan.ddsObject())) + .toList(); + if (plans.isEmpty()) { + return List.of(status( + "direct", "DDS END USER 직접 비교", "비교 대상 없음", true, + "확인 필요", "warning", "현재 권한 규칙에서 직접 비교용 벡터 Grant 계획을 만들지 못했습니다.", + "일부 확인", partialObservationDetail(snapshot), + "판정 불가", "권한 매핑과 보호 객체 등록을 확인하세요.", + "검증 대기", "비교 대상을 만든 뒤 직접 조회를 실행할 수 있습니다.", snapshot, + "권한 반영 보기", "/dds-provision" + )); + } + return plans.stream().map(plan -> directPath(plan, snapshot)).toList(); + } catch (DataAccessException | IllegalArgumentException exception) { + return List.of(status( + "direct", "DDS END USER 직접 비교", "직접 비교 계획 확인 필요", true, + "확인 불가", "danger", "직접 비교용 권한 계획을 계산하지 못했습니다.", + "일부 확인", partialObservationDetail(snapshot), + "판정 불가", "권한 원천 또는 보호 객체 매핑을 확인하세요.", + "검증 불가", "계획을 읽은 뒤 직접 조회를 실행할 수 있습니다.", snapshot, + "권한 반영 보기", "/dds-provision" + )); + } + } + + private DdsProtectionPathStatus directPath( + DdsGrantPlan plan, + DdsGrantInventorySnapshot snapshot + ) { + String subject = plan.userLabel() + " · " + plan.dataRole(); + boolean actualGrant = snapshot.hasGrant(plan.grantName()); + if (plan.publishable() && !actualGrant) { + return status( + "direct-" + plan.userKey(), "DDS END USER 직접 비교", subject, true, + "조치 필요", "danger", "현재 권한으로 생성할 Grant가 아직 관측되지 않았습니다.", + "일부 확인", partialObservationDetail(snapshot), + "반영 필요", "현재 권한 계획과 catalog Grant가 일치하지 않습니다.", + "검증 대기", "Grant 반영 뒤 허용·거부 fixture로 확인하세요.", snapshot, + "권한 반영 미리보기", "/dds-provision" + ); + } + if (!plan.publishable() && actualGrant) { + return status( + "direct-" + plan.userKey(), "DDS END USER 직접 비교", subject, true, + "조치 필요", "danger", "현재 권한 계획은 기본 거부지만 기존 Grant가 남아 있습니다.", + "일부 확인", partialObservationDetail(snapshot), + "차이 발견", "권한을 회수해야 하는 Grant가 catalog에서 관측됩니다.", + "검증 대기", "반영 후 default deny 결과를 확인하세요.", snapshot, + "권한 반영 미리보기", "/dds-provision" + ); + } + if (!plan.publishable()) { + return status( + "direct-" + plan.userKey(), "DDS END USER 직접 비교", subject, true, + "재검증 필요", "warning", "현재 권한 계획은 이 주체의 기본 거부를 의도합니다.", + "일부 확인", partialObservationDetail(snapshot), + "기본 거부 일치", "생성 대상 Grant가 없고 기존 Grant도 관측되지 않았습니다.", + "검증 기록 없음", "직접 비교에서 객체 미노출 또는 안전한 차단 결과를 확인하세요.", snapshot, + "직접 비교 실행", "/vector-knowledge" + ); + } + return status( + "direct-" + plan.userKey(), "DDS END USER 직접 비교", subject, true, + "재검증 필요", "warning", "현재 권한 계획과 같은 이름의 Grant가 관측되었습니다.", + "일부 확인", partialObservationDetail(snapshot), + "선언 관측", "Grant 이름·보호 객체·DATA ROLE이 catalog에서 확인되었습니다.", + "검증 기록 없음", "직접 비교 fixture로 허용·거부 결과를 확인하세요.", snapshot, + "직접 비교 실행", "/vector-knowledge" + ); + } + + private DdsProtectionPathStatus unavailable( + String pathLabel, + String subject, + boolean advanced, + DdsGrantInventorySnapshot snapshot + ) { + return status( + "unavailable-" + pathLabel.toLowerCase(Locale.ROOT), pathLabel, subject, advanced, + "확인 불가", "danger", "보호 상태를 판정할 inventory를 읽지 못했습니다.", + "확인 불가", snapshot.safeIssue(), + "판정 불가", "Grant와 권한 계획의 일치를 비교할 수 없습니다.", + "검증 불가", "inventory가 복구된 뒤 보호 결과를 확인하세요.", snapshot, + "보호 연결 다시 확인", "/dds-protection" + ); + } + + private DdsProtectionPathStatus status( + String pathKey, + String pathLabel, + String subject, + boolean advanced, + String primaryLabel, + String primaryTone, + String primaryDescription, + String observationLabel, + String observationDetail, + String synchronizationLabel, + String synchronizationDetail, + String verificationLabel, + String verificationDetail, + DdsGrantInventorySnapshot snapshot, + String actionLabel, + String actionHref + ) { + return new DdsProtectionPathStatus( + pathKey, pathLabel, subject, properties.vectorObject(), advanced, + primaryLabel, primaryTone, primaryDescription, + observationLabel, observationDetail, + synchronizationLabel, synchronizationDetail, + verificationLabel, verificationDetail, + snapshot.observedAt(), actionLabel, actionHref + ); + } + + private String partialObservationDetail(DdsGrantInventorySnapshot snapshot) { + return snapshot.grants().size() + "개 Grant의 이름·보호 객체·DATA ROLE만 확인했습니다. " + + "predicate와 컬럼 범위는 이 화면에서 비교하지 않습니다."; + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProvisionPlanProvider.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProvisionPlanProvider.java new file mode 100644 index 0000000..331ad49 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProvisionPlanProvider.java @@ -0,0 +1,10 @@ +package com.cloudhandson.ddsbackoffice.service; + +import com.cloudhandson.ddsbackoffice.domain.DdsProvisioningPlan; + +/** Read-only boundary used by the protection dashboard and its tests. */ +@FunctionalInterface +public interface DdsProvisionPlanProvider { + + DdsProvisioningPlan preview(); +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/JdbcDdsGrantInventory.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/JdbcDdsGrantInventory.java new file mode 100644 index 0000000..bd64cc7 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/JdbcDdsGrantInventory.java @@ -0,0 +1,44 @@ +package com.cloudhandson.ddsbackoffice.service; + +import com.cloudhandson.ddsbackoffice.domain.DdsGrantInventoryEntry; +import com.cloudhandson.ddsbackoffice.domain.DdsGrantInventorySnapshot; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * Uses the same DDS inventory source already used by the setup SQL. The catalog does not expose + * every predicate detail here, so a successful read is deliberately reported as partial evidence. + */ +@Service +public class JdbcDdsGrantInventory implements DdsGrantInventory { + + private final JdbcTemplate jdbcTemplate; + + public JdbcDdsGrantInventory(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public DdsGrantInventorySnapshot observe(String protectedObject) { + String objectName = protectedObject.substring(protectedObject.lastIndexOf('.') + 1) + .toUpperCase(Locale.ROOT); + try { + List grants = jdbcTemplate.query(""" + SELECT grant_name, object_name, grantee + FROM dba_data_grants + WHERE object_name = ? + ORDER BY grant_name + """, (row, ignored) -> new DdsGrantInventoryEntry( + row.getString("grant_name"), row.getString("object_name"), row.getString("grantee") + ), objectName); + return new DdsGrantInventorySnapshot(true, grants, Instant.now(), ""); + } catch (DataAccessException exception) { + return new DdsGrantInventorySnapshot(false, List.of(), Instant.now(), + "DDS Grant 목록을 읽을 수 없습니다. DB 연결 또는 catalog 조회 권한을 확인하세요."); + } + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java index e4f7d0b..9e95ba3 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java @@ -1,55 +1,48 @@ package com.cloudhandson.ddsbackoffice.web; import com.cloudhandson.ddsbackoffice.config.DdsProperties; -import com.cloudhandson.ddsbackoffice.service.DdsQueryService; -import com.cloudhandson.vpdbackoffice.domain.permission.PermissionView; -import com.cloudhandson.vpdbackoffice.service.PermissionService; -import java.util.List; -import org.springframework.dao.DataAccessException; +import com.cloudhandson.ddsbackoffice.service.DdsProtectionStatusService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.servlet.view.RedirectView; -/** DDS equivalent of the VPD protection-connection step. */ +/** DDS protection status, deliberately separate from the legacy VPD route. */ @Controller public class DdsProtectionController { - private static final String MANAGEMENT_OBJECT = "CB_VECTOR_SEARCH_DOCUMENTS"; - - private final PermissionService permissionService; private final DdsProperties properties; - private final DdsQueryService queryService; + private final DdsProtectionStatusService protectionStatusService; public DdsProtectionController( - PermissionService permissionService, DdsProperties properties, - DdsQueryService queryService + DdsProtectionStatusService protectionStatusService ) { - this.permissionService = permissionService; this.properties = properties; - this.queryService = queryService; + this.protectionStatusService = protectionStatusService; } - @GetMapping("/vpd-policies") + @GetMapping("/dds-protection") public String page(Model model) { model.addAttribute("ddsVectorObject", properties.vectorObject()); - model.addAttribute("managementObject", MANAGEMENT_OBJECT); - model.addAttribute("ddsUsers", queryService.users()); - try { - List permissions = permissionService.findPermissionViews().stream() - .filter(permission -> MANAGEMENT_OBJECT.equalsIgnoreCase(permission.objectName())) - .toList(); - model.addAttribute("permissions", permissions); - } catch (DataAccessException exception) { - model.addAttribute("permissions", List.of()); - model.addAttribute("runtimeError", exception.getMessage()); - } + model.addAttribute("protection", protectionStatusService.overview()); return "vpd-policies"; } + @GetMapping("/dds-protection/direct") + public String directComparison(Model model) { + model.addAttribute("directPaths", protectionStatusService.directComparison()); + return "fragments/dds-protection-direct :: directComparison"; + } + + /** Existing bookmarks resolve to the DDS canonical route without retaining VPD in the UI. */ + @GetMapping("/vpd-policies") + public RedirectView legacyPolicies() { + return new RedirectView("/dds-protection"); + } + @GetMapping("/vpd-filter-policies") public RedirectView filterPolicies() { - return new RedirectView("/vpd-policies"); + return new RedirectView("/dds-protection"); } } diff --git a/dds-backoffice/src/main/resources/application.yml b/dds-backoffice/src/main/resources/application.yml index ad37348..cfd5692 100644 --- a/dds-backoffice/src/main/resources/application.yml +++ b/dds-backoffice/src/main/resources/application.yml @@ -67,6 +67,7 @@ dds: token: username: ${DDS_BACKOFFICE_TOKEN_USERNAME:dds_demo_token} password: ${DDS_BACKOFFICE_TOKEN_PASSWORD:${DDSUSER_TOKEN_PASSWORD:}} + data-role: ${DDS_BACKOFFICE_TOKEN_DATA_ROLE:cb_dds_token_role} users: my: label: MY 전용 사용자 diff --git a/dds-backoffice/src/main/resources/static/css/dds.css b/dds-backoffice/src/main/resources/static/css/dds.css index e5ce194..0f9319b 100644 --- a/dds-backoffice/src/main/resources/static/css/dds.css +++ b/dds-backoffice/src/main/resources/static/css/dds.css @@ -67,12 +67,41 @@ body { margin: 0; background: var(--dds-bg); color: var(--dds-ink); font-family: .journey-card-compact h2 { margin: 0 0 .25rem; } .journey-card-compact strong { font-size: .8rem; } .compact-context { padding: .75rem; } +.protection-summary { border-top: 3px solid #3157d5; } +.protection-status-card { background: #fbfcff; border: 1px solid #dbe3f4; border-radius: 14px; margin-top: 1rem; padding: 1.25rem; } +.protection-status-card.status-danger { background: #fffafa; border-color: #f2c8c8; } +.protection-status-card.status-warning { background: #fffdf8; border-color: #ead9ad; } +.protection-status-topline { align-items: flex-start; display: flex; gap: 1rem; justify-content: space-between; } +.protection-path-label { color: var(--dds-accent); display: block; font-size: .78rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; } +.protection-status-topline h3 { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1rem; margin: .35rem 0; overflow-wrap: anywhere; } +.protection-status-topline p, .protection-primary-description { color: var(--dds-muted); margin: 0; } +.protection-primary-description { line-height: 1.55; margin-top: .8rem; } +.protection-primary-badge, .protection-table-badge { border-radius: 999px; display: inline-block; font-size: .8rem; font-weight: 800; padding: .35rem .65rem; white-space: nowrap; } +.tone-danger { background: #fbe8e8; color: #9e3030; } +.tone-warning { background: #fff1cf; color: #8a5d12; } +.tone-success { background: #e3f6ed; color: #11765a; } +.tone-neutral { background: #edf1f7; color: #43516a; } +.protection-evidence-grid { display: grid; gap: .8rem; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 1.2rem 0; } +.protection-evidence-grid > div { background: #fff; border: 1px solid var(--dds-line); border-radius: 10px; min-width: 0; padding: .85rem; } +.protection-evidence-grid dt { color: var(--dds-muted); font-size: .72rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; } +.protection-evidence-grid dd { margin: .35rem 0 0; } +.protection-evidence-grid dd strong, .protection-evidence-grid dd span { display: block; } +.protection-evidence-grid dd span { color: var(--dds-muted); font-size: .82rem; line-height: 1.45; margin-top: .35rem; } +.protection-status-footer { align-items: center; border-top: 1px solid var(--dds-line); color: var(--dds-muted); display: flex; font-size: .82rem; gap: 1rem; justify-content: space-between; padding-top: .9rem; } +.protection-path-comparison { display: grid; gap: .8rem; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: .8rem; } +.protection-path-comparison > div { background: #f7f9fc; border: 1px solid var(--dds-line); border-radius: 10px; padding: .9rem; } +.protection-path-comparison strong, .protection-path-comparison span { display: block; } +.protection-path-comparison span { color: var(--dds-muted); font-size: .86rem; line-height: 1.5; margin-top: .35rem; } +.advanced-protection > summary { cursor: pointer; font-weight: 800; } +.protection-direct-table small { color: var(--dds-muted); display: block; font-size: .78rem; line-height: 1.4; margin-top: .3rem; max-width: 20rem; } +.protection-direct-table td { vertical-align: top; } @media (max-width: 800px) { .flow-grid, .matrix-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .query-grid, .context-grid { grid-template-columns: 1fr; } + .query-grid, .context-grid, .protection-evidence-grid, .protection-path-comparison { grid-template-columns: 1fr; } } @media (max-width: 540px) { .flow-grid, .matrix-grid { grid-template-columns: 1fr; } .panel { padding: 19px; } .hero { padding-top: 30px; } + .protection-status-topline, .protection-status-footer { align-items: flex-start; flex-direction: column; } } diff --git a/dds-backoffice/src/main/resources/templates/dds-home.html b/dds-backoffice/src/main/resources/templates/dds-home.html index 462150e..cb013b9 100644 --- a/dds-backoffice/src/main/resources/templates/dds-home.html +++ b/dds-backoffice/src/main/resources/templates/dds-home.html @@ -19,7 +19,7 @@
1

권한 설계

열기 →
- 2

DDS 보호 연결

열기 →
+ 2

DDS 보호 상태

열기 →
3

DDS 권한 연결

열기 →
4

권한 기반 검색

열기 →
5

보호 객체 확인

열기 →
diff --git a/dds-backoffice/src/main/resources/templates/dds-provision.html b/dds-backoffice/src/main/resources/templates/dds-provision.html index d0ee22a..4f3b59e 100644 --- a/dds-backoffice/src/main/resources/templates/dds-provision.html +++ b/dds-backoffice/src/main/resources/templates/dds-provision.html @@ -15,7 +15,7 @@ -
+
diff --git a/dds-backoffice/src/main/resources/templates/fragments/dds-protection-direct.html b/dds-backoffice/src/main/resources/templates/fragments/dds-protection-direct.html new file mode 100644 index 0000000..6242cb9 --- /dev/null +++ b/dds-backoffice/src/main/resources/templates/fragments/dds-protection-direct.html @@ -0,0 +1,26 @@ + + + +
+ + + + + + + + + + + +
검증 프로필보호 상태권한 반영검색 검증조치
+ 검증 프로필 + 보호 객체 + + 재검증 필요 + 설명 + 선언 관측설명검증 기록 없음설명실행
+
+ + diff --git a/dds-backoffice/src/main/resources/templates/fragments/layout.html b/dds-backoffice/src/main/resources/templates/fragments/layout.html index 829fce8..fa158c9 100644 --- a/dds-backoffice/src/main/resources/templates/fragments/layout.html +++ b/dds-backoffice/src/main/resources/templates/fragments/layout.html @@ -31,7 +31,7 @@
- DB 보호 연결 DDS 권한 반영 검증 세션 발급 @@ -80,7 +80,7 @@

누가 어떤 데이터의 어느 행과 컬럼을 볼지 정합니다.

-
+
2 · ENFORCE DB 보호 연결

DB 보호 정책이 권한을 적용합니다.

diff --git a/dds-backoffice/src/main/resources/templates/vpd-policies.html b/dds-backoffice/src/main/resources/templates/vpd-policies.html index 4bb014a..6c18d12 100644 --- a/dds-backoffice/src/main/resources/templates/vpd-policies.html +++ b/dds-backoffice/src/main/resources/templates/vpd-policies.html @@ -1,109 +1,100 @@ - +
-
- DDS · ENFORCEMENT -

DDS 보호 연결

-

권한 관리에서 만든 규칙을 DDS DATA ROLE/DATA GRANT로 연결하고 실제 검색 결과로 확인합니다.

-
- VPD의 Policy/Filter와 무엇이 다른가요? -

VPD는 Policy가 Filter function을 호출해 요청마다 predicate를 계산합니다. DDS는 보호 VIEW/TABLE별 DATA GRANT에 행·컬럼 조건을 선언하고, 필요하면 predicate가 공통 권한을 읽는 Definer-rights 함수를 호출합니다.

-

따라서 이 화면에서는 VPD Filter를 수정하지 않습니다. 일상적인 변경은 권한 관리에서 하고, DDS grant 반영은 승인된 SQL/배포 절차로 수행합니다.

-
-
+
+ DDS · PROTECTION STATUS +

DDS 보호 상태

+

지식 검색에 쓰는 보호 객체가 현재 권한 기준대로 연결되어 있는지 확인합니다.

+
-
-
- DDS 권한 규칙을 불러오지 못했습니다. -
+
+ +
+
+
+ 기본 서비스 경로 +

토큰 기반 지식 검색

+

일상 운영에서는 이 경로만 확인하면 됩니다. 직접 DDS END USER 비교는 아래 고급 검증에 분리했습니다.

+
+ 조치 필요 + 검증 확인 필요 +
+ +
+
+
+ 토큰 기반 서비스 경로 +

ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS

+

기술 사용자

+
+ 재검증 필요 +
+

설명

+ +
+
+
DB 관측
+
일부 확인설명
+
+
+
권한 반영
+
선언 관측설명
+
+
+
검색 검증
+
검증 기록 없음설명
+
+
+ + +
+
- 공통 관리 기준 -

VPD와 동일한 권한 관리 대상을 사용합니다.

-

사용자·그룹·역할·행 규칙·TAG 규칙은 한 권한 화면에서 관리합니다.

+ 권한 변경 +

권한을 바꾸려면

+

권한 규칙을 먼저 수정하고, 직접 비교 경로의 변경은 DDS 권한 반영에서 미리 확인합니다.

-
- 사용자·그룹·역할 - 객체별 행·TAG·컬럼 권한 - DATA ROLE / DATA GRANT - 토큰 Context 또는 END USER 결과 -
-

관리 대상 이름: CB_VECTOR_SEARCH_DOCUMENTS

-
- -
-
-
-

DDS 보호 객체

-

VPD용 객체와 분리된 DDS 전용 VIEW입니다. 같은 청크·태그 저장소를 읽지만 VPD 정책은 붙이지 않습니다.

+
+ 두 집행 경로의 차이 보기 +
+
토큰 기반 서비스기술 사용자 연결에서 Bearer 토큰으로 업무 사용자를 식별합니다. 객체별 DATA GRANT가 현재 공통 권한을 평가합니다.
+
DDS END USER 직접 비교DATA ROLE과 게시된 DATA GRANT를 직접 확인하는 고급 진단 경로입니다. 권한 변경 뒤에는 별도 반영이 필요합니다.
- 지식 검색 열기 -
-
- - - - - - - - - - -
보호 VIEW적용 방식기본 거부검증
ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS토큰 Context/END USER → DATA ROLE → 객체별 DATA GRANTGrant predicate가 없거나 조건 불일치 시 차단검색 결과 확인
-
-
- 추가·수정 가이드 -

새 보호 대상을 추가할 때는 (1) 권한 관리에 논리 객체와 TAG 규칙을 등록하고 (2) DDS 전용 VIEW/TABLE을 만들고 (3) 해당 객체에 CREATE OR REPLACE DATA GRANT ... WHERE ...를 연결하고 (4) 토큰 Context와 직접 END USER의 허용·거부·권한 없음을 각각 검증합니다.

-

벡터 기준 SQL은 sql/adb/32_dds_vector_tag_setup.sql, 토큰 기반 공통 predicate 기준은 sql/adb/34_dds_token_data_grant_common_auth.sql입니다.

-
-
-

역할별 관리 규칙

-

아래 내용은 공통 권한 화면의 현재 규칙입니다. DDS에서는 이 규칙을 DATA GRANT 조건으로 반영합니다.

+
+ 고급 검증: DDS END USER 직접 비교 보기 +

아래 항목은 제품 계정 목록이 아니라 DDS 선언형 권한을 비교하기 위한 검증 프로필입니다. 평소 운영에서는 열 필요가 없습니다.

+ +
+

직접 비교는 필요할 때만 불러옵니다. 기본 서비스 검색에는 영향을 주지 않습니다.

- 0개 규칙 -
-
- - - - - - - - - - - -
역할효과행·태그 규칙원문 허용 컬럼
ROLEALLOWTAG SPRING_BOOT없음
아직 이 객체에 연결된 권한 규칙이 없습니다. 권한 관리에서 역할과 TAG 규칙을 먼저 등록하세요.
-
-
- -
-
-

DDS END USER 검증 주체

비밀번호는 서버 환경 변수로만 관리하고 화면에는 표시하지 않습니다.

-
-
-
- 사용자 - dds_demo - 상태 -
-
+
diff --git a/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/config/DdsPropertiesTest.java b/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/config/DdsPropertiesTest.java index 69b2c9b..40fbaf7 100644 --- a/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/config/DdsPropertiesTest.java +++ b/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/config/DdsPropertiesTest.java @@ -61,7 +61,8 @@ class DdsPropertiesTest { void bindsTokenTechnicalUserSeparatelyFromApplicationUsers() { var source = new MapConfigurationPropertySource(Map.of( "dds.token.username", "dds_demo_token", - "dds.token.password", "secret" + "dds.token.password", "secret", + "dds.token.data-role", "cb_dds_token_role" )); var properties = new Binder(source) @@ -69,6 +70,7 @@ class DdsPropertiesTest { .orElseThrow(() -> new AssertionError("dds token properties did not bind")); assertEquals("dds_demo_token", properties.token().username()); + assertEquals("cb_dds_token_role", properties.token().dataRole()); assertTrue(properties.token().configured()); } } diff --git a/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionStatusServiceTest.java b/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionStatusServiceTest.java new file mode 100644 index 0000000..9007104 --- /dev/null +++ b/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionStatusServiceTest.java @@ -0,0 +1,92 @@ +package com.cloudhandson.ddsbackoffice.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.cloudhandson.ddsbackoffice.config.DdsProperties; +import com.cloudhandson.ddsbackoffice.domain.DdsGrantInventoryEntry; +import com.cloudhandson.ddsbackoffice.domain.DdsGrantInventorySnapshot; +import com.cloudhandson.ddsbackoffice.domain.DdsGrantPlan; +import com.cloudhandson.ddsbackoffice.domain.DdsProvisioningPlan; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class DdsProtectionStatusServiceTest { + + @Test + void neverMarksAnUnavailableInventoryAsProtected() { + DdsGrantInventory inventory = object -> new DdsGrantInventorySnapshot( + false, List.of(), Instant.parse("2026-06-30T00:00:00Z"), "catalog 접근 실패" + ); + var service = new DdsProtectionStatusService(properties(), inventory, emptyPlanProvider()); + + var overview = service.overview(); + + assertEquals("확인 불가", overview.servicePath().primaryLabel()); + assertEquals("확인 불가", overview.servicePath().observationLabel()); + assertTrue(overview.servicePath().needsAction()); + } + + @Test + void separatesMissingTokenGrantFromDirectComparisonEvidence() { + DdsGrantInventory inventory = object -> new DdsGrantInventorySnapshot( + true, + List.of(new DdsGrantInventoryEntry( + "DDS_DEMO_BOTH_VECTOR_GRANT", "CB_DDS_VECTOR_SEARCH_DOCUMENTS", "DDS_DEMO_BOTH_ROLE")), + Instant.parse("2026-06-30T00:00:00Z"), "" + ); + DdsProvisionPlanProvider provider = () -> new DdsProvisioningPlan(List.of(new DdsGrantPlan( + "both", "통합 사용자", 103L, "dds_demo_both_role", "CB_VECTOR_SEARCH_DOCUMENTS", + "ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS", "DDS_DEMO_BOTH_VECTOR_GRANT", "1 = 1", "", + "CREATE DATA GRANT", true, "게시 가능" + )), List.of(), 1); + var service = new DdsProtectionStatusService(properties(), inventory, provider); + + var overview = service.overview(); + + assertEquals("조치 필요", overview.servicePath().primaryLabel()); + assertEquals("반영 필요", overview.servicePath().synchronizationLabel()); + var direct = service.directComparison(); + assertEquals(1, direct.size()); + assertEquals("재검증 필요", direct.getFirst().primaryLabel()); + assertEquals("선언 관측", direct.getFirst().synchronizationLabel()); + } + + @Test + void reportsDirectGrantDriftWhenCurrentPlanHasNoGrantButInventoryDoes() { + DdsGrantInventory inventory = object -> new DdsGrantInventorySnapshot( + true, + List.of(new DdsGrantInventoryEntry( + "DDS_DEMO_BOTH_VECTOR_GRANT", "CB_DDS_VECTOR_SEARCH_DOCUMENTS", "DDS_DEMO_BOTH_ROLE")), + Instant.parse("2026-06-30T00:00:00Z"), "" + ); + DdsProvisionPlanProvider provider = () -> new DdsProvisioningPlan(List.of(new DdsGrantPlan( + "both", "통합 사용자", 103L, "dds_demo_both_role", "CB_VECTOR_SEARCH_DOCUMENTS", + "ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS", "DDS_DEMO_BOTH_VECTOR_GRANT", "", "", "", + false, "ALLOW 규칙 없음" + )), List.of(), 1); + var service = new DdsProtectionStatusService(properties(), inventory, provider); + + var direct = service.directComparison().getFirst(); + + assertEquals("조치 필요", direct.primaryLabel()); + assertEquals("차이 발견", direct.synchronizationLabel()); + } + + private static DdsProperties properties() { + return new DdsProperties( + "jdbc:test", Duration.ofSeconds(5), "ADMIN.V_DDS_CUSTOMERS_PG", "ADMIN.V_DDS_CUSTOMERS_MY", + "ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS", + Map.of("both", new DdsProperties.User( + "통합 사용자", "직접 비교", "dds_demo_both", "secret", 103L, "dds_demo_both_role")), + Map.of("CB_VECTOR_SEARCH_DOCUMENTS", "ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS"), + new DdsProperties.Token("dds_demo_token", "secret", "cb_dds_token_role") + ); + } + + private static DdsProvisionPlanProvider emptyPlanProvider() { + return () -> new DdsProvisioningPlan(List.of(), List.of(), 0); + } +} diff --git a/scripts/deploy-dds-backoffice-local.sh b/scripts/deploy-dds-backoffice-local.sh index 74645b1..10ae61a 100755 --- a/scripts/deploy-dds-backoffice-local.sh +++ b/scripts/deploy-dds-backoffice-local.sh @@ -115,7 +115,7 @@ for _ in {1..30}; do done "$APP_DIR/status.sh" curl -sS -o /tmp/dds-backoffice-login.html -w 'login=%{http_code}\n' "http://127.0.0.1:${PORT}/login" -for path in / /users /groups /roles /permissions /effective-matrix /vpd-policies /dds-provision /vector-knowledge /dds; do +for path in / /users /groups /roles /permissions /effective-matrix /dds-protection /vpd-policies /dds-provision /vector-knowledge /dds; do curl -sS --connect-timeout 3 --max-time 20 -u "${BACKOFFICE_ADMIN_USER:-}:${BACKOFFICE_ADMIN_PASSWORD:-}" \ -o "/tmp/dds-backoffice-check-${path#/}.html" \ -w "${path}=%{http_code}\n" "http://127.0.0.1:${PORT}${path}" || true