feat: add DDS protection status dashboard
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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와 컬럼 범위는 이 화면에서 비교하지 않습니다.";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 조회 권한을 확인하세요.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user