feat: add DDS protection status dashboard

This commit is contained in:
devmrko
2026-06-30 20:31:31 +09:00
parent 2f67587b40
commit 1766696ab4
21 changed files with 637 additions and 123 deletions

View File

@@ -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();
}
}

View File

@@ -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
) {
}

View File

@@ -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<DdsGrantInventoryEntry> 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));
}
}

View File

@@ -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<DdsProtectionPathStatus> 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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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(

View File

@@ -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.
*
* <p>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".</p>
*/
@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<DdsProtectionPathStatus> 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<DdsProtectionPathStatus> directPaths(DdsGrantInventorySnapshot snapshot) {
if (!snapshot.available()) {
return List.of(unavailable("DDS END USER 직접 비교", "직접 비교 상태를 읽지 못했습니다.", true, snapshot));
}
try {
List<DdsGrantPlan> 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와 컬럼 범위는 이 화면에서 비교하지 않습니다.";
}
}

View File

@@ -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();
}

View File

@@ -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<DdsGrantInventoryEntry> 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 조회 권한을 확인하세요.");
}
}
}

View File

@@ -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<PermissionView> 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");
}
}