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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 전용 사용자
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user