Compare commits
2 Commits
2f67587b40
...
f162851bc6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f162851bc6 | ||
|
|
1766696ab4 |
@@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
* 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(
|
||||||
|
|||||||
@@ -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;
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 전용 사용자
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 확인이다.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user