feat: record DDS protection evidence
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
package com.cloudhandson.ddsbackoffice.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** Immutable fingerprint captured after a DDS grant publication or service baseline. */
|
||||
public record DdsPublishedProtectionSpec(
|
||||
String path,
|
||||
String subjectKey,
|
||||
String fingerprint,
|
||||
Instant publishedAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.cloudhandson.ddsbackoffice.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** Aggregate result of the latest deterministic DDS validation run. */
|
||||
public record DdsValidationEvidence(
|
||||
String path,
|
||||
String subjectKey,
|
||||
String outcome,
|
||||
int fixtureCount,
|
||||
int failedCount,
|
||||
Instant validatedAt
|
||||
) {
|
||||
|
||||
public boolean passed() {
|
||||
return "PASSED".equals(outcome);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.cloudhandson.ddsbackoffice.domain;
|
||||
|
||||
/** A deterministic document-visibility fixture; it never includes a bearer secret. */
|
||||
public record DdsValidationFixture(
|
||||
String fixtureId,
|
||||
String path,
|
||||
String subjectKey,
|
||||
long applicationUserId,
|
||||
String documentId,
|
||||
boolean expectedVisible,
|
||||
String description
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.cloudhandson.ddsbackoffice.domain;
|
||||
|
||||
/** Safe summary returned to the administrator after a fixture run. */
|
||||
public record DdsValidationRunResult(
|
||||
String path,
|
||||
int totalCount,
|
||||
int passedCount,
|
||||
int failedCount,
|
||||
String message
|
||||
) {
|
||||
|
||||
public boolean passed() {
|
||||
return totalCount > 0 && failedCount == 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.cloudhandson.ddsbackoffice.service;
|
||||
|
||||
import com.cloudhandson.ddsbackoffice.domain.DdsPublishedProtectionSpec;
|
||||
import com.cloudhandson.ddsbackoffice.domain.DdsValidationEvidence;
|
||||
import com.cloudhandson.ddsbackoffice.domain.DdsValidationFixture;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Persistence boundary for immutable publication fingerprints and validation evidence. */
|
||||
public interface DdsProtectionEvidenceStore {
|
||||
|
||||
Optional<DdsPublishedProtectionSpec> latestPublished(String path, String subjectKey);
|
||||
|
||||
Optional<DdsValidationEvidence> latestValidation(String path, String subjectKey);
|
||||
|
||||
List<DdsValidationFixture> activeFixtures(String path);
|
||||
|
||||
void recordPublished(DdsPublishedProtectionSpec spec, String actor, String note);
|
||||
|
||||
void recordValidation(String runId, String fixtureId, String path, String subjectKey,
|
||||
boolean expectedVisible, boolean actualVisible, String fingerprint);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.cloudhandson.ddsbackoffice.service;
|
||||
|
||||
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
||||
import com.cloudhandson.ddsbackoffice.domain.DdsGrantPlan;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
/** Canonical, non-secret fingerprints used for publication/validation freshness checks. */
|
||||
public final class DdsProtectionFingerprint {
|
||||
|
||||
private DdsProtectionFingerprint() {
|
||||
}
|
||||
|
||||
public static String service(DdsProperties properties) {
|
||||
return sha256("SERVICE|" + properties.vectorObject() + "|" + properties.token().dataRole()
|
||||
+ "|DDS_DEMO_TOKEN_VECTOR_GRANT|ADMIN.CB_DDS_VECTOR_TAG_ALLOWED");
|
||||
}
|
||||
|
||||
public static String direct(DdsGrantPlan plan) {
|
||||
return sha256("DIRECT|" + plan.ddsObject() + "|" + plan.grantName() + "|" + plan.dataRole()
|
||||
+ "|" + value(plan.predicate()) + "|" + value(plan.excludedColumns()) + "|" + plan.publishable());
|
||||
}
|
||||
|
||||
private static String value(String input) {
|
||||
return input == null ? "" : input.trim().replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
private static String sha256(String input) {
|
||||
try {
|
||||
byte[] bytes = MessageDigest.getInstance("SHA-256").digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder result = new StringBuilder(bytes.length * 2);
|
||||
for (byte value : bytes) {
|
||||
result.append(String.format("%02X", value));
|
||||
}
|
||||
return result.toString();
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256을 사용할 수 없습니다.", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.cloudhandson.ddsbackoffice.service;
|
||||
|
||||
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
||||
import com.cloudhandson.ddsbackoffice.domain.DdsPublishedProtectionSpec;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Idempotent in-console installer for the evidence schema and non-secret fixtures. */
|
||||
@Service
|
||||
public class DdsProtectionSchemaService {
|
||||
private final JdbcTemplate jdbc;
|
||||
private final DdsProperties properties;
|
||||
private final DdsProtectionEvidenceStore evidence;
|
||||
|
||||
public DdsProtectionSchemaService(JdbcTemplate jdbc, DdsProperties properties, DdsProtectionEvidenceStore evidence) {
|
||||
this.jdbc = jdbc; this.properties = properties; this.evidence = evidence;
|
||||
}
|
||||
|
||||
public void initialize() {
|
||||
ensure("CREATE SEQUENCE cb_dds_protection_snapshot_seq START WITH 1 INCREMENT BY 1 NOCACHE");
|
||||
ensure("CREATE SEQUENCE cb_dds_validation_evidence_seq START WITH 1 INCREMENT BY 1 NOCACHE");
|
||||
ensure("CREATE TABLE cb_dds_protection_snapshot (snapshot_id NUMBER PRIMARY KEY, object_name VARCHAR2(261) NOT NULL, enforcement_path VARCHAR2(20) NOT NULL, subject_key VARCHAR2(100) NOT NULL, fingerprint VARCHAR2(64) NOT NULL, actor VARCHAR2(100) NOT NULL, note VARCHAR2(500), published_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL)");
|
||||
ensure("CREATE TABLE cb_dds_validation_fixture (fixture_id VARCHAR2(100) PRIMARY KEY, enforcement_path VARCHAR2(20) NOT NULL, subject_key VARCHAR2(100) NOT NULL, application_user_id NUMBER DEFAULT 0 NOT NULL, document_id VARCHAR2(200) NOT NULL, expected_visible CHAR(1) NOT NULL, description VARCHAR2(500) NOT NULL, active_yn CHAR(1) DEFAULT 'Y' NOT NULL, fixture_version VARCHAR2(40) DEFAULT 'v1' NOT NULL)");
|
||||
ensure("CREATE TABLE cb_dds_validation_evidence (evidence_id NUMBER PRIMARY KEY, run_id VARCHAR2(36) NOT NULL, fixture_id VARCHAR2(100) NOT NULL, enforcement_path VARCHAR2(20) NOT NULL, subject_key VARCHAR2(100) NOT NULL, expected_visible CHAR(1) NOT NULL, actual_visible CHAR(1) NOT NULL, outcome VARCHAR2(20) NOT NULL, fingerprint VARCHAR2(64) NOT NULL, created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL)");
|
||||
jdbc.update("MERGE INTO cb_app_user t USING (SELECT 9904 id FROM dual) s ON (t.user_id=s.id) WHEN NOT MATCHED THEN INSERT (user_id,user_name,employee_no,dept_code,can_read_contents,active) VALUES (9904,'dds_fixture_agent','E9904','DDS','N','Y')");
|
||||
jdbc.update("MERGE INTO cb_app_role t USING (SELECT 9904 id FROM dual) s ON (t.role_id=s.id) WHEN NOT MATCHED THEN INSERT (role_id,role_name,max_sensitivity_level) VALUES (9904,'DDS_FIXTURE_ALLOW_ROLE','INTERNAL')");
|
||||
jdbc.update("MERGE INTO cb_user_role t USING (SELECT 9904 u,9904 r FROM dual) s ON (t.user_id=s.u AND t.role_id=s.r) WHEN NOT MATCHED THEN INSERT (user_id,role_id) VALUES (9904,9904)");
|
||||
jdbc.update("MERGE INTO cb_permission t USING (SELECT 9904 id FROM dual) s ON (t.perm_id=s.id) WHEN NOT MATCHED THEN INSERT (perm_id,role_id,target_name,action_name,permission_effect) VALUES (9904,9904,'CB_VECTOR_SEARCH_DOCUMENTS','SELECT','ALLOW')");
|
||||
jdbc.update("MERGE INTO cb_permission_rule t USING (SELECT 9904 id FROM dual) s ON (t.rule_id=s.id) WHEN NOT MATCHED THEN INSERT (rule_id,perm_id,rule_column,rule_type,rule_value) VALUES (9904,9904,'TECH_TAG','TAG','DDS_VERIFY_ALLOW')");
|
||||
seedDocument(29040, "dds-fixture-service-allow", "DDS_VERIFY_ALLOW", "DDS verification allow fixture");
|
||||
seedDocument(29041, "dds-fixture-service-deny", "DDS_VERIFY_DENY", "DDS verification deny fixture");
|
||||
fixture("SERVICE_ALLOW", "SERVICE", "service", 9904, "dds-fixture-service-allow", "Y", "Temporary token sees allowed tag");
|
||||
fixture("SERVICE_DENY", "SERVICE", "service", 9904, "dds-fixture-service-deny", "N", "Temporary token cannot see denied tag");
|
||||
fixture("DIRECT_ALLOW", "DIRECT", "both", 0, "knowledge-002", "Y", "Combined DDS profile sees direct fixture");
|
||||
fixture("DIRECT_DENY", "DIRECT", "none", 0, "knowledge-002", "N", "Default deny DDS profile cannot see fixture");
|
||||
if (evidence.latestPublished("SERVICE", "service").isEmpty()) {
|
||||
evidence.recordPublished(new DdsPublishedProtectionSpec("SERVICE", "service", DdsProtectionFingerprint.service(properties), java.time.Instant.now()), "DDS setup", "Initial token-service baseline");
|
||||
}
|
||||
}
|
||||
|
||||
private void seedDocument(int chunkId, String documentId, String tag, String title) {
|
||||
jdbc.update("INSERT INTO cb_vector_document_chunk (chunk_id,document_id,chunk_no,title,chunk_text,source_uri,embedding) SELECT ?,?,1,?,TO_CLOB(?),'fixture://dds',TO_VECTOR('[0.11,0.22,0.33,0.44]') FROM dual WHERE NOT EXISTS (SELECT 1 FROM cb_vector_document_chunk WHERE chunk_id=?)", chunkId, documentId, title, title, chunkId);
|
||||
jdbc.update("MERGE INTO cb_vector_document_tag t USING (SELECT ? chunk_id, ? tech_tag FROM dual) s ON (t.chunk_id=s.chunk_id AND t.tech_tag=s.tech_tag) WHEN NOT MATCHED THEN INSERT (chunk_id,tech_tag) VALUES (s.chunk_id,s.tech_tag)", chunkId, tag);
|
||||
}
|
||||
|
||||
private void fixture(String id, String path, String subject, int userId, String documentId, String visible, String description) {
|
||||
jdbc.update("MERGE INTO cb_dds_validation_fixture t USING (SELECT ? id FROM dual) s ON (t.fixture_id=s.id) WHEN MATCHED THEN UPDATE SET active_yn='Y' WHEN NOT MATCHED THEN INSERT (fixture_id,enforcement_path,subject_key,application_user_id,document_id,expected_visible,description) VALUES (?,?,?,?,?,?,?)", id, id, path, subject, userId, documentId, visible, description);
|
||||
}
|
||||
|
||||
private void ensure(String sql) {
|
||||
try { jdbc.execute(sql); } catch (DataAccessException exception) {
|
||||
if (!exception.getMessage().contains("ORA-00955")) throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,14 @@ 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.DdsPublishedProtectionSpec;
|
||||
import com.cloudhandson.ddsbackoffice.domain.DdsProtectionOverview;
|
||||
import com.cloudhandson.ddsbackoffice.domain.DdsProtectionPathStatus;
|
||||
import com.cloudhandson.ddsbackoffice.domain.DdsValidationEvidence;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -23,15 +27,18 @@ public class DdsProtectionStatusService {
|
||||
private final DdsProperties properties;
|
||||
private final DdsGrantInventory inventory;
|
||||
private final DdsProvisionPlanProvider planProvider;
|
||||
private final DdsProtectionEvidenceStore evidenceStore;
|
||||
|
||||
public DdsProtectionStatusService(
|
||||
DdsProperties properties,
|
||||
DdsGrantInventory inventory,
|
||||
DdsProvisionPlanProvider planProvider
|
||||
DdsProvisionPlanProvider planProvider,
|
||||
DdsProtectionEvidenceStore evidenceStore
|
||||
) {
|
||||
this.properties = properties;
|
||||
this.inventory = inventory;
|
||||
this.planProvider = planProvider;
|
||||
this.evidenceStore = evidenceStore;
|
||||
}
|
||||
|
||||
public DdsProtectionOverview overview() {
|
||||
@@ -75,13 +82,14 @@ public class DdsProtectionStatusService {
|
||||
);
|
||||
}
|
||||
|
||||
Verification verification = verification("SERVICE", "service", servicePublished());
|
||||
return status(
|
||||
"service", "토큰 기반 서비스 경로", "기술 사용자 · " + token.username(), false,
|
||||
"재검증 필요", "warning", "Grant 존재는 확인했지만 최근 권한 결과 검증 기록이 없습니다.",
|
||||
verification.primaryLabel(), verification.primaryTone(), verification.primaryDescription(),
|
||||
"일부 확인", partialObservationDetail(snapshot),
|
||||
"선언 관측", "Grant 이름·보호 객체·DATA ROLE이 catalog에서 확인되었습니다.",
|
||||
"검증 기록 없음", "허용·거부 TAG fixture를 사용해 Bearer 토큰 검색을 실행하세요.", snapshot,
|
||||
"토큰 권한으로 검색", "/vector-knowledge"
|
||||
serviceSynchronizationLabel(), serviceSynchronizationDetail(),
|
||||
verification.label(), verification.detail(), snapshot,
|
||||
verification.actionLabel(), verification.actionHref()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,6 +141,19 @@ public class DdsProtectionStatusService {
|
||||
"권한 반영 미리보기", "/dds-provision"
|
||||
);
|
||||
}
|
||||
Optional<DdsPublishedProtectionSpec> published = published("DIRECT", plan.userKey());
|
||||
boolean sourceChanged = published.isPresent()
|
||||
&& !published.get().fingerprint().equalsIgnoreCase(DdsProtectionFingerprint.direct(plan));
|
||||
if (sourceChanged) {
|
||||
return status(
|
||||
"direct-" + plan.userKey(), "DDS END USER 직접 비교", subject, true,
|
||||
"조치 필요", "danger", "권한 규칙이 마지막 DDS 게시 뒤 변경되었습니다.",
|
||||
"일부 확인", partialObservationDetail(snapshot),
|
||||
"반영 필요", "현재 권한 계획이 마지막 게시 기준과 다릅니다.",
|
||||
"검증 만료", "새 권한을 게시한 뒤 fixture 검증을 다시 실행하세요.", snapshot,
|
||||
"권한 반영 미리보기", "/dds-provision"
|
||||
);
|
||||
}
|
||||
if (!plan.publishable() && actualGrant) {
|
||||
return status(
|
||||
"direct-" + plan.userKey(), "DDS END USER 직접 비교", subject, true,
|
||||
@@ -153,13 +174,16 @@ public class DdsProtectionStatusService {
|
||||
"직접 비교 실행", "/vector-knowledge"
|
||||
);
|
||||
}
|
||||
Verification verification = verification("DIRECT", plan.userKey(), published);
|
||||
return status(
|
||||
"direct-" + plan.userKey(), "DDS END USER 직접 비교", subject, true,
|
||||
"재검증 필요", "warning", "현재 권한 계획과 같은 이름의 Grant가 관측되었습니다.",
|
||||
verification.primaryLabel(), verification.primaryTone(), verification.primaryDescription(),
|
||||
"일부 확인", partialObservationDetail(snapshot),
|
||||
"선언 관측", "Grant 이름·보호 객체·DATA ROLE이 catalog에서 확인되었습니다.",
|
||||
"검증 기록 없음", "직접 비교 fixture로 허용·거부 결과를 확인하세요.", snapshot,
|
||||
"직접 비교 실행", "/vector-knowledge"
|
||||
published.isEmpty() ? "게시 기준 미등록" : "선언 관측",
|
||||
published.isEmpty() ? "다음 DDS 권한 반영 시 immutable 게시 기준을 기록합니다."
|
||||
: "Grant 이름·보호 객체·DATA ROLE이 catalog에서 확인되었습니다.",
|
||||
verification.label(), verification.detail(), snapshot,
|
||||
verification.actionLabel(), verification.actionHref()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -211,4 +235,81 @@ public class DdsProtectionStatusService {
|
||||
return snapshot.grants().size() + "개 Grant의 이름·보호 객체·DATA ROLE만 확인했습니다. "
|
||||
+ "predicate와 컬럼 범위는 이 화면에서 비교하지 않습니다.";
|
||||
}
|
||||
|
||||
private Optional<DdsPublishedProtectionSpec> servicePublished() {
|
||||
return published("SERVICE", "service");
|
||||
}
|
||||
|
||||
private Optional<DdsPublishedProtectionSpec> published(String path, String subject) {
|
||||
try {
|
||||
return evidenceStore.latestPublished(path, subject);
|
||||
} catch (DataAccessException exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private String serviceSynchronizationLabel() {
|
||||
Optional<DdsPublishedProtectionSpec> published = servicePublished();
|
||||
if (published.isEmpty()) {
|
||||
return "게시 기준 미등록";
|
||||
}
|
||||
return published.get().fingerprint().equalsIgnoreCase(DdsProtectionFingerprint.service(properties))
|
||||
? "선언 기준 일치" : "기준 갱신 필요";
|
||||
}
|
||||
|
||||
private String serviceSynchronizationDetail() {
|
||||
return "Grant 이름·보호 객체·DATA ROLE이 catalog에서 확인되었습니다."
|
||||
+ (servicePublished().isEmpty() ? " 다음 기준 등록 시 immutable fingerprint를 남깁니다." : "");
|
||||
}
|
||||
|
||||
private Verification verification(
|
||||
String path,
|
||||
String subject,
|
||||
Optional<DdsPublishedProtectionSpec> published
|
||||
) {
|
||||
Optional<DdsValidationEvidence> evidence;
|
||||
try {
|
||||
evidence = evidenceStore.latestValidation(path, subject);
|
||||
} catch (DataAccessException exception) {
|
||||
evidence = Optional.empty();
|
||||
}
|
||||
if (evidence.isEmpty()) {
|
||||
return new Verification(
|
||||
"검증 기록 없음", "결정적 허용·거부 fixture를 아직 실행하지 않았습니다.",
|
||||
"재검증 필요", "warning", "Grant 존재는 확인했지만 최근 권한 결과 검증 기록이 없습니다.",
|
||||
"보호 검증 실행", "SERVICE".equals(path) ? "/dds-protection#service-verify" : "/dds-protection/direct"
|
||||
);
|
||||
}
|
||||
DdsValidationEvidence latest = evidence.get();
|
||||
if (!latest.passed()) {
|
||||
return new Verification(
|
||||
"검증 실패", latest.failedCount() + "개 fixture가 기대 결과와 달랐습니다.",
|
||||
"조치 필요", "danger", "최근 결정적 권한 검증이 실패했습니다.",
|
||||
"권한 반영 미리보기", "/dds-provision"
|
||||
);
|
||||
}
|
||||
if (published.isPresent() && latest.validatedAt().isBefore(published.get().publishedAt())) {
|
||||
return new Verification(
|
||||
"검증 만료", "마지막 권한 게시 뒤 fixture 검증이 필요합니다.",
|
||||
"재검증 필요", "warning", "최근 게시 이후 검증 기록이 없습니다.",
|
||||
"보호 검증 실행", "SERVICE".equals(path) ? "/dds-protection#service-verify" : "/dds-protection/direct"
|
||||
);
|
||||
}
|
||||
return new Verification(
|
||||
"검증 통과", latest.fixtureCount() + "개 결정적 fixture가 기대 결과와 일치했습니다.",
|
||||
"검증 통과", "success", "최근 fixture 검증이 권한 결과와 일치했습니다.",
|
||||
"보호 검증 실행", "SERVICE".equals(path) ? "/dds-protection#service-verify" : "/dds-protection/direct"
|
||||
);
|
||||
}
|
||||
|
||||
private record Verification(
|
||||
String label,
|
||||
String detail,
|
||||
String primaryLabel,
|
||||
String primaryTone,
|
||||
String primaryDescription,
|
||||
String actionLabel,
|
||||
String actionHref
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package com.cloudhandson.ddsbackoffice.service;
|
||||
|
||||
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
||||
import com.cloudhandson.ddsbackoffice.domain.DdsValidationFixture;
|
||||
import com.cloudhandson.ddsbackoffice.domain.DdsValidationRunResult;
|
||||
import com.cloudhandson.ddsbackoffice.domain.DdsPublishedProtectionSpec;
|
||||
import java.security.SecureRandom;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Runs deterministic document-visibility fixtures without retaining bearer secrets. */
|
||||
@Service
|
||||
public class DdsProtectionValidationService {
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private final DdsProperties properties;
|
||||
private final DdsProtectionEvidenceStore evidenceStore;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public DdsProtectionValidationService(
|
||||
DdsProperties properties,
|
||||
DdsProtectionEvidenceStore evidenceStore,
|
||||
JdbcTemplate jdbcTemplate
|
||||
) {
|
||||
this.properties = properties;
|
||||
this.evidenceStore = evidenceStore;
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
public DdsValidationRunResult runServiceFixtures() {
|
||||
List<DdsValidationFixture> fixtures = evidenceStore.activeFixtures("SERVICE");
|
||||
DdsValidationRunResult result = run(fixtures, "SERVICE");
|
||||
if (result.passed()) {
|
||||
evidenceStore.recordPublished(new DdsPublishedProtectionSpec(
|
||||
"SERVICE", "service", DdsProtectionFingerprint.service(properties), java.time.Instant.now()),
|
||||
"DDS fixture validation", "토큰 서비스 경로 기준 확인");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public DdsValidationRunResult runDirectFixtures() {
|
||||
List<DdsValidationFixture> fixtures = evidenceStore.activeFixtures("DIRECT");
|
||||
return run(fixtures, "DIRECT");
|
||||
}
|
||||
|
||||
private DdsValidationRunResult run(List<DdsValidationFixture> fixtures, String path) {
|
||||
if (fixtures.isEmpty()) {
|
||||
return new DdsValidationRunResult(path, 0, 0, 0,
|
||||
"활성 검증 fixture가 없습니다. DDS 검증 데이터 설정을 확인하세요.");
|
||||
}
|
||||
String runId = UUID.randomUUID().toString();
|
||||
int passed = 0;
|
||||
for (DdsValidationFixture fixture : fixtures) {
|
||||
FixtureEvaluation evaluation = "SERVICE".equals(path)
|
||||
? evaluateService(fixture) : evaluateDirect(fixture);
|
||||
boolean actualForEvidence = evaluation.completed()
|
||||
? evaluation.visible() : !fixture.expectedVisible();
|
||||
evidenceStore.recordValidation(
|
||||
runId, fixture.fixtureId(), path, fixture.subjectKey(), fixture.expectedVisible(),
|
||||
actualForEvidence, fingerprint(path, fixture.subjectKey())
|
||||
);
|
||||
if (evaluation.completed() && evaluation.visible() == fixture.expectedVisible()) {
|
||||
passed++;
|
||||
}
|
||||
}
|
||||
int failed = fixtures.size() - passed;
|
||||
return new DdsValidationRunResult(
|
||||
path, fixtures.size(), passed, failed,
|
||||
failed == 0
|
||||
? fixtures.size() + "개 결정적 DDS 검증을 통과했습니다. 임시 Bearer 토큰은 즉시 회수했습니다."
|
||||
: failed + "개 DDS 검증이 기대 결과와 달랐습니다. 보호 상태에서 실패 근거를 확인하세요."
|
||||
);
|
||||
}
|
||||
|
||||
private FixtureEvaluation evaluateService(DdsValidationFixture fixture) {
|
||||
if (!properties.token().configured() || properties.dbUrl().isBlank()) {
|
||||
return FixtureEvaluation.failed();
|
||||
}
|
||||
String temporaryToken = temporaryToken();
|
||||
String prefix = "ddsv_" + UUID.randomUUID().toString().replace("-", "").substring(0, 20);
|
||||
try {
|
||||
issueTemporaryToken(prefix, temporaryToken, fixture.applicationUserId());
|
||||
Properties connectionProperties = connectionProperties(
|
||||
properties.token().username(), properties.token().password());
|
||||
try (Connection connection = DriverManager.getConnection(properties.dbUrl(), connectionProperties)) {
|
||||
connection.setReadOnly(true);
|
||||
try {
|
||||
callContext(connection, "BEGIN ADMIN.CB_AGENT_CTX_PKG.SET_USER_BY_BEARER(?); END;", temporaryToken);
|
||||
return FixtureEvaluation.completed(documentVisible(connection, fixture.documentId()));
|
||||
} finally {
|
||||
clearContext(connection);
|
||||
}
|
||||
}
|
||||
} catch (SQLException | DataAccessException exception) {
|
||||
return FixtureEvaluation.failed();
|
||||
} finally {
|
||||
revokeTemporaryToken(prefix);
|
||||
}
|
||||
}
|
||||
|
||||
private FixtureEvaluation evaluateDirect(DdsValidationFixture fixture) {
|
||||
DdsProperties.User user = properties.users().get(fixture.subjectKey().toLowerCase(Locale.ROOT));
|
||||
if (user == null || !user.configured() || properties.dbUrl().isBlank()) {
|
||||
return FixtureEvaluation.failed();
|
||||
}
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
properties.dbUrl(), connectionProperties(user.username(), user.password()))) {
|
||||
connection.setReadOnly(true);
|
||||
return FixtureEvaluation.completed(documentVisible(connection, fixture.documentId()));
|
||||
} catch (SQLException exception) {
|
||||
// The expected DDS default-deny signal is an inaccessible protected object, not a generic error.
|
||||
if (!fixture.expectedVisible() && hasOracleCode(exception, 942)) {
|
||||
return FixtureEvaluation.completed(false);
|
||||
}
|
||||
return FixtureEvaluation.failed();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean documentVisible(Connection connection, String documentId) throws SQLException {
|
||||
String sql = "SELECT COUNT(*) FROM " + properties.vectorObject() + " WHERE document_id = ?";
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setQueryTimeout(timeoutSeconds(properties.queryTimeout()));
|
||||
statement.setString(1, documentId);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.next() && resultSet.getLong(1) > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void issueTemporaryToken(String prefix, String token, long userId) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO cb_agent_bearer_key (
|
||||
key_id, user_id, key_prefix, key_hash, issued_at, expires_at, revoked_at, active, description
|
||||
) VALUES (
|
||||
cb_agent_bearer_key_seq.NEXTVAL, ?, ?, STANDARD_HASH(?, 'SHA256'),
|
||||
SYSTIMESTAMP, SYSTIMESTAMP + INTERVAL '5' MINUTE, NULL, 'Y', 'DDS fixture validation'
|
||||
)
|
||||
""", userId, prefix, token);
|
||||
}
|
||||
|
||||
private void revokeTemporaryToken(String prefix) {
|
||||
try {
|
||||
jdbcTemplate.update("""
|
||||
UPDATE cb_agent_bearer_key
|
||||
SET active = 'N', revoked_at = SYSTIMESTAMP
|
||||
WHERE key_prefix = ? AND active = 'Y'
|
||||
""", prefix);
|
||||
} catch (DataAccessException ignored) {
|
||||
// The original validation failure is more useful than a best-effort cleanup failure.
|
||||
}
|
||||
}
|
||||
|
||||
private void callContext(Connection connection, String sql, String token) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setQueryTimeout(timeoutSeconds(properties.queryTimeout()));
|
||||
statement.setString(1, token);
|
||||
statement.execute();
|
||||
}
|
||||
}
|
||||
|
||||
private void clearContext(Connection connection) {
|
||||
try (PreparedStatement statement = connection.prepareStatement("BEGIN ADMIN.CB_AGENT_CTX_PKG.CLEAR_USER; END;")) {
|
||||
statement.setQueryTimeout(timeoutSeconds(properties.queryTimeout()));
|
||||
statement.execute();
|
||||
} catch (SQLException ignored) {
|
||||
// Connections are closed after validation. Never hide the validation outcome for cleanup only.
|
||||
}
|
||||
}
|
||||
|
||||
private Properties connectionProperties(String username, String password) {
|
||||
int timeoutSeconds = timeoutSeconds(properties.queryTimeout());
|
||||
Properties values = new Properties();
|
||||
values.setProperty("user", jdbcUsername(username));
|
||||
values.setProperty("password", password);
|
||||
values.setProperty("oracle.net.CONNECT_TIMEOUT", String.valueOf(timeoutSeconds * 1000));
|
||||
values.setProperty("oracle.jdbc.ReadTimeout", String.valueOf(timeoutSeconds * 1000));
|
||||
return values;
|
||||
}
|
||||
|
||||
private static String fingerprint(String path, String subject) {
|
||||
return path + ":" + subject;
|
||||
}
|
||||
|
||||
private static String temporaryToken() {
|
||||
byte[] bytes = new byte[32];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
private static int timeoutSeconds(Duration duration) {
|
||||
return Math.max(1, (int) Math.ceil(duration.toMillis() / 1000.0));
|
||||
}
|
||||
|
||||
private static String jdbcUsername(String username) {
|
||||
String normalized = username == null ? "" : username.trim();
|
||||
if (normalized.length() >= 2 && normalized.startsWith("\"") && normalized.endsWith("\"")) {
|
||||
return normalized;
|
||||
}
|
||||
if (!normalized.isEmpty() && normalized.equals(normalized.toLowerCase(Locale.ROOT))) {
|
||||
return "\"" + normalized.replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static boolean hasOracleCode(SQLException exception, int expected) {
|
||||
SQLException current = exception;
|
||||
while (current != null) {
|
||||
if (Math.abs(current.getErrorCode()) == expected) {
|
||||
return true;
|
||||
}
|
||||
current = current.getNextException();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private record FixtureEvaluation(boolean completed, boolean visible) {
|
||||
static FixtureEvaluation completed(boolean visible) {
|
||||
return new FixtureEvaluation(true, visible);
|
||||
}
|
||||
|
||||
static FixtureEvaluation failed() {
|
||||
return new FixtureEvaluation(false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.cloudhandson.ddsbackoffice.service;
|
||||
|
||||
import com.cloudhandson.ddsbackoffice.domain.DdsPublishedProtectionSpec;
|
||||
import com.cloudhandson.ddsbackoffice.domain.DdsValidationEvidence;
|
||||
import com.cloudhandson.ddsbackoffice.domain.DdsValidationFixture;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class JdbcDdsProtectionEvidenceStore implements DdsProtectionEvidenceStore {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public JdbcDdsProtectionEvidenceStore(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<DdsPublishedProtectionSpec> latestPublished(String path, String subjectKey) {
|
||||
List<DdsPublishedProtectionSpec> rows = jdbcTemplate.query("""
|
||||
SELECT enforcement_path, subject_key, fingerprint, published_at
|
||||
FROM cb_dds_protection_snapshot
|
||||
WHERE enforcement_path = ? AND subject_key = ?
|
||||
ORDER BY published_at DESC, snapshot_id DESC
|
||||
FETCH FIRST 1 ROW ONLY
|
||||
""", (row, ignored) -> new DdsPublishedProtectionSpec(
|
||||
row.getString("enforcement_path"), row.getString("subject_key"),
|
||||
row.getString("fingerprint"), toInstant(row.getTimestamp("published_at"))
|
||||
), path, subjectKey);
|
||||
return rows.stream().findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<DdsValidationEvidence> latestValidation(String path, String subjectKey) {
|
||||
List<DdsValidationEvidence> rows = jdbcTemplate.query("""
|
||||
SELECT enforcement_path, subject_key,
|
||||
CASE WHEN SUM(CASE WHEN outcome = 'FAILED' THEN 1 ELSE 0 END) > 0
|
||||
THEN 'FAILED' ELSE 'PASSED' END AS outcome,
|
||||
COUNT(*) AS fixture_count,
|
||||
SUM(CASE WHEN outcome = 'FAILED' THEN 1 ELSE 0 END) AS failed_count,
|
||||
MAX(created_at) AS validated_at
|
||||
FROM cb_dds_validation_evidence
|
||||
WHERE enforcement_path = ?
|
||||
AND subject_key = ?
|
||||
AND run_id = (
|
||||
SELECT run_id
|
||||
FROM (
|
||||
SELECT run_id
|
||||
FROM cb_dds_validation_evidence
|
||||
WHERE enforcement_path = ? AND subject_key = ?
|
||||
GROUP BY run_id
|
||||
ORDER BY MAX(created_at) DESC
|
||||
)
|
||||
WHERE ROWNUM = 1
|
||||
)
|
||||
GROUP BY enforcement_path, subject_key
|
||||
""", (row, ignored) -> new DdsValidationEvidence(
|
||||
row.getString("enforcement_path"), row.getString("subject_key"), row.getString("outcome"),
|
||||
row.getInt("fixture_count"), row.getInt("failed_count"), toInstant(row.getTimestamp("validated_at"))
|
||||
), path, subjectKey, path, subjectKey);
|
||||
return rows.stream().findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DdsValidationFixture> activeFixtures(String path) {
|
||||
return jdbcTemplate.query("""
|
||||
SELECT fixture_id, enforcement_path, subject_key, application_user_id,
|
||||
document_id, expected_visible, description
|
||||
FROM cb_dds_validation_fixture
|
||||
WHERE enforcement_path = ? AND active_yn = 'Y'
|
||||
ORDER BY fixture_id
|
||||
""", (row, ignored) -> new DdsValidationFixture(
|
||||
row.getString("fixture_id"), row.getString("enforcement_path"), row.getString("subject_key"),
|
||||
row.getLong("application_user_id"), row.getString("document_id"),
|
||||
"Y".equalsIgnoreCase(row.getString("expected_visible")), row.getString("description")
|
||||
), path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void recordPublished(DdsPublishedProtectionSpec spec, String actor, String note) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO cb_dds_protection_snapshot (
|
||||
snapshot_id, object_name, enforcement_path, subject_key, fingerprint,
|
||||
actor, note, published_at
|
||||
) VALUES (
|
||||
cb_dds_protection_snapshot_seq.NEXTVAL, ?, ?, ?, ?, ?, ?, SYSTIMESTAMP
|
||||
)
|
||||
""", spec.subjectKey().startsWith("service") ? "ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS" : "ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS",
|
||||
spec.path(), spec.subjectKey(), spec.fingerprint(), actor, note);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void recordValidation(
|
||||
String runId,
|
||||
String fixtureId,
|
||||
String path,
|
||||
String subjectKey,
|
||||
boolean expectedVisible,
|
||||
boolean actualVisible,
|
||||
String fingerprint
|
||||
) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO cb_dds_validation_evidence (
|
||||
evidence_id, run_id, fixture_id, enforcement_path, subject_key,
|
||||
expected_visible, actual_visible, outcome, fingerprint, created_at
|
||||
) VALUES (
|
||||
cb_dds_validation_evidence_seq.NEXTVAL, ?, ?, ?, ?, ?, ?, ?, ?, SYSTIMESTAMP
|
||||
)
|
||||
""", runId, fixtureId, path, subjectKey,
|
||||
expectedVisible ? "Y" : "N", actualVisible ? "Y" : "N",
|
||||
expectedVisible == actualVisible ? "PASSED" : "FAILED", fingerprint);
|
||||
}
|
||||
|
||||
private static Instant toInstant(Timestamp timestamp) {
|
||||
return timestamp == null ? Instant.EPOCH : timestamp.toInstant();
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@ package com.cloudhandson.ddsbackoffice.web;
|
||||
|
||||
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
||||
import com.cloudhandson.ddsbackoffice.service.DdsProtectionStatusService;
|
||||
import com.cloudhandson.ddsbackoffice.service.DdsProtectionValidationService;
|
||||
import com.cloudhandson.ddsbackoffice.service.DdsProtectionSchemaService;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.servlet.view.RedirectView;
|
||||
|
||||
/** DDS protection status, deliberately separate from the legacy VPD route. */
|
||||
@@ -13,13 +16,19 @@ public class DdsProtectionController {
|
||||
|
||||
private final DdsProperties properties;
|
||||
private final DdsProtectionStatusService protectionStatusService;
|
||||
private final DdsProtectionValidationService validationService;
|
||||
private final DdsProtectionSchemaService schemaService;
|
||||
|
||||
public DdsProtectionController(
|
||||
DdsProperties properties,
|
||||
DdsProtectionStatusService protectionStatusService
|
||||
DdsProtectionStatusService protectionStatusService,
|
||||
DdsProtectionValidationService validationService,
|
||||
DdsProtectionSchemaService schemaService
|
||||
) {
|
||||
this.properties = properties;
|
||||
this.protectionStatusService = protectionStatusService;
|
||||
this.validationService = validationService;
|
||||
this.schemaService = schemaService;
|
||||
}
|
||||
|
||||
@GetMapping("/dds-protection")
|
||||
@@ -35,6 +44,35 @@ public class DdsProtectionController {
|
||||
return "fragments/dds-protection-direct :: directComparison";
|
||||
}
|
||||
|
||||
@PostMapping("/dds-protection/verify/service")
|
||||
public RedirectView verifyService(org.springframework.web.servlet.mvc.support.RedirectAttributes attributes) {
|
||||
try {
|
||||
var result = validationService.runServiceFixtures();
|
||||
attributes.addFlashAttribute(result.passed() ? "successMessage" : "errorMessage", result.message());
|
||||
} catch (RuntimeException exception) {
|
||||
attributes.addFlashAttribute("errorMessage", "DDS 보호 검증을 실행하지 못했습니다. fixture와 DB 설정을 확인하세요.");
|
||||
}
|
||||
return new RedirectView("/dds-protection#service-verify");
|
||||
}
|
||||
|
||||
@PostMapping("/dds-protection/verify/direct")
|
||||
public String verifyDirect(Model model) {
|
||||
validationService.runDirectFixtures();
|
||||
model.addAttribute("directPaths", protectionStatusService.directComparison());
|
||||
return "fragments/dds-protection-direct :: directComparison";
|
||||
}
|
||||
|
||||
@PostMapping("/dds-protection/setup")
|
||||
public RedirectView setup(org.springframework.web.servlet.mvc.support.RedirectAttributes attributes) {
|
||||
try {
|
||||
schemaService.initialize();
|
||||
attributes.addFlashAttribute("successMessage", "DDS 검증 이력과 fixture 구성을 완료했습니다.");
|
||||
} catch (RuntimeException exception) {
|
||||
attributes.addFlashAttribute("errorMessage", "DDS 검증 구성을 완료하지 못했습니다. DB 권한과 선행 DDS 객체를 확인하세요.");
|
||||
}
|
||||
return new RedirectView("/dds-protection");
|
||||
}
|
||||
|
||||
/** Existing bookmarks resolve to the DDS canonical route without retaining VPD in the UI. */
|
||||
@GetMapping("/vpd-policies")
|
||||
public RedirectView legacyPolicies() {
|
||||
|
||||
@@ -2,6 +2,9 @@ package com.cloudhandson.ddsbackoffice.web;
|
||||
|
||||
import com.cloudhandson.ddsbackoffice.domain.DdsProvisioningPlan;
|
||||
import com.cloudhandson.ddsbackoffice.service.DdsGrantPublisher;
|
||||
import com.cloudhandson.ddsbackoffice.service.DdsProtectionEvidenceStore;
|
||||
import com.cloudhandson.ddsbackoffice.service.DdsProtectionFingerprint;
|
||||
import com.cloudhandson.ddsbackoffice.domain.DdsPublishedProtectionSpec;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
@@ -13,9 +16,11 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
public class DdsProvisionController {
|
||||
|
||||
private final DdsGrantPublisher publisher;
|
||||
private final DdsProtectionEvidenceStore evidenceStore;
|
||||
|
||||
public DdsProvisionController(DdsGrantPublisher publisher) {
|
||||
public DdsProvisionController(DdsGrantPublisher publisher, DdsProtectionEvidenceStore evidenceStore) {
|
||||
this.publisher = publisher;
|
||||
this.evidenceStore = evidenceStore;
|
||||
}
|
||||
|
||||
@GetMapping("/dds-provision")
|
||||
@@ -32,6 +37,16 @@ public class DdsProvisionController {
|
||||
"successMessage",
|
||||
plan.publishableCount() + "개 DDS DATA GRANT를 게시했습니다. 권한 없는 대상의 기존 Grant는 회수했습니다."
|
||||
);
|
||||
try {
|
||||
plan.grants().forEach(grant -> evidenceStore.recordPublished(
|
||||
new DdsPublishedProtectionSpec(
|
||||
"DIRECT", grant.userKey(), DdsProtectionFingerprint.direct(grant), java.time.Instant.now()),
|
||||
"DDS Backoffice", "DDS 권한 반영"
|
||||
));
|
||||
} catch (DataAccessException exception) {
|
||||
redirectAttributes.addFlashAttribute("warningMessage",
|
||||
"DDS Grant는 게시됐지만 상태 이력 저장에 실패했습니다. 상태 이력 DB 설정을 확인하세요.");
|
||||
}
|
||||
if (plan.hasWarnings()) {
|
||||
redirectAttributes.addFlashAttribute("warningMessage", String.join(" / ", plan.warnings()));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<div th:fragment="directComparison" class="table-responsive">
|
||||
<form hx-post="/dds-protection/verify/direct" hx-target="#direct-comparison-result" hx-swap="innerHTML" class="mb-3">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<button class="btn btn-sm rw-btn-primary" type="submit">직접 비교 fixture 검증 실행</button>
|
||||
</form>
|
||||
<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>
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
|
||||
<section th:replace="~{fragments/layout :: architectureStrip('protection')}"></section>
|
||||
|
||||
<div class="alert alert-success" th:if="${successMessage}" th:text="${successMessage}"></div>
|
||||
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
|
||||
<form method="post" action="/dds-protection/setup" class="mb-3">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<button class="btn btn-sm rw-btn-secondary" type="submit">DDS 검증 이력·fixture 구성 확인</button>
|
||||
</form>
|
||||
|
||||
<section class="content-band protection-summary" aria-labelledby="service-path-heading">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
@@ -24,7 +31,7 @@
|
||||
<span class="badge text-bg-warning" th:unless="${protection.actionRequiredCount() > 0}">검증 확인 필요</span>
|
||||
</div>
|
||||
|
||||
<article class="protection-status-card"
|
||||
<article id="service-verify" class="protection-status-card"
|
||||
th:classappend="${' status-' + protection.servicePath.primaryTone()}"
|
||||
aria-label="토큰 기반 서비스 경로 보호 상태">
|
||||
<div class="protection-status-topline">
|
||||
@@ -56,8 +63,13 @@
|
||||
|
||||
<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>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<a class="btn rw-btn-secondary" href="/vector-knowledge">지식 검색 열기</a>
|
||||
<form method="post" action="/dds-protection/verify/service" class="d-inline">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<button class="btn rw-btn-primary" type="submit">결정적 보호 검증 실행</button>
|
||||
</form>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user