Compare commits

...

2 Commits

Author SHA1 Message Date
devmrko
f162851bc6 [UX] #605 make permission wizard safety review actionable 2026-06-30 20:39:54 +09:00
devmrko
1766696ab4 feat: add DDS protection status dashboard 2026-06-30 20:31:31 +09:00
28 changed files with 926 additions and 148 deletions

View File

@@ -81,10 +81,21 @@ public record DdsProperties(
new Token("dds_demo_token", "")); 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() { public boolean configured() {
return username != null && !username.isBlank() return !username.isBlank() && !password.isBlank();
&& password != null && !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. * the bearer token.
*/ */
@Service @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 IDENTIFIER = Pattern.compile("[A-Z][A-Z0-9_$#]*");
private static final Pattern OBJECT = Pattern.compile( 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; package com.cloudhandson.ddsbackoffice.web;
import com.cloudhandson.ddsbackoffice.config.DdsProperties; import com.cloudhandson.ddsbackoffice.config.DdsProperties;
import com.cloudhandson.ddsbackoffice.service.DdsQueryService; import com.cloudhandson.ddsbackoffice.service.DdsProtectionStatusService;
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionView;
import com.cloudhandson.vpdbackoffice.service.PermissionService;
import java.util.List;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.servlet.view.RedirectView; 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 @Controller
public class DdsProtectionController { public class DdsProtectionController {
private static final String MANAGEMENT_OBJECT = "CB_VECTOR_SEARCH_DOCUMENTS";
private final PermissionService permissionService;
private final DdsProperties properties; private final DdsProperties properties;
private final DdsQueryService queryService; private final DdsProtectionStatusService protectionStatusService;
public DdsProtectionController( public DdsProtectionController(
PermissionService permissionService,
DdsProperties properties, DdsProperties properties,
DdsQueryService queryService DdsProtectionStatusService protectionStatusService
) { ) {
this.permissionService = permissionService;
this.properties = properties; this.properties = properties;
this.queryService = queryService; this.protectionStatusService = protectionStatusService;
} }
@GetMapping("/vpd-policies") @GetMapping("/dds-protection")
public String page(Model model) { public String page(Model model) {
model.addAttribute("ddsVectorObject", properties.vectorObject()); model.addAttribute("ddsVectorObject", properties.vectorObject());
model.addAttribute("managementObject", MANAGEMENT_OBJECT); model.addAttribute("protection", protectionStatusService.overview());
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());
}
return "vpd-policies"; 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") @GetMapping("/vpd-filter-policies")
public RedirectView filterPolicies() { public RedirectView filterPolicies() {
return new RedirectView("/vpd-policies"); return new RedirectView("/dds-protection");
} }
} }

View File

@@ -67,6 +67,7 @@ dds:
token: token:
username: ${DDS_BACKOFFICE_TOKEN_USERNAME:dds_demo_token} username: ${DDS_BACKOFFICE_TOKEN_USERNAME:dds_demo_token}
password: ${DDS_BACKOFFICE_TOKEN_PASSWORD:${DDSUSER_TOKEN_PASSWORD:}} password: ${DDS_BACKOFFICE_TOKEN_PASSWORD:${DDSUSER_TOKEN_PASSWORD:}}
data-role: ${DDS_BACKOFFICE_TOKEN_DATA_ROLE:cb_dds_token_role}
users: users:
my: my:
label: 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 h2 { margin: 0 0 .25rem; }
.journey-card-compact strong { font-size: .8rem; } .journey-card-compact strong { font-size: .8rem; }
.compact-context { padding: .75rem; } .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) { @media (max-width: 800px) {
.flow-grid, .matrix-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .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) { @media (max-width: 540px) {
.flow-grid, .matrix-grid { grid-template-columns: 1fr; } .flow-grid, .matrix-grid { grid-template-columns: 1fr; }
.panel { padding: 19px; } .panel { padding: 19px; }
.hero { padding-top: 30px; } .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 권한 적용 네 단계"> <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="/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="/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="/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> <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> </details>
</div> </div>
<section th:replace="~{fragments/layout :: architectureStrip('vpd')}"></section> <section th:replace="~{fragments/layout :: architectureStrip('protection')}"></section>
<section class="content-band"> <section class="content-band">
<details class="explanation-details"> <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" <button class="rw-menu-trigger" type="button" aria-expanded="false"
th:text="${backofficeTrack == 'DDS' ? '2. DDS 보호·검증' : '2. 보호·검증'}">2. 보호·검증</button> th:text="${backofficeTrack == 'DDS' ? '2. DDS 보호·검증' : '2. 보호·검증'}">2. 보호·검증</button>
<div class="rw-menu-panel"> <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> 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="/dds-provision" th:if="${backofficeTrack == 'DDS'}">DDS 권한 반영</a>
<a class="nav-link" href="/tokens" th:if="${backofficeTrack != 'DDS'}">검증 세션 발급</a> <a class="nav-link" href="/tokens" th:if="${backofficeTrack != 'DDS'}">검증 세션 발급</a>
@@ -80,7 +80,7 @@
<p th:if="${backofficeTrack != 'DDS'}">누가 어떤 데이터의 어느 행과 컬럼을 볼지 정합니다.</p> <p th:if="${backofficeTrack != 'DDS'}">누가 어떤 데이터의 어느 행과 컬럼을 볼지 정합니다.</p>
</div> </div>
<div class="architecture-arrow"></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> <span class="architecture-kicker" th:text="${backofficeTrack == 'DDS' ? '2 · ENFORCE' : '2 · ENFORCE'}">2 · ENFORCE</span>
<strong th:text="${backofficeTrack == 'DDS' ? 'DDS 보호 연결' : 'DB 보호 연결'}">DB 보호 연결</strong> <strong th:text="${backofficeTrack == 'DDS' ? 'DDS 보호 연결' : 'DB 보호 연결'}">DB 보호 연결</strong>
<p th:if="${backofficeTrack != 'DDS'}" th:text="${'VPD가 저장된 권한체계를 매번 읽어 DB에서 행을 자동 제한합니다.'}">DB 보호 정책이 권한을 적용합니다.</p> <p th:if="${backofficeTrack != 'DDS'}" th:text="${'VPD가 저장된 권한체계를 매번 읽어 DB에서 행을 자동 제한합니다.'}">DB 보호 정책이 권한을 적용합니다.</p>

View File

@@ -1,109 +1,100 @@
<!doctype html> <!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"> <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> <body>
<nav th:replace="~{fragments/layout :: nav}"></nav> <nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4"> <main class="container py-4">
<div class="page-title"> <header class="page-title">
<span class="architecture-kicker">DDS · ENFORCEMENT</span> <span class="architecture-kicker">DDS · PROTECTION STATUS</span>
<h1>DDS 보호 연결</h1> <h1>DDS 보호 상태</h1>
<p class="context-summary">권한 관리에서 만든 규칙을 DDS DATA ROLE/DATA GRANT로 연결하고 실제 검색 결과로 확인합니다.</p> <p class="context-summary">지식 검색에 쓰는 보호 객체가 현재 권한 기준대로 연결되어 있는지 확인합니다.</p>
<details class="explanation-details"> </header>
<summary>VPD의 Policy/Filter와 무엇이 다른가요?</summary>
<p>VPD는 Policy가 Filter function을 호출해 요청마다 predicate를 계산합니다. DDS는 보호 VIEW/TABLE별 DATA GRANT에 행·컬럼 조건을 선언하고, 필요하면 predicate가 공통 권한을 읽는 Definer-rights 함수를 호출합니다.</p> <section th:replace="~{fragments/layout :: architectureStrip('protection')}"></section>
<p class="mb-0">따라서 이 화면에서는 VPD Filter를 수정하지 않습니다. 일상적인 변경은 <a href="/permissions">권한 관리</a>에서 하고, DDS grant 반영은 승인된 SQL/배포 절차로 수행합니다.</p>
</details> <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> </div>
<section th:replace="~{fragments/layout :: architectureStrip('vpd')}"></section> <article class="protection-status-card"
<div class="alert alert-warning" th:if="${runtimeError}"> th:classappend="${' status-' + protection.servicePath.primaryTone()}"
DDS 권한 규칙을 불러오지 못했습니다. <span th:text="${runtimeError}"></span> 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> </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"> <section class="content-band">
<div class="section-heading"> <div class="section-heading">
<div> <div>
<span class="architecture-kicker">공통 관리 기준</span> <span class="architecture-kicker">권한 변경</span>
<h2>VPD와 동일한 권한 관리 대상을 사용합니다.</h2> <h2>권한을 바꾸려면</h2>
<p class="section-subtitle">사용자·그룹·역할·행 규칙·TAG 규칙은 한 권한 화면에서 관리합니다.</p> <p class="section-subtitle">권한 규칙을 먼저 수정하고, 직접 비교 경로의 변경은 DDS 권한 반영에서 미리 확인합니다.</p>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a class="btn btn-sm rw-btn-secondary" href="/permissions">권한 규칙 열기</a> <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> </div>
<div class="policy-apply-flow" aria-label="DDS 적용 계층"> <details class="explanation-details m-0">
<span>사용자·그룹·역할</span><strong></strong> <summary>두 집행 경로의 차이 보기</summary>
<span>객체별 행·TAG·컬럼 권한</span><strong></strong> <div class="protection-path-comparison">
<span>DATA ROLE / DATA GRANT</span><strong></strong> <div><strong>토큰 기반 서비스</strong><span>기술 사용자 연결에서 Bearer 토큰으로 업무 사용자를 식별합니다. 객체별 DATA GRANT가 현재 공통 권한을 평가합니다.</span></div>
<span>토큰 Context 또는 END USER 결과</span> <div><strong>DDS END USER 직접 비교</strong><span>DATA ROLE과 게시된 DATA GRANT를 직접 확인하는 고급 진단 경로입니다. 권한 변경 뒤에는 별도 반영이 필요합니다.</span></div>
</div> </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>
</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> </details>
</section> </section>
<section class="content-band"> <section class="content-band">
<div class="section-heading"> <details class="advanced-protection" aria-labelledby="direct-check-heading">
<div> <summary id="direct-check-heading">고급 검증: DDS END USER 직접 비교 보기</summary>
<h2>역할별 관리 규칙</h2> <p class="section-subtitle mt-3">아래 항목은 제품 계정 목록이 아니라 DDS 선언형 권한을 비교하기 위한 검증 프로필입니다. 평소 운영에서는 열 필요가 없습니다.</p>
<p class="section-subtitle">아래 내용은 공통 권한 화면의 현재 규칙입니다. DDS에서는 이 규칙을 DATA GRANT 조건으로 반영합니다.</p> <button class="btn btn-sm rw-btn-secondary mt-2" type="button"
</div> hx-get="/dds-protection/direct" hx-target="#direct-comparison-result" hx-swap="innerHTML">
<span class="badge text-bg-secondary" th:text="${#lists.size(permissions)} + '개 규칙'">0개 규칙</span> 고급 검증 불러오기
</div> </button>
<div class="table-responsive"> <div id="direct-comparison-result" class="mt-3" aria-live="polite">
<table class="table table-sm align-middle"> <p class="text-muted mb-0">직접 비교는 필요할 때만 불러옵니다. 기본 서비스 검색에는 영향을 주지 않습니다.</p>
<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> </div>
</details>
</section> </section>
</main> </main>
</body> </body>

View File

@@ -61,7 +61,8 @@ class DdsPropertiesTest {
void bindsTokenTechnicalUserSeparatelyFromApplicationUsers() { void bindsTokenTechnicalUserSeparatelyFromApplicationUsers() {
var source = new MapConfigurationPropertySource(Map.of( var source = new MapConfigurationPropertySource(Map.of(
"dds.token.username", "dds_demo_token", "dds.token.username", "dds_demo_token",
"dds.token.password", "secret" "dds.token.password", "secret",
"dds.token.data-role", "cb_dds_token_role"
)); ));
var properties = new Binder(source) var properties = new Binder(source)
@@ -69,6 +70,7 @@ class DdsPropertiesTest {
.orElseThrow(() -> new AssertionError("dds token properties did not bind")); .orElseThrow(() -> new AssertionError("dds token properties did not bind"));
assertEquals("dds_demo_token", properties.token().username()); assertEquals("dds_demo_token", properties.token().username());
assertEquals("cb_dds_token_role", properties.token().dataRole());
assertTrue(properties.token().configured()); assertTrue(properties.token().configured());
} }
} }

View File

@@ -0,0 +1,92 @@
package com.cloudhandson.ddsbackoffice.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
import com.cloudhandson.ddsbackoffice.domain.DdsGrantInventoryEntry;
import com.cloudhandson.ddsbackoffice.domain.DdsGrantInventorySnapshot;
import com.cloudhandson.ddsbackoffice.domain.DdsGrantPlan;
import com.cloudhandson.ddsbackoffice.domain.DdsProvisioningPlan;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class DdsProtectionStatusServiceTest {
@Test
void neverMarksAnUnavailableInventoryAsProtected() {
DdsGrantInventory inventory = object -> new DdsGrantInventorySnapshot(
false, List.of(), Instant.parse("2026-06-30T00:00:00Z"), "catalog 접근 실패"
);
var service = new DdsProtectionStatusService(properties(), inventory, emptyPlanProvider());
var overview = service.overview();
assertEquals("확인 불가", overview.servicePath().primaryLabel());
assertEquals("확인 불가", overview.servicePath().observationLabel());
assertTrue(overview.servicePath().needsAction());
}
@Test
void separatesMissingTokenGrantFromDirectComparisonEvidence() {
DdsGrantInventory inventory = object -> new DdsGrantInventorySnapshot(
true,
List.of(new DdsGrantInventoryEntry(
"DDS_DEMO_BOTH_VECTOR_GRANT", "CB_DDS_VECTOR_SEARCH_DOCUMENTS", "DDS_DEMO_BOTH_ROLE")),
Instant.parse("2026-06-30T00:00:00Z"), ""
);
DdsProvisionPlanProvider provider = () -> new DdsProvisioningPlan(List.of(new DdsGrantPlan(
"both", "통합 사용자", 103L, "dds_demo_both_role", "CB_VECTOR_SEARCH_DOCUMENTS",
"ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS", "DDS_DEMO_BOTH_VECTOR_GRANT", "1 = 1", "",
"CREATE DATA GRANT", true, "게시 가능"
)), List.of(), 1);
var service = new DdsProtectionStatusService(properties(), inventory, provider);
var overview = service.overview();
assertEquals("조치 필요", overview.servicePath().primaryLabel());
assertEquals("반영 필요", overview.servicePath().synchronizationLabel());
var direct = service.directComparison();
assertEquals(1, direct.size());
assertEquals("재검증 필요", direct.getFirst().primaryLabel());
assertEquals("선언 관측", direct.getFirst().synchronizationLabel());
}
@Test
void reportsDirectGrantDriftWhenCurrentPlanHasNoGrantButInventoryDoes() {
DdsGrantInventory inventory = object -> new DdsGrantInventorySnapshot(
true,
List.of(new DdsGrantInventoryEntry(
"DDS_DEMO_BOTH_VECTOR_GRANT", "CB_DDS_VECTOR_SEARCH_DOCUMENTS", "DDS_DEMO_BOTH_ROLE")),
Instant.parse("2026-06-30T00:00:00Z"), ""
);
DdsProvisionPlanProvider provider = () -> new DdsProvisioningPlan(List.of(new DdsGrantPlan(
"both", "통합 사용자", 103L, "dds_demo_both_role", "CB_VECTOR_SEARCH_DOCUMENTS",
"ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS", "DDS_DEMO_BOTH_VECTOR_GRANT", "", "", "",
false, "ALLOW 규칙 없음"
)), List.of(), 1);
var service = new DdsProtectionStatusService(properties(), inventory, provider);
var direct = service.directComparison().getFirst();
assertEquals("조치 필요", direct.primaryLabel());
assertEquals("차이 발견", direct.synchronizationLabel());
}
private static DdsProperties properties() {
return new DdsProperties(
"jdbc:test", Duration.ofSeconds(5), "ADMIN.V_DDS_CUSTOMERS_PG", "ADMIN.V_DDS_CUSTOMERS_MY",
"ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS",
Map.of("both", new DdsProperties.User(
"통합 사용자", "직접 비교", "dds_demo_both", "secret", 103L, "dds_demo_both_role")),
Map.of("CB_VECTOR_SEARCH_DOCUMENTS", "ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS"),
new DdsProperties.Token("dds_demo_token", "secret", "cb_dds_token_role")
);
}
private static DdsProvisionPlanProvider emptyPlanProvider() {
return () -> new DdsProvisioningPlan(List.of(), List.of(), 0);
}
}

View File

@@ -1,8 +1,8 @@
# VPD Backoffice 전 페이지 페르소나 UX 리뷰와 개선 로드맵 # VPD Backoffice 전 페이지 페르소나 UX 리뷰와 개선 로드맵
> **상태**: Review complete · UX-1 in progress > **상태**: Review complete · UX-1/UX-2 implementation in progress
> **작성**: [AI] UX Facilitator · **최종수정**: 2026-06-30 > **작성**: [AI] UX Facilitator · **최종수정**: 2026-06-30
> **추적성** — Redmine: UX audit 상위 이슈 등록 예정 · 선행 개선: #575 대시보드, #576 기본 검증 사용자명 > **추적성** — Redmine: #577 · 하위 구현: #575 대시보드, #576 기본 검증 사용자명, #599 설정, #605 권한 관리
## 1. 리뷰 방법 ## 1. 리뷰 방법
@@ -75,3 +75,15 @@ UX-1부터 시작한다. 기존 데이터 모델과 VPD 로직은 바꾸지 않
- 유효 권한 매트릭스를 사용자 중심 탭형 구조로 변경했다. 기본 화면은 선택한 사용자 한 명의 직접 역할, 그룹 상속, 최종 역할, 보호 객체, 권한 수를 보여 준다. - 유효 권한 매트릭스를 사용자 중심 탭형 구조로 변경했다. 기본 화면은 선택한 사용자 한 명의 직접 역할, 그룹 상속, 최종 역할, 보호 객체, 권한 수를 보여 준다.
- 그룹/역할 전체 표는 제거하지 않고 보조 탭으로 이동했다. - 그룹/역할 전체 표는 제거하지 않고 보조 탭으로 이동했다.
- 복합 Thymeleaf 속성과 layout fragment를 실제로 처리하는 `EffectiveMatrixTemplateRenderTest`를 추가해 UI template parse 오류를 빌드에서 검출한다. - 복합 Thymeleaf 속성과 layout fragment를 실제로 처리하는 `EffectiveMatrixTemplateRenderTest`를 추가해 UI template parse 오류를 빌드에서 검출한다.
## 7. UX-2 진행 기록 — 권한 생성 안전성 (#605)
권한 관리 화면은 5단계 구조가 있었지만, 이전에는 다음 단계와 단계 표시를 눌러 필수 선택을 건너뛸 수 있었다. `ALL`과 조건 규칙을 섞으면 서버가 저장 시 거부했기 때문에, 사용자는 긴 입력 뒤에야 실패를 알게 됐다.
- 역할과 보호 객체에 빈 선택지를 두어 적용 주체와 대상을 명시적으로 고르게 한다.
- 다음/단계 이동/저장 시 1~3단계를 검증한다. 빠진 역할·객체·비교 값, 중복 규칙, `ALL`과 조건 규칙의 혼용은 해당 단계에서 이유를 보여 주고 이동을 막는다.
- 상단 요약에 직접 사용자·그룹·그룹 상속 사용자 수와 저장 준비 상태를 고정한다.
- 최종 검토에 `전체 행 허용/거부` 또는 조건부 영향 문장과 되돌리기 방법을 표시한다. 마지막 권한을 삭제하면 보호 객체가 비활성화될 수 있음을 저장 전에 안내한다.
- 브라우저 검증을 우회한 POST도 역할 누락·객체 형식·행 규칙 오류를 flash 오류로 돌려준다. 서버의 `PermissionService` 검증은 그대로 최종 방어선으로 유지한다.
검증 범위는 `GuidedFlowTemplateTest`, `PermissionServiceTest`, 전체 Maven 테스트 및 배포 후 `/permissions` HTTP 확인이다.

View File

@@ -115,7 +115,7 @@ for _ in {1..30}; do
done done
"$APP_DIR/status.sh" "$APP_DIR/status.sh"
curl -sS -o /tmp/dds-backoffice-login.html -w 'login=%{http_code}\n' "http://127.0.0.1:${PORT}/login" curl -sS -o /tmp/dds-backoffice-login.html -w 'login=%{http_code}\n' "http://127.0.0.1:${PORT}/login"
for path in / /users /groups /roles /permissions /effective-matrix /vpd-policies /dds-provision /vector-knowledge /dds; do for path in / /users /groups /roles /permissions /effective-matrix /dds-protection /vpd-policies /dds-provision /vector-knowledge /dds; do
curl -sS --connect-timeout 3 --max-time 20 -u "${BACKOFFICE_ADMIN_USER:-}:${BACKOFFICE_ADMIN_PASSWORD:-}" \ curl -sS --connect-timeout 3 --max-time 20 -u "${BACKOFFICE_ADMIN_USER:-}:${BACKOFFICE_ADMIN_PASSWORD:-}" \
-o "/tmp/dds-backoffice-check-${path#/}.html" \ -o "/tmp/dds-backoffice-check-${path#/}.html" \
-w "${path}=%{http_code}\n" "http://127.0.0.1:${PORT}${path}" || true -w "${path}=%{http_code}\n" "http://127.0.0.1:${PORT}${path}" || true

View File

@@ -195,15 +195,19 @@ public class PermissionController {
@PostMapping("/permissions") @PostMapping("/permissions")
public String save( public String save(
@RequestParam long roleId, @RequestParam(required = false) Long roleId,
@RequestParam String objectRef, @RequestParam(required = false) String objectRef,
@RequestParam(defaultValue = "ALLOW") String permissionEffect, @RequestParam(defaultValue = "ALLOW") String permissionEffect,
@RequestParam(required = false) List<String> ruleColumn, @RequestParam(required = false) List<String> ruleColumn,
@RequestParam List<String> ruleType, @RequestParam(required = false) List<String> ruleType,
@RequestParam(required = false) List<String> ruleValue, @RequestParam(required = false) List<String> ruleValue,
@RequestParam(required = false) String visibleColumns, @RequestParam(required = false) String visibleColumns,
RedirectAttributes redirectAttributes RedirectAttributes redirectAttributes
) { ) {
try {
if (roleId == null) {
throw new AppException("권한을 적용할 역할을 선택하세요.");
}
long objectId = resolveObjectId(objectRef); long objectId = resolveObjectId(objectRef);
permissionService.savePermissionSet(new PermissionSetCommand( permissionService.savePermissionSet(new PermissionSetCommand(
roleId, roleId,
@@ -214,6 +218,9 @@ public class PermissionController {
splitColumns(visibleColumns) splitColumns(visibleColumns)
)); ));
redirectAttributes.addFlashAttribute("message", "권한을 저장했습니다."); redirectAttributes.addFlashAttribute("message", "권한을 저장했습니다.");
} catch (AppException | IllegalArgumentException exception) {
redirectAttributes.addFlashAttribute("error", exception.getMessage());
}
return "redirect:/permissions"; return "redirect:/permissions";
} }

View File

@@ -531,6 +531,11 @@ body {
min-width: 0; min-width: 0;
} }
.section-description {
color: var(--rw-muted);
margin: .2rem 0 0;
}
.wizard-progress { .wizard-progress {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -584,6 +589,15 @@ body {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.wizard-validation {
background: #fff4e5;
border: 1px solid #e7a84c;
border-radius: 8px;
color: #7a4600;
font-weight: 700;
padding: .75rem .9rem;
}
.wizard-panel { .wizard-panel {
border: 1px solid var(--rw-border); border: 1px solid var(--rw-border);
border-radius: 8px; border-radius: 8px;
@@ -615,6 +629,18 @@ body {
margin: .15rem 0 0; margin: .15rem 0 0;
} }
.rule-safety-note {
background: var(--rw-primary-soft);
border-left: 3px solid var(--rw-primary);
color: var(--rw-text);
margin: .5rem 0 .75rem;
padding: .65rem .75rem;
}
.policy-preview-emphasis {
border-color: var(--rw-primary) !important;
}
.wizard-step-number { .wizard-step-number {
align-items: center; align-items: center;
background: var(--rw-primary-soft); background: var(--rw-primary-soft);

View File

@@ -770,6 +770,10 @@ function updatePermissionWizardPreview(root = document) {
const rules = collectWizardRules(wizard); const rules = collectWizardRules(wizard);
const predicates = collectWizardPredicates(wizard); const predicates = collectWizardPredicates(wizard);
const ruleText = rules.length ? rules.join(', ') : '행 규칙 없음'; const ruleText = rules.length ? rules.join(', ') : '행 규칙 없음';
const hasRole = Boolean(roleSelect?.value);
const hasObject = Boolean(objectSelect?.value);
const hasAllRule = rules.includes('ALL');
const hasAllRuleConflict = hasAllRule && rules.length > 1;
const directUsers = splitList(roleOption?.dataset.directUsers || ''); const directUsers = splitList(roleOption?.dataset.directUsers || '');
const groups = splitList(roleOption?.dataset.groups || ''); const groups = splitList(roleOption?.dataset.groups || '');
const groupUsers = splitList(roleOption?.dataset.groupUsers || ''); const groupUsers = splitList(roleOption?.dataset.groupUsers || '');
@@ -790,11 +794,23 @@ function updatePermissionWizardPreview(root = document) {
? '마스킹 대상 컬럼 없음' ? '마스킹 대상 컬럼 없음'
: (nullColumns.length ? `NULL 처리: ${nullColumns.join(', ')}` : '선택한 마스킹 컬럼 모두 원문 표시 허용'); : (nullColumns.length ? `NULL 처리: ${nullColumns.join(', ')}` : '선택한 마스킹 컬럼 모두 원문 표시 허용');
const affectedPrincipals = `직접 사용자 ${directUsers.length}명 / 그룹 ${groups.length}개 / 그룹 상속 사용자 ${groupUsers.length}`; const affectedPrincipals = `직접 사용자 ${directUsers.length}명 / 그룹 ${groups.length}개 / 그룹 상속 사용자 ${groupUsers.length}`;
const readiness = !hasRole
? '역할을 선택하세요'
: !hasObject
? '보호 객체를 선택하세요'
: hasAllRuleConflict
? 'ALL 규칙을 단독으로 정리하세요'
: '저장 전 검토 가능';
const saveGuard = hasAllRule
? `${effect === 'DENY' ? '전체 행 거부' : '전체 행 허용'}입니다. ${affectedPrincipals}에게 영향을 줄 수 있습니다.`
: `${effect === 'DENY' ? '선택 조건의 행을 거부' : '선택 조건의 행만 허용'}합니다. 저장 후 결과 확인에서 실제 VPD predicate를 검증하세요.`;
wizard.querySelector('[data-wizard-summary="role"]').textContent = selectedText(roleSelect); wizard.querySelector('[data-wizard-summary="role"]').textContent = selectedText(roleSelect);
wizard.querySelector('[data-wizard-summary="object"]').textContent = selectedText(objectSelect); wizard.querySelector('[data-wizard-summary="object"]').textContent = selectedText(objectSelect);
wizard.querySelector('[data-wizard-summary="effect"]').textContent = effect; wizard.querySelector('[data-wizard-summary="effect"]').textContent = effect;
wizard.querySelector('[data-wizard-summary="rules"]').textContent = ruleText; wizard.querySelector('[data-wizard-summary="rules"]').textContent = ruleText;
wizard.querySelector('[data-wizard-summary="affected"]').textContent = hasRole ? affectedPrincipals : '역할 선택 후 확인';
wizard.querySelector('[data-wizard-summary="readiness"]').textContent = readiness;
setWizardPreview(wizard, 'role', selectedText(roleSelect)); setWizardPreview(wizard, 'role', selectedText(roleSelect));
setWizardPreview(wizard, 'sensitivity', roleOption?.dataset.maxSensitivity || 'PUBLIC'); setWizardPreview(wizard, 'sensitivity', roleOption?.dataset.maxSensitivity || 'PUBLIC');
@@ -810,6 +826,84 @@ function updatePermissionWizardPreview(root = document) {
setWizardPreview(wizard, 'predicatePreview', predicateText); setWizardPreview(wizard, 'predicatePreview', predicateText);
setWizardPreview(wizard, 'columnPolicy', columnPolicy); setWizardPreview(wizard, 'columnPolicy', columnPolicy);
setWizardPreview(wizard, 'nullPolicy', nullPolicy); setWizardPreview(wizard, 'nullPolicy', nullPolicy);
setWizardPreview(wizard, 'saveGuard', hasRole && hasObject ? saveGuard : '역할과 보호 객체를 선택하면 저장 영향을 계산합니다.');
}
function permissionWizardIndicators(wizard) {
return wizard.closest('.content-band')?.querySelectorAll('[data-wizard-target]') || [];
}
function showPermissionWizardValidation(wizard, message) {
const validation = wizard.querySelector('[data-wizard-validation]');
if (!validation) {
return;
}
validation.textContent = message;
validation.hidden = false;
}
function clearPermissionWizardValidation(wizard) {
const validation = wizard.querySelector('[data-wizard-validation]');
if (validation) {
validation.hidden = true;
validation.textContent = '';
}
}
function permissionWizardRuleValidationMessage(wizard) {
const rows = Array.from(wizard.querySelectorAll('.rule-row'));
if (!rows.length) {
return '행 규칙을 하나 이상 추가하세요.';
}
const values = rows.map((row) => ({
column: row.querySelector('[name="ruleColumn"]')?.value || '',
type: row.querySelector('[name="ruleType"]')?.value || '',
value: row.querySelector('[name="ruleValue"]')?.value.trim() || ''
}));
if (values.some((rule) => rule.type === 'ALL') && values.length > 1) {
return 'ALL 규칙은 다른 조건 규칙과 함께 저장할 수 없습니다. ALL만 남기거나 ALL을 삭제하세요.';
}
const valueRequired = ['=', '!=', 'DEPT', 'EMP_NO', 'TAG'];
const seen = new Set();
for (const [index, rule] of values.entries()) {
const position = index + 1;
if (!rule.type) {
return `${position}번째 행 규칙 유형을 선택하세요.`;
}
if (['=', '!='].includes(rule.type) && !rule.column) {
return `${position}번째 ${rule.type} 규칙에는 비교할 컬럼이 필요합니다.`;
}
if (valueRequired.includes(rule.type) && !rule.value) {
return `${position}번째 ${rule.type} 규칙의 값을 입력하세요.`;
}
if (rule.type === 'TAG' && rule.value && !/^[A-Za-z0-9_-]+$/.test(rule.value)) {
return `${position}번째 TAG 값은 영문·숫자, '_' 또는 '-'만 사용할 수 있습니다.`;
}
const signature = `${rule.column}:${rule.type}:${rule.value.toUpperCase()}`;
if (seen.has(signature)) {
return `${position}번째 행 규칙이 앞의 규칙과 중복됩니다.`;
}
seen.add(signature);
}
return '';
}
function validatePermissionWizardStep(wizard, step) {
if (step === 1 && !wizard.querySelector('[name="roleId"]')?.value) {
return '권한을 적용할 역할을 선택하세요.';
}
if (step === 2 && !wizard.querySelector('[name="objectRef"]')?.value) {
return '권한을 적용할 보호 객체를 선택하세요.';
}
if (step === 3) {
return permissionWizardRuleValidationMessage(wizard);
}
return '';
}
function focusPermissionWizardStep(wizard, step) {
const panel = wizard.querySelector(`[data-wizard-step="${step}"]`);
panel?.querySelector('select, input, button')?.focus();
} }
function activatePermissionWizardStep(wizard, step) { function activatePermissionWizardStep(wizard, step) {
@@ -819,8 +913,10 @@ function activatePermissionWizardStep(wizard, step) {
panels.forEach((panel) => { panels.forEach((panel) => {
panel.classList.toggle('active', Number(panel.dataset.wizardStep) === nextStep); panel.classList.toggle('active', Number(panel.dataset.wizardStep) === nextStep);
}); });
document.querySelectorAll('[data-wizard-target]').forEach((button) => { permissionWizardIndicators(wizard).forEach((button) => {
button.classList.toggle('active', Number(button.dataset.wizardTarget) === nextStep); const active = Number(button.dataset.wizardTarget) === nextStep;
button.classList.toggle('active', active);
button.setAttribute('aria-current', active ? 'step' : 'false');
}); });
wizard.dataset.currentStep = String(nextStep); wizard.dataset.currentStep = String(nextStep);
const prev = wizard.querySelector('[data-wizard-prev]'); const prev = wizard.querySelector('[data-wizard-prev]');
@@ -838,6 +934,27 @@ function activatePermissionWizardStep(wizard, step) {
updatePermissionWizardPreview(document); updatePermissionWizardPreview(document);
} }
function movePermissionWizard(wizard, targetStep) {
const currentStep = Number(wizard.dataset.currentStep || '1');
if (targetStep <= currentStep) {
clearPermissionWizardValidation(wizard);
activatePermissionWizardStep(wizard, targetStep);
return true;
}
for (let step = 1; step < targetStep; step += 1) {
const message = validatePermissionWizardStep(wizard, step);
if (message) {
activatePermissionWizardStep(wizard, step);
showPermissionWizardValidation(wizard, message);
focusPermissionWizardStep(wizard, step);
return false;
}
}
clearPermissionWizardValidation(wizard);
activatePermissionWizardStep(wizard, targetStep);
return true;
}
function initPermissionWizard() { function initPermissionWizard() {
const wizard = document.querySelector('[data-permission-wizard]'); const wizard = document.querySelector('[data-permission-wizard]');
if (!wizard) { if (!wizard) {
@@ -845,16 +962,34 @@ function initPermissionWizard() {
} }
wizard.dataset.currentStep = wizard.dataset.currentStep || '1'; wizard.dataset.currentStep = wizard.dataset.currentStep || '1';
wizard.querySelector('[data-wizard-prev]')?.addEventListener('click', () => { wizard.querySelector('[data-wizard-prev]')?.addEventListener('click', () => {
activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1') - 1); movePermissionWizard(wizard, Number(wizard.dataset.currentStep || '1') - 1);
}); });
wizard.querySelector('[data-wizard-next]')?.addEventListener('click', () => { wizard.querySelector('[data-wizard-next]')?.addEventListener('click', () => {
activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1') + 1); movePermissionWizard(wizard, Number(wizard.dataset.currentStep || '1') + 1);
}); });
document.querySelectorAll('[data-wizard-target]').forEach((button) => { permissionWizardIndicators(wizard).forEach((button) => {
button.addEventListener('click', () => activatePermissionWizardStep(wizard, Number(button.dataset.wizardTarget))); button.addEventListener('click', () => movePermissionWizard(wizard, Number(button.dataset.wizardTarget)));
});
wizard.addEventListener('input', () => {
clearPermissionWizardValidation(wizard);
updatePermissionWizardPreview(document);
});
wizard.addEventListener('change', () => {
clearPermissionWizardValidation(wizard);
updatePermissionWizardPreview(document);
});
wizard.addEventListener('submit', (event) => {
for (let step = 1; step <= 3; step += 1) {
const message = validatePermissionWizardStep(wizard, step);
if (message) {
event.preventDefault();
activatePermissionWizardStep(wizard, step);
showPermissionWizardValidation(wizard, message);
focusPermissionWizardStep(wizard, step);
return;
}
}
}); });
wizard.addEventListener('input', () => updatePermissionWizardPreview(document));
wizard.addEventListener('change', () => updatePermissionWizardPreview(document));
activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1')); activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1'));
} }

View File

@@ -20,7 +20,10 @@
<section class="content-band"> <section class="content-band">
<div class="section-heading"> <div class="section-heading">
<h2>권한 추가 Wizard</h2> <div>
<h2>새 권한 규칙</h2>
<p class="section-description">저장 전 적용 대상과 행 범위를 검토한 뒤 VPD 권한을 반영합니다.</p>
</div>
<div class="wizard-progress" aria-label="권한 추가 단계"> <div class="wizard-progress" aria-label="권한 추가 단계">
<button class="wizard-step-indicator active" type="button" data-wizard-target="1">1 역할</button> <button class="wizard-step-indicator active" type="button" data-wizard-target="1">1 역할</button>
<button class="wizard-step-indicator" type="button" data-wizard-target="2">2 객체</button> <button class="wizard-step-indicator" type="button" data-wizard-target="2">2 객체</button>
@@ -31,6 +34,7 @@
</div> </div>
<form method="post" action="/permissions" class="permission-wizard" data-permission-wizard> <form method="post" action="/permissions" class="permission-wizard" data-permission-wizard>
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<div class="wizard-validation" data-wizard-validation role="alert" hidden></div>
<aside class="wizard-summary" aria-live="polite"> <aside class="wizard-summary" aria-live="polite">
<div> <div>
<span>역할</span> <span>역할</span>
@@ -48,6 +52,14 @@
<span>행 규칙</span> <span>행 규칙</span>
<strong data-wizard-summary="rules">ALL</strong> <strong data-wizard-summary="rules">ALL</strong>
</div> </div>
<div>
<span>적용 대상</span>
<strong data-wizard-summary="affected">역할 선택 후 확인</strong>
</div>
<div>
<span>저장 상태</span>
<strong data-wizard-summary="readiness">역할과 객체를 선택하세요</strong>
</div>
</aside> </aside>
<div class="wizard-panel active" data-wizard-step="1"> <div class="wizard-panel active" data-wizard-step="1">
@@ -61,6 +73,7 @@
<label> <label>
역할 역할
<select class="form-select" name="roleId" required> <select class="form-select" name="roleId" required>
<option value="" selected disabled>권한을 적용할 역할 선택</option>
<option th:each="role : ${roles}" <option th:each="role : ${roles}"
th:value="${role.roleId()}" th:value="${role.roleId()}"
th:text="${role.roleName()}" th:text="${role.roleName()}"
@@ -95,6 +108,7 @@
<label> <label>
보호 객체 보호 객체
<select class="form-select" name="objectRef" required> <select class="form-select" name="objectRef" required>
<option value="" selected disabled>권한을 적용할 보호 객체 선택</option>
<optgroup label="등록된 보호 객체"> <optgroup label="등록된 보호 객체">
<option th:each="object : ${objects}" <option th:each="object : ${objects}"
th:value="${'protected:' + object.objectId()}" th:value="${'protected:' + object.objectId()}"
@@ -142,6 +156,7 @@
</label> </label>
<div> <div>
<div class="field-block-title">행 규칙</div> <div class="field-block-title">행 규칙</div>
<p class="rule-safety-note"><strong>주의:</strong> <code>ALL</code>은 전체 행을 뜻합니다. <code>ALL</code>과 다른 조건 규칙은 한 권한에 함께 저장할 수 없습니다.</p>
<div id="rowRuleList" class="rule-list"> <div id="rowRuleList" class="rule-list">
<div class="rule-row"> <div class="rule-row">
<select class="form-select rule-column-select" name="ruleColumn"> <select class="form-select rule-column-select" name="ruleColumn">
@@ -216,6 +231,8 @@
<div><dt>VPD predicate 예상</dt><dd data-preview="predicatePreview">-</dd></div> <div><dt>VPD predicate 예상</dt><dd data-preview="predicatePreview">-</dd></div>
<div><dt>권한별 컬럼 마스킹</dt><dd data-preview="columnPolicy">-</dd></div> <div><dt>권한별 컬럼 마스킹</dt><dd data-preview="columnPolicy">-</dd></div>
<div><dt>NULL 처리 예상</dt><dd data-preview="nullPolicy">-</dd></div> <div><dt>NULL 처리 예상</dt><dd data-preview="nullPolicy">-</dd></div>
<div class="policy-preview-emphasis"><dt>저장 영향</dt><dd data-preview="saveGuard">-</dd></div>
<div><dt>되돌리기</dt><dd>저장 후 아래 권한 목록에서 삭제할 수 있습니다. 이 객체의 마지막 권한을 삭제하면 보호 객체가 비활성화될 수 있습니다.</dd></div>
</dl> </dl>
</div> </div>
</div> </div>

View File

@@ -85,11 +85,20 @@ class GuidedFlowTemplateTest {
@Test @Test
void permissionWizardExplainsTagRulesAndOrSemantics() throws IOException { void permissionWizardExplainsTagRulesAndOrSemantics() throws IOException {
String html = template("permissions.html"); String html = template("permissions.html");
String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js"));
assertThat(html) assertThat(html)
.contains("value=\"TAG\">특정 기술 태그") .contains("value=\"TAG\">특정 기술 태그")
.contains("TECH_TAG") .contains("TECH_TAG")
.contains("태그를 여러 개 추가하면"); .contains("태그를 여러 개 추가하면")
.contains("data-wizard-validation")
.contains("다른 조건 규칙은 한 권한에 함께 저장할 수 없습니다")
.contains("저장 영향")
.contains("되돌리기");
assertThat(javascript)
.contains("validatePermissionWizardStep")
.contains("ALL 규칙은 다른 조건 규칙과 함께 저장할 수 없습니다")
.contains("movePermissionWizard");
} }
@Test @Test

View File

@@ -0,0 +1,58 @@
package com.cloudhandson.vpdbackoffice.web;
import static org.assertj.core.api.Assertions.assertThat;
import com.cloudhandson.vpdbackoffice.domain.permission.AppRole;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.DatabaseObjectOption;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.templateresolver.FileTemplateResolver;
class PermissionTemplateRenderTest {
@Test
void rendersThePermissionSafetyReviewWithLayoutFragments() {
var resolver = new FileTemplateResolver();
resolver.setPrefix(Path.of("src/main/resources/templates").toAbsolutePath() + "/");
resolver.setSuffix(".html");
resolver.setTemplateMode("HTML");
resolver.setCacheable(false);
var engine = new SpringTemplateEngine();
engine.setTemplateResolver(resolver);
var role = new AppRole(30L, "ALL_DOC_ROLE", "전체 문서 역할", "CONFIDENTIAL");
var object = new ProtectedObject(3L, "ADMIN", "CB_VECTOR_SEARCH_DOCUMENTS", "vector/search", "Y");
var context = new Context(Locale.KOREAN);
context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token"));
context.setVariable("roles", List.of(role));
context.setVariable("objects", List.of(object));
context.setVariable("columnsByObject", Map.of(3L, List.of("CHUNK_TEXT", "TECH_TAG")));
context.setVariable("maskableColumnsByObject", Map.of(3L, List.of("CHUNK_TEXT")));
context.setVariable("maskableColumnLabelsByObject", Map.of(3L, List.of("CHUNK_TEXT [CONFIDENTIAL/NULLIFY]")));
context.setVariable("directUsersByRole", Map.of(30L, List.of("김어드민")));
context.setVariable("groupsByRole", Map.of(30L, List.of("OPS / 운영")));
context.setVariable("groupUsersByRole", Map.of(30L, List.of("박파이넨스")));
context.setVariable("dbObjects", List.of(new DatabaseObjectOption("ADMIN", "BOARD_POSTS", "TABLE")));
context.setVariable("permissions", List.of());
context.setVariable("lastPermissionByPermissionId", Map.of());
String rendered = engine.process("permissions", context);
assertThat(rendered)
.contains("권한을 적용할 역할 선택")
.contains("권한을 적용할 보호 객체 선택")
.contains("data-wizard-validation")
.contains("저장 영향")
.contains("되돌리기")
.contains("김어드민");
}
private record CsrfFixture(String parameterName, String token) {
}
}