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

View File

@@ -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 전용 사용자

View File

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

View File

@@ -19,7 +19,7 @@
<section class="journey-grid" aria-label="DDS 권한 적용 네 단계">
<a class="journey-card journey-card-compact" href="/permissions"><span class="journey-number">1</span><div><h2>권한 설계</h2><strong>열기 →</strong></div></a>
<a class="journey-card journey-card-compact" href="/vpd-policies"><span class="journey-number">2</span><div><h2>DDS 보호 연결</h2><strong>열기 →</strong></div></a>
<a class="journey-card journey-card-compact" href="/dds-protection"><span class="journey-number">2</span><div><h2>DDS 보호 상태</h2><strong>열기 →</strong></div></a>
<a class="journey-card journey-card-compact" href="/dds-provision"><span class="journey-number">3</span><div><h2>DDS 권한 연결</h2><strong>열기 →</strong></div></a>
<a class="journey-card journey-card-compact" href="/vector-knowledge"><span class="journey-number">4</span><div><h2>권한 기반 검색</h2><strong>열기 →</strong></div></a>
<a class="journey-card journey-card-compact" href="/dds"><span class="journey-number">5</span><div><h2>보호 객체 확인</h2><strong>열기 →</strong></div></a>

View File

@@ -15,7 +15,7 @@
</details>
</div>
<section th:replace="~{fragments/layout :: architectureStrip('vpd')}"></section>
<section th:replace="~{fragments/layout :: architectureStrip('protection')}"></section>
<section class="content-band">
<details class="explanation-details">

View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="directComparison" class="table-responsive">
<table class="table table-sm align-middle protection-direct-table">
<thead><tr><th>검증 프로필</th><th>보호 상태</th><th>권한 반영</th><th>검색 검증</th><th>조치</th></tr></thead>
<tbody>
<tr th:each="status : ${directPaths}">
<td>
<strong th:text="${status.subjectLabel()}">검증 프로필</strong>
<small th:text="${status.objectName()}">보호 객체</small>
</td>
<td>
<span class="protection-table-badge" th:classappend="${' tone-' + status.primaryTone()}"
th:text="${status.primaryLabel()}">재검증 필요</span>
<small th:text="${status.primaryDescription()}">설명</small>
</td>
<td><strong th:text="${status.synchronizationLabel()}">선언 관측</strong><small th:text="${status.synchronizationDetail()}">설명</small></td>
<td><strong th:text="${status.verificationLabel()}">검증 기록 없음</strong><small th:text="${status.verificationDetail()}">설명</small></td>
<td><a class="btn btn-sm rw-btn-secondary" th:href="${status.actionHref()}" th:text="${status.actionLabel()}">실행</a></td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -31,7 +31,7 @@
<button class="rw-menu-trigger" type="button" aria-expanded="false"
th:text="${backofficeTrack == 'DDS' ? '2. DDS 보호·검증' : '2. 보호·검증'}">2. 보호·검증</button>
<div class="rw-menu-panel">
<a class="nav-link" href="/vpd-policies"
<a class="nav-link" th:href="${backofficeTrack == 'DDS' ? '/dds-protection' : '/vpd-policies'}"
th:text="${backofficeTrack == 'DDS' ? 'DDS 보호 연결' : 'DB 보호 연결'}">DB 보호 연결</a>
<a class="nav-link" href="/dds-provision" th:if="${backofficeTrack == 'DDS'}">DDS 권한 반영</a>
<a class="nav-link" href="/tokens" th:if="${backofficeTrack != 'DDS'}">검증 세션 발급</a>
@@ -80,7 +80,7 @@
<p th:if="${backofficeTrack != 'DDS'}">누가 어떤 데이터의 어느 행과 컬럼을 볼지 정합니다.</p>
</div>
<div class="architecture-arrow"></div>
<div class="architecture-step" th:classappend="${activeLayer == 'vpd'} ? ' active'">
<div class="architecture-step" th:classappend="${activeLayer == 'vpd' or activeLayer == 'protection'} ? ' active'">
<span class="architecture-kicker" th:text="${backofficeTrack == 'DDS' ? '2 · ENFORCE' : '2 · ENFORCE'}">2 · ENFORCE</span>
<strong th:text="${backofficeTrack == 'DDS' ? 'DDS 보호 연결' : 'DB 보호 연결'}">DB 보호 연결</strong>
<p th:if="${backofficeTrack != 'DDS'}" th:text="${'VPD가 저장된 권한체계를 매번 읽어 DB에서 행을 자동 제한합니다.'}">DB 보호 정책이 권한을 적용합니다.</p>

View File

@@ -1,109 +1,100 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('DDS 보호 연결')}"></head>
<head th:replace="~{fragments/layout :: head('DDS 보호 상태')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<span class="architecture-kicker">DDS · ENFORCEMENT</span>
<h1>DDS 보호 연결</h1>
<p class="context-summary">권한 관리에서 만든 규칙을 DDS DATA ROLE/DATA GRANT로 연결하고 실제 검색 결과로 확인합니다.</p>
<details class="explanation-details">
<summary>VPD의 Policy/Filter와 무엇이 다른가요?</summary>
<p>VPD는 Policy가 Filter function을 호출해 요청마다 predicate를 계산합니다. DDS는 보호 VIEW/TABLE별 DATA GRANT에 행·컬럼 조건을 선언하고, 필요하면 predicate가 공통 권한을 읽는 Definer-rights 함수를 호출합니다.</p>
<p class="mb-0">따라서 이 화면에서는 VPD Filter를 수정하지 않습니다. 일상적인 변경은 <a href="/permissions">권한 관리</a>에서 하고, DDS grant 반영은 승인된 SQL/배포 절차로 수행합니다.</p>
</details>
</div>
<header class="page-title">
<span class="architecture-kicker">DDS · PROTECTION STATUS</span>
<h1>DDS 보호 상태</h1>
<p class="context-summary">지식 검색에 쓰는 보호 객체가 현재 권한 기준대로 연결되어 있는지 확인합니다.</p>
</header>
<section th:replace="~{fragments/layout :: architectureStrip('vpd')}"></section>
<div class="alert alert-warning" th:if="${runtimeError}">
DDS 권한 규칙을 불러오지 못했습니다. <span th:text="${runtimeError}"></span>
</div>
<section th:replace="~{fragments/layout :: architectureStrip('protection')}"></section>
<section class="content-band protection-summary" aria-labelledby="service-path-heading">
<div class="section-heading">
<div>
<span class="architecture-kicker">기본 서비스 경로</span>
<h2 id="service-path-heading">토큰 기반 지식 검색</h2>
<p class="section-subtitle">일상 운영에서는 이 경로만 확인하면 됩니다. 직접 DDS END USER 비교는 아래 고급 검증에 분리했습니다.</p>
</div>
<span class="badge text-bg-danger" th:if="${protection.actionRequiredCount() > 0}"
th:text="${protection.actionRequiredCount() + '개 조치 필요'}">조치 필요</span>
<span class="badge text-bg-warning" th:unless="${protection.actionRequiredCount() > 0}">검증 확인 필요</span>
</div>
<article class="protection-status-card"
th:classappend="${' status-' + protection.servicePath.primaryTone()}"
aria-label="토큰 기반 서비스 경로 보호 상태">
<div class="protection-status-topline">
<div>
<span class="protection-path-label" th:text="${protection.servicePath.pathLabel()}">토큰 기반 서비스 경로</span>
<h3 th:text="${protection.servicePath.objectName()}">ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS</h3>
<p th:text="${protection.servicePath.subjectLabel()}">기술 사용자</p>
</div>
<span class="protection-primary-badge"
th:classappend="${' tone-' + protection.servicePath.primaryTone()}"
th:text="${protection.servicePath.primaryLabel()}">재검증 필요</span>
</div>
<p class="protection-primary-description" th:text="${protection.servicePath.primaryDescription()}">설명</p>
<dl class="protection-evidence-grid">
<div>
<dt>DB 관측</dt>
<dd><strong th:text="${protection.servicePath.observationLabel()}">일부 확인</strong><span th:text="${protection.servicePath.observationDetail()}">설명</span></dd>
</div>
<div>
<dt>권한 반영</dt>
<dd><strong th:text="${protection.servicePath.synchronizationLabel()}">선언 관측</strong><span th:text="${protection.servicePath.synchronizationDetail()}">설명</span></dd>
</div>
<div>
<dt>검색 검증</dt>
<dd><strong th:text="${protection.servicePath.verificationLabel()}">검증 기록 없음</strong><span th:text="${protection.servicePath.verificationDetail()}">설명</span></dd>
</div>
</dl>
<footer class="protection-status-footer">
<span th:text="${'마지막 관측 ' + protection.servicePath.observedAtLabel()}">마지막 관측</span>
<a class="btn rw-btn-primary" th:href="${protection.servicePath.actionHref()}"
th:text="${protection.servicePath.actionLabel()}">토큰 권한으로 검색</a>
</footer>
</article>
</section>
<section class="content-band">
<div class="section-heading">
<div>
<span class="architecture-kicker">공통 관리 기준</span>
<h2>VPD와 동일한 권한 관리 대상을 사용합니다.</h2>
<p class="section-subtitle">사용자·그룹·역할·행 규칙·TAG 규칙은 한 권한 화면에서 관리합니다.</p>
<span class="architecture-kicker">권한 변경</span>
<h2>권한을 바꾸려면</h2>
<p class="section-subtitle">권한 규칙을 먼저 수정하고, 직접 비교 경로의 변경은 DDS 권한 반영에서 미리 확인합니다.</p>
</div>
<div class="d-flex gap-2">
<a class="btn btn-sm rw-btn-secondary" href="/permissions">권한 규칙 열기</a>
<a class="btn btn-sm rw-btn-primary" href="/dds-provision">DDS 권한 반영</a>
<a class="btn btn-sm rw-btn-primary" href="/dds-provision">권한 반영 미리보기</a>
</div>
</div>
<div class="policy-apply-flow" aria-label="DDS 적용 계층">
<span>사용자·그룹·역할</span><strong></strong>
<span>객체별 행·TAG·컬럼 권한</span><strong></strong>
<span>DATA ROLE / DATA GRANT</span><strong></strong>
<span>토큰 Context 또는 END USER 결과</span>
</div>
<p class="text-muted mb-0">관리 대상 이름: <code th:text="${managementObject}">CB_VECTOR_SEARCH_DOCUMENTS</code></p>
</section>
<section class="content-band">
<div class="section-heading">
<div>
<h2>DDS 보호 객체</h2>
<p class="section-subtitle">VPD용 객체와 분리된 DDS 전용 VIEW입니다. 같은 청크·태그 저장소를 읽지만 VPD 정책은 붙이지 않습니다.</p>
<details class="explanation-details m-0">
<summary>두 집행 경로의 차이 보기</summary>
<div class="protection-path-comparison">
<div><strong>토큰 기반 서비스</strong><span>기술 사용자 연결에서 Bearer 토큰으로 업무 사용자를 식별합니다. 객체별 DATA GRANT가 현재 공통 권한을 평가합니다.</span></div>
<div><strong>DDS END USER 직접 비교</strong><span>DATA ROLE과 게시된 DATA GRANT를 직접 확인하는 고급 진단 경로입니다. 권한 변경 뒤에는 별도 반영이 필요합니다.</span></div>
</div>
<a class="btn btn-sm rw-btn-secondary" href="/vector-knowledge">지식 검색 열기</a>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead><tr><th>보호 VIEW</th><th>적용 방식</th><th>기본 거부</th><th>검증</th></tr></thead>
<tbody>
<tr>
<td><code th:text="${ddsVectorObject}">ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS</code></td>
<td>토큰 Context/END USER → DATA ROLE → 객체별 DATA GRANT</td>
<td>Grant predicate가 없거나 조건 불일치 시 차단</td>
<td><a class="btn btn-sm btn-outline-primary" href="/vector-knowledge">검색 결과 확인</a></td>
</tr>
</tbody>
</table>
</div>
<details class="explanation-details mt-3">
<summary>추가·수정 가이드</summary>
<p>새 보호 대상을 추가할 때는 (1) 권한 관리에 논리 객체와 TAG 규칙을 등록하고 (2) DDS 전용 VIEW/TABLE을 만들고 (3) 해당 객체에 <code>CREATE OR REPLACE DATA GRANT ... WHERE ...</code>를 연결하고 (4) 토큰 Context와 직접 END USER의 허용·거부·권한 없음을 각각 검증합니다.</p>
<p class="mb-0">벡터 기준 SQL은 <code>sql/adb/32_dds_vector_tag_setup.sql</code>, 토큰 기반 공통 predicate 기준은 <code>sql/adb/34_dds_token_data_grant_common_auth.sql</code>입니다.</p>
</details>
</section>
<section class="content-band">
<div class="section-heading">
<div>
<h2>역할별 관리 규칙</h2>
<p class="section-subtitle">아래 내용은 공통 권한 화면의 현재 규칙입니다. DDS에서는 이 규칙을 DATA GRANT 조건으로 반영합니다.</p>
<details class="advanced-protection" aria-labelledby="direct-check-heading">
<summary id="direct-check-heading">고급 검증: DDS END USER 직접 비교 보기</summary>
<p class="section-subtitle mt-3">아래 항목은 제품 계정 목록이 아니라 DDS 선언형 권한을 비교하기 위한 검증 프로필입니다. 평소 운영에서는 열 필요가 없습니다.</p>
<button class="btn btn-sm rw-btn-secondary mt-2" type="button"
hx-get="/dds-protection/direct" hx-target="#direct-comparison-result" hx-swap="innerHTML">
고급 검증 불러오기
</button>
<div id="direct-comparison-result" class="mt-3" aria-live="polite">
<p class="text-muted mb-0">직접 비교는 필요할 때만 불러옵니다. 기본 서비스 검색에는 영향을 주지 않습니다.</p>
</div>
<span class="badge text-bg-secondary" th:text="${#lists.size(permissions)} + '개 규칙'">0개 규칙</span>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead><tr><th>역할</th><th>효과</th><th>행·태그 규칙</th><th>원문 허용 컬럼</th></tr></thead>
<tbody>
<tr th:each="permission : ${permissions}">
<td><strong th:text="${permission.roleName()}">ROLE</strong></td>
<td><span class="badge" th:classappend="${permission.permissionEffect() == 'ALLOW'} ? ' text-bg-success' : ' text-bg-danger'" th:text="${permission.permissionEffect()}">ALLOW</span></td>
<td><code th:text="${permission.rules() ?: '등록된 규칙 없음'}">TAG SPRING_BOOT</code></td>
<td th:text="${permission.visibleColumns() ?: '없음'}">없음</td>
</tr>
<tr th:if="${#lists.isEmpty(permissions)}"><td colspan="4" class="text-muted">아직 이 객체에 연결된 권한 규칙이 없습니다. 권한 관리에서 역할과 TAG 규칙을 먼저 등록하세요.</td></tr>
</tbody>
</table>
</div>
</section>
<section class="content-band">
<div class="section-heading">
<div><h2>DDS END USER 검증 주체</h2><p class="section-subtitle">비밀번호는 서버 환경 변수로만 관리하고 화면에는 표시하지 않습니다.</p></div>
</div>
<div class="summary-grid">
<div class="summary-tile" th:each="user : ${ddsUsers}">
<span class="label" th:text="${user.label()}">사용자</span>
<strong th:text="${user.username()}">dds_demo</strong>
<small th:text="${user.configured()} ? '연결 설정됨' : '비밀번호 설정 필요'">상태</small>
</div>
</div>
</details>
</section>
</main>
</body>