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.config.DdsProperties;
|
||||||
import com.cloudhandson.ddsbackoffice.domain.DdsGrantInventorySnapshot;
|
import com.cloudhandson.ddsbackoffice.domain.DdsGrantInventorySnapshot;
|
||||||
import com.cloudhandson.ddsbackoffice.domain.DdsGrantPlan;
|
import com.cloudhandson.ddsbackoffice.domain.DdsGrantPlan;
|
||||||
|
import com.cloudhandson.ddsbackoffice.domain.DdsPublishedProtectionSpec;
|
||||||
import com.cloudhandson.ddsbackoffice.domain.DdsProtectionOverview;
|
import com.cloudhandson.ddsbackoffice.domain.DdsProtectionOverview;
|
||||||
import com.cloudhandson.ddsbackoffice.domain.DdsProtectionPathStatus;
|
import com.cloudhandson.ddsbackoffice.domain.DdsProtectionPathStatus;
|
||||||
|
import com.cloudhandson.ddsbackoffice.domain.DdsValidationEvidence;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.Optional;
|
||||||
import org.springframework.dao.DataAccessException;
|
import org.springframework.dao.DataAccessException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -23,15 +27,18 @@ public class DdsProtectionStatusService {
|
|||||||
private final DdsProperties properties;
|
private final DdsProperties properties;
|
||||||
private final DdsGrantInventory inventory;
|
private final DdsGrantInventory inventory;
|
||||||
private final DdsProvisionPlanProvider planProvider;
|
private final DdsProvisionPlanProvider planProvider;
|
||||||
|
private final DdsProtectionEvidenceStore evidenceStore;
|
||||||
|
|
||||||
public DdsProtectionStatusService(
|
public DdsProtectionStatusService(
|
||||||
DdsProperties properties,
|
DdsProperties properties,
|
||||||
DdsGrantInventory inventory,
|
DdsGrantInventory inventory,
|
||||||
DdsProvisionPlanProvider planProvider
|
DdsProvisionPlanProvider planProvider,
|
||||||
|
DdsProtectionEvidenceStore evidenceStore
|
||||||
) {
|
) {
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
this.inventory = inventory;
|
this.inventory = inventory;
|
||||||
this.planProvider = planProvider;
|
this.planProvider = planProvider;
|
||||||
|
this.evidenceStore = evidenceStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DdsProtectionOverview overview() {
|
public DdsProtectionOverview overview() {
|
||||||
@@ -75,13 +82,14 @@ public class DdsProtectionStatusService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Verification verification = verification("SERVICE", "service", servicePublished());
|
||||||
return status(
|
return status(
|
||||||
"service", "토큰 기반 서비스 경로", "기술 사용자 · " + token.username(), false,
|
"service", "토큰 기반 서비스 경로", "기술 사용자 · " + token.username(), false,
|
||||||
"재검증 필요", "warning", "Grant 존재는 확인했지만 최근 권한 결과 검증 기록이 없습니다.",
|
verification.primaryLabel(), verification.primaryTone(), verification.primaryDescription(),
|
||||||
"일부 확인", partialObservationDetail(snapshot),
|
"일부 확인", partialObservationDetail(snapshot),
|
||||||
"선언 관측", "Grant 이름·보호 객체·DATA ROLE이 catalog에서 확인되었습니다.",
|
serviceSynchronizationLabel(), serviceSynchronizationDetail(),
|
||||||
"검증 기록 없음", "허용·거부 TAG fixture를 사용해 Bearer 토큰 검색을 실행하세요.", snapshot,
|
verification.label(), verification.detail(), snapshot,
|
||||||
"토큰 권한으로 검색", "/vector-knowledge"
|
verification.actionLabel(), verification.actionHref()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +141,19 @@ public class DdsProtectionStatusService {
|
|||||||
"권한 반영 미리보기", "/dds-provision"
|
"권한 반영 미리보기", "/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) {
|
if (!plan.publishable() && actualGrant) {
|
||||||
return status(
|
return status(
|
||||||
"direct-" + plan.userKey(), "DDS END USER 직접 비교", subject, true,
|
"direct-" + plan.userKey(), "DDS END USER 직접 비교", subject, true,
|
||||||
@@ -153,13 +174,16 @@ public class DdsProtectionStatusService {
|
|||||||
"직접 비교 실행", "/vector-knowledge"
|
"직접 비교 실행", "/vector-knowledge"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Verification verification = verification("DIRECT", plan.userKey(), published);
|
||||||
return status(
|
return status(
|
||||||
"direct-" + plan.userKey(), "DDS END USER 직접 비교", subject, true,
|
"direct-" + plan.userKey(), "DDS END USER 직접 비교", subject, true,
|
||||||
"재검증 필요", "warning", "현재 권한 계획과 같은 이름의 Grant가 관측되었습니다.",
|
verification.primaryLabel(), verification.primaryTone(), verification.primaryDescription(),
|
||||||
"일부 확인", partialObservationDetail(snapshot),
|
"일부 확인", partialObservationDetail(snapshot),
|
||||||
"선언 관측", "Grant 이름·보호 객체·DATA ROLE이 catalog에서 확인되었습니다.",
|
published.isEmpty() ? "게시 기준 미등록" : "선언 관측",
|
||||||
"검증 기록 없음", "직접 비교 fixture로 허용·거부 결과를 확인하세요.", snapshot,
|
published.isEmpty() ? "다음 DDS 권한 반영 시 immutable 게시 기준을 기록합니다."
|
||||||
"직접 비교 실행", "/vector-knowledge"
|
: "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만 확인했습니다. "
|
return snapshot.grants().size() + "개 Grant의 이름·보호 객체·DATA ROLE만 확인했습니다. "
|
||||||
+ "predicate와 컬럼 범위는 이 화면에서 비교하지 않습니다.";
|
+ "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.config.DdsProperties;
|
||||||
import com.cloudhandson.ddsbackoffice.service.DdsProtectionStatusService;
|
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.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.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.servlet.view.RedirectView;
|
import org.springframework.web.servlet.view.RedirectView;
|
||||||
|
|
||||||
/** DDS protection status, deliberately separate from the legacy VPD route. */
|
/** DDS protection status, deliberately separate from the legacy VPD route. */
|
||||||
@@ -13,13 +16,19 @@ public class DdsProtectionController {
|
|||||||
|
|
||||||
private final DdsProperties properties;
|
private final DdsProperties properties;
|
||||||
private final DdsProtectionStatusService protectionStatusService;
|
private final DdsProtectionStatusService protectionStatusService;
|
||||||
|
private final DdsProtectionValidationService validationService;
|
||||||
|
private final DdsProtectionSchemaService schemaService;
|
||||||
|
|
||||||
public DdsProtectionController(
|
public DdsProtectionController(
|
||||||
DdsProperties properties,
|
DdsProperties properties,
|
||||||
DdsProtectionStatusService protectionStatusService
|
DdsProtectionStatusService protectionStatusService,
|
||||||
|
DdsProtectionValidationService validationService,
|
||||||
|
DdsProtectionSchemaService schemaService
|
||||||
) {
|
) {
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
this.protectionStatusService = protectionStatusService;
|
this.protectionStatusService = protectionStatusService;
|
||||||
|
this.validationService = validationService;
|
||||||
|
this.schemaService = schemaService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/dds-protection")
|
@GetMapping("/dds-protection")
|
||||||
@@ -35,6 +44,35 @@ public class DdsProtectionController {
|
|||||||
return "fragments/dds-protection-direct :: directComparison";
|
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. */
|
/** Existing bookmarks resolve to the DDS canonical route without retaining VPD in the UI. */
|
||||||
@GetMapping("/vpd-policies")
|
@GetMapping("/vpd-policies")
|
||||||
public RedirectView legacyPolicies() {
|
public RedirectView legacyPolicies() {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package com.cloudhandson.ddsbackoffice.web;
|
|||||||
|
|
||||||
import com.cloudhandson.ddsbackoffice.domain.DdsProvisioningPlan;
|
import com.cloudhandson.ddsbackoffice.domain.DdsProvisioningPlan;
|
||||||
import com.cloudhandson.ddsbackoffice.service.DdsGrantPublisher;
|
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.dao.DataAccessException;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
@@ -13,9 +16,11 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
|||||||
public class DdsProvisionController {
|
public class DdsProvisionController {
|
||||||
|
|
||||||
private final DdsGrantPublisher publisher;
|
private final DdsGrantPublisher publisher;
|
||||||
|
private final DdsProtectionEvidenceStore evidenceStore;
|
||||||
|
|
||||||
public DdsProvisionController(DdsGrantPublisher publisher) {
|
public DdsProvisionController(DdsGrantPublisher publisher, DdsProtectionEvidenceStore evidenceStore) {
|
||||||
this.publisher = publisher;
|
this.publisher = publisher;
|
||||||
|
this.evidenceStore = evidenceStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/dds-provision")
|
@GetMapping("/dds-provision")
|
||||||
@@ -32,6 +37,16 @@ public class DdsProvisionController {
|
|||||||
"successMessage",
|
"successMessage",
|
||||||
plan.publishableCount() + "개 DDS DATA GRANT를 게시했습니다. 권한 없는 대상의 기존 Grant는 회수했습니다."
|
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()) {
|
if (plan.hasWarnings()) {
|
||||||
redirectAttributes.addFlashAttribute("warningMessage", String.join(" / ", plan.warnings()));
|
redirectAttributes.addFlashAttribute("warningMessage", String.join(" / ", plan.warnings()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||||
<body>
|
<body>
|
||||||
<div th:fragment="directComparison" class="table-responsive">
|
<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">
|
<table class="table table-sm align-middle protection-direct-table">
|
||||||
<thead><tr><th>검증 프로필</th><th>보호 상태</th><th>권한 반영</th><th>검색 검증</th><th>조치</th></tr></thead>
|
<thead><tr><th>검증 프로필</th><th>보호 상태</th><th>권한 반영</th><th>검색 검증</th><th>조치</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
|
|
||||||
<section th:replace="~{fragments/layout :: architectureStrip('protection')}"></section>
|
<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">
|
<section class="content-band protection-summary" aria-labelledby="service-path-heading">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
@@ -24,7 +31,7 @@
|
|||||||
<span class="badge text-bg-warning" th:unless="${protection.actionRequiredCount() > 0}">검증 확인 필요</span>
|
<span class="badge text-bg-warning" th:unless="${protection.actionRequiredCount() > 0}">검증 확인 필요</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<article class="protection-status-card"
|
<article id="service-verify" class="protection-status-card"
|
||||||
th:classappend="${' status-' + protection.servicePath.primaryTone()}"
|
th:classappend="${' status-' + protection.servicePath.primaryTone()}"
|
||||||
aria-label="토큰 기반 서비스 경로 보호 상태">
|
aria-label="토큰 기반 서비스 경로 보호 상태">
|
||||||
<div class="protection-status-topline">
|
<div class="protection-status-topline">
|
||||||
@@ -56,8 +63,13 @@
|
|||||||
|
|
||||||
<footer class="protection-status-footer">
|
<footer class="protection-status-footer">
|
||||||
<span th:text="${'마지막 관측 ' + protection.servicePath.observedAtLabel()}">마지막 관측</span>
|
<span th:text="${'마지막 관측 ' + protection.servicePath.observedAtLabel()}">마지막 관측</span>
|
||||||
<a class="btn rw-btn-primary" th:href="${protection.servicePath.actionHref()}"
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
th:text="${protection.servicePath.actionLabel()}">토큰 권한으로 검색</a>
|
<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>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
|||||||
import com.cloudhandson.ddsbackoffice.domain.DdsGrantInventoryEntry;
|
import com.cloudhandson.ddsbackoffice.domain.DdsGrantInventoryEntry;
|
||||||
import com.cloudhandson.ddsbackoffice.domain.DdsGrantInventorySnapshot;
|
import com.cloudhandson.ddsbackoffice.domain.DdsGrantInventorySnapshot;
|
||||||
import com.cloudhandson.ddsbackoffice.domain.DdsGrantPlan;
|
import com.cloudhandson.ddsbackoffice.domain.DdsGrantPlan;
|
||||||
|
import com.cloudhandson.ddsbackoffice.domain.DdsPublishedProtectionSpec;
|
||||||
import com.cloudhandson.ddsbackoffice.domain.DdsProvisioningPlan;
|
import com.cloudhandson.ddsbackoffice.domain.DdsProvisioningPlan;
|
||||||
|
import com.cloudhandson.ddsbackoffice.domain.DdsValidationEvidence;
|
||||||
|
import com.cloudhandson.ddsbackoffice.domain.DdsValidationFixture;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
class DdsProtectionStatusServiceTest {
|
class DdsProtectionStatusServiceTest {
|
||||||
@@ -20,7 +24,8 @@ class DdsProtectionStatusServiceTest {
|
|||||||
DdsGrantInventory inventory = object -> new DdsGrantInventorySnapshot(
|
DdsGrantInventory inventory = object -> new DdsGrantInventorySnapshot(
|
||||||
false, List.of(), Instant.parse("2026-06-30T00:00:00Z"), "catalog 접근 실패"
|
false, List.of(), Instant.parse("2026-06-30T00:00:00Z"), "catalog 접근 실패"
|
||||||
);
|
);
|
||||||
var service = new DdsProtectionStatusService(properties(), inventory, emptyPlanProvider());
|
var service = new DdsProtectionStatusService(
|
||||||
|
properties(), inventory, emptyPlanProvider(), emptyEvidenceStore());
|
||||||
|
|
||||||
var overview = service.overview();
|
var overview = service.overview();
|
||||||
|
|
||||||
@@ -42,7 +47,7 @@ class DdsProtectionStatusServiceTest {
|
|||||||
"ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS", "DDS_DEMO_BOTH_VECTOR_GRANT", "1 = 1", "",
|
"ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS", "DDS_DEMO_BOTH_VECTOR_GRANT", "1 = 1", "",
|
||||||
"CREATE DATA GRANT", true, "게시 가능"
|
"CREATE DATA GRANT", true, "게시 가능"
|
||||||
)), List.of(), 1);
|
)), List.of(), 1);
|
||||||
var service = new DdsProtectionStatusService(properties(), inventory, provider);
|
var service = new DdsProtectionStatusService(properties(), inventory, provider, emptyEvidenceStore());
|
||||||
|
|
||||||
var overview = service.overview();
|
var overview = service.overview();
|
||||||
|
|
||||||
@@ -51,7 +56,7 @@ class DdsProtectionStatusServiceTest {
|
|||||||
var direct = service.directComparison();
|
var direct = service.directComparison();
|
||||||
assertEquals(1, direct.size());
|
assertEquals(1, direct.size());
|
||||||
assertEquals("재검증 필요", direct.getFirst().primaryLabel());
|
assertEquals("재검증 필요", direct.getFirst().primaryLabel());
|
||||||
assertEquals("선언 관측", direct.getFirst().synchronizationLabel());
|
assertEquals("게시 기준 미등록", direct.getFirst().synchronizationLabel());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -67,7 +72,7 @@ class DdsProtectionStatusServiceTest {
|
|||||||
"ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS", "DDS_DEMO_BOTH_VECTOR_GRANT", "", "", "",
|
"ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS", "DDS_DEMO_BOTH_VECTOR_GRANT", "", "", "",
|
||||||
false, "ALLOW 규칙 없음"
|
false, "ALLOW 규칙 없음"
|
||||||
)), List.of(), 1);
|
)), List.of(), 1);
|
||||||
var service = new DdsProtectionStatusService(properties(), inventory, provider);
|
var service = new DdsProtectionStatusService(properties(), inventory, provider, emptyEvidenceStore());
|
||||||
|
|
||||||
var direct = service.directComparison().getFirst();
|
var direct = service.directComparison().getFirst();
|
||||||
|
|
||||||
@@ -89,4 +94,32 @@ class DdsProtectionStatusServiceTest {
|
|||||||
private static DdsProvisionPlanProvider emptyPlanProvider() {
|
private static DdsProvisionPlanProvider emptyPlanProvider() {
|
||||||
return () -> new DdsProvisioningPlan(List.of(), List.of(), 0);
|
return () -> new DdsProvisioningPlan(List.of(), List.of(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static DdsProtectionEvidenceStore emptyEvidenceStore() {
|
||||||
|
return new DdsProtectionEvidenceStore() {
|
||||||
|
@Override
|
||||||
|
public Optional<DdsPublishedProtectionSpec> latestPublished(String path, String subjectKey) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<DdsValidationEvidence> latestValidation(String path, String subjectKey) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<DdsValidationFixture> activeFixtures(String path) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void recordPublished(DdsPublishedProtectionSpec spec, String actor, String note) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void recordValidation(String runId, String fixtureId, String path, String subjectKey,
|
||||||
|
boolean expectedVisible, boolean actualVisible, String fingerprint) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
scripts/setup-dds-protection-evidence.sh
Normal file
17
scripts/setup-dds-protection-evidence.sh
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Apply immutable DDS publication evidence and deterministic validation fixtures.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
if [[ -f /home/opc/apps/vpd-backoffice/.env ]]; then
|
||||||
|
set -a
|
||||||
|
. /home/opc/apps/vpd-backoffice/.env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
: "${ADB_USER:?ADB_USER is required}"
|
||||||
|
: "${ADB_PASSWORD:?ADB_PASSWORD is required}"
|
||||||
|
: "${ADB_TNS:?ADB_TNS is required}"
|
||||||
|
|
||||||
|
sqlplus -S -L "${ADB_USER}/${ADB_PASSWORD}@${ADB_TNS}" "@${ROOT}/sql/adb/40_dds_protection_evidence.sql"
|
||||||
|
echo "DDS protection evidence schema and fixture manifest applied"
|
||||||
211
sql/adb/40_dds_protection_evidence.sql
Normal file
211
sql/adb/40_dds_protection_evidence.sql
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 40_dds_protection_evidence.sql
|
||||||
|
-- Immutable DDS publication fingerprints and deterministic validation fixtures.
|
||||||
|
--
|
||||||
|
-- Prerequisite: 25, 32, 34. Run as ADMIN.
|
||||||
|
-- The script is idempotent and never prints a bearer secret.
|
||||||
|
-- ============================================================
|
||||||
|
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||||
|
SET ECHO ON
|
||||||
|
SET FEEDBACK ON
|
||||||
|
SET DEFINE OFF
|
||||||
|
|
||||||
|
PROMPT === 1. Evidence metadata ===
|
||||||
|
BEGIN
|
||||||
|
EXECUTE IMMEDIATE 'CREATE SEQUENCE cb_dds_protection_snapshot_seq START WITH 1 INCREMENT BY 1 NOCACHE';
|
||||||
|
EXCEPTION WHEN OTHERS THEN IF SQLCODE <> -955 THEN RAISE; END IF; END;
|
||||||
|
/
|
||||||
|
BEGIN
|
||||||
|
EXECUTE IMMEDIATE 'CREATE SEQUENCE cb_dds_validation_evidence_seq START WITH 1 INCREMENT BY 1 NOCACHE';
|
||||||
|
EXCEPTION WHEN OTHERS THEN IF SQLCODE <> -955 THEN RAISE; END IF; END;
|
||||||
|
/
|
||||||
|
BEGIN
|
||||||
|
EXECUTE IMMEDIATE q'[
|
||||||
|
CREATE TABLE cb_dds_protection_snapshot (
|
||||||
|
snapshot_id NUMBER PRIMARY KEY,
|
||||||
|
object_name VARCHAR2(261) NOT NULL,
|
||||||
|
enforcement_path VARCHAR2(20) NOT NULL CHECK (enforcement_path IN ('SERVICE','DIRECT')),
|
||||||
|
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
|
||||||
|
)]';
|
||||||
|
EXCEPTION WHEN OTHERS THEN IF SQLCODE <> -955 THEN RAISE; END IF; END;
|
||||||
|
/
|
||||||
|
BEGIN
|
||||||
|
EXECUTE IMMEDIATE q'[
|
||||||
|
CREATE TABLE cb_dds_validation_fixture (
|
||||||
|
fixture_id VARCHAR2(100) PRIMARY KEY,
|
||||||
|
enforcement_path VARCHAR2(20) NOT NULL CHECK (enforcement_path IN ('SERVICE','DIRECT')),
|
||||||
|
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 CHECK (expected_visible IN ('Y','N')),
|
||||||
|
description VARCHAR2(500) NOT NULL,
|
||||||
|
active_yn CHAR(1) DEFAULT 'Y' NOT NULL CHECK (active_yn IN ('Y','N')),
|
||||||
|
fixture_version VARCHAR2(40) DEFAULT 'v1' NOT NULL
|
||||||
|
)]';
|
||||||
|
EXCEPTION WHEN OTHERS THEN IF SQLCODE <> -955 THEN RAISE; END IF; END;
|
||||||
|
/
|
||||||
|
BEGIN
|
||||||
|
EXECUTE IMMEDIATE q'[
|
||||||
|
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 CHECK (enforcement_path IN ('SERVICE','DIRECT')),
|
||||||
|
subject_key VARCHAR2(100) NOT NULL,
|
||||||
|
expected_visible CHAR(1) NOT NULL CHECK (expected_visible IN ('Y','N')),
|
||||||
|
actual_visible CHAR(1) NOT NULL CHECK (actual_visible IN ('Y','N')),
|
||||||
|
outcome VARCHAR2(20) NOT NULL CHECK (outcome IN ('PASSED','FAILED')),
|
||||||
|
fingerprint VARCHAR2(64) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
|
||||||
|
)]';
|
||||||
|
EXCEPTION WHEN OTHERS THEN IF SQLCODE <> -955 THEN RAISE; END IF; END;
|
||||||
|
/
|
||||||
|
BEGIN
|
||||||
|
EXECUTE IMMEDIATE 'CREATE INDEX cb_dds_snapshot_ix ON cb_dds_protection_snapshot (enforcement_path, subject_key, published_at)';
|
||||||
|
EXCEPTION WHEN OTHERS THEN IF SQLCODE <> -955 THEN RAISE; END IF; END;
|
||||||
|
/
|
||||||
|
BEGIN
|
||||||
|
EXECUTE IMMEDIATE 'CREATE INDEX cb_dds_validation_ix ON cb_dds_validation_evidence (enforcement_path, subject_key, created_at)';
|
||||||
|
EXCEPTION WHEN OTHERS THEN IF SQLCODE <> -955 THEN RAISE; END IF; END;
|
||||||
|
/
|
||||||
|
|
||||||
|
PROMPT === 2. Deterministic SERVICE fixtures ===
|
||||||
|
MERGE INTO cb_app_user target
|
||||||
|
USING (SELECT 9904 user_id, 'dds_fixture_agent' user_name, 'E9904' employee_no,
|
||||||
|
'DDS' dept_code, 'N' can_read_contents, 'Y' active FROM dual) source
|
||||||
|
ON (target.user_id = source.user_id)
|
||||||
|
WHEN MATCHED THEN UPDATE SET target.user_name = source.user_name, target.employee_no = source.employee_no,
|
||||||
|
target.dept_code = source.dept_code, target.can_read_contents = source.can_read_contents, target.active = source.active
|
||||||
|
WHEN NOT MATCHED THEN INSERT (user_id, user_name, employee_no, dept_code, can_read_contents, active)
|
||||||
|
VALUES (source.user_id, source.user_name, source.employee_no, source.dept_code, source.can_read_contents, source.active);
|
||||||
|
|
||||||
|
MERGE INTO cb_app_role target
|
||||||
|
USING (SELECT 9904 role_id, 'DDS_FIXTURE_ALLOW_ROLE' role_name, 'INTERNAL' max_sensitivity_level FROM dual) source
|
||||||
|
ON (target.role_id = source.role_id)
|
||||||
|
WHEN MATCHED THEN UPDATE SET target.role_name = source.role_name, target.max_sensitivity_level = source.max_sensitivity_level
|
||||||
|
WHEN NOT MATCHED THEN INSERT (role_id, role_name, max_sensitivity_level)
|
||||||
|
VALUES (source.role_id, source.role_name, source.max_sensitivity_level);
|
||||||
|
|
||||||
|
MERGE INTO cb_user_role target
|
||||||
|
USING (SELECT 9904 user_id, 9904 role_id FROM dual) source
|
||||||
|
ON (target.user_id = source.user_id AND target.role_id = source.role_id)
|
||||||
|
WHEN NOT MATCHED THEN INSERT (user_id, role_id) VALUES (source.user_id, source.role_id);
|
||||||
|
|
||||||
|
MERGE INTO cb_permission target
|
||||||
|
USING (SELECT 9904 perm_id, 9904 role_id, 'CB_VECTOR_SEARCH_DOCUMENTS' target_name,
|
||||||
|
'SELECT' action_name, 'ALLOW' permission_effect FROM dual) source
|
||||||
|
ON (target.perm_id = source.perm_id)
|
||||||
|
WHEN MATCHED THEN UPDATE SET target.role_id = source.role_id, target.target_name = source.target_name,
|
||||||
|
target.action_name = source.action_name, target.permission_effect = source.permission_effect
|
||||||
|
WHEN NOT MATCHED THEN INSERT (perm_id, role_id, target_name, action_name, permission_effect)
|
||||||
|
VALUES (source.perm_id, source.role_id, source.target_name, source.action_name, source.permission_effect);
|
||||||
|
|
||||||
|
MERGE INTO cb_permission_rule target
|
||||||
|
USING (SELECT 9904 rule_id, 9904 perm_id, 'TECH_TAG' rule_column, 'TAG' rule_type,
|
||||||
|
'DDS_VERIFY_ALLOW' rule_value FROM dual) source
|
||||||
|
ON (target.rule_id = source.rule_id)
|
||||||
|
WHEN MATCHED THEN UPDATE SET target.perm_id = source.perm_id, target.rule_column = source.rule_column,
|
||||||
|
target.rule_type = source.rule_type, target.rule_value = source.rule_value
|
||||||
|
WHEN NOT MATCHED THEN INSERT (rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||||
|
VALUES (source.rule_id, source.perm_id, source.rule_column, source.rule_type, source.rule_value);
|
||||||
|
|
||||||
|
MERGE INTO cb_vector_document_chunk target
|
||||||
|
USING (SELECT 29040 chunk_id, 'dds-fixture-service-allow' document_id, 1 chunk_no,
|
||||||
|
'DDS verification allow fixture' title, TO_CLOB('Deterministic allow fixture.') chunk_text,
|
||||||
|
'fixture://dds/service/allow' source_uri, TO_VECTOR('[0.11,0.22,0.33,0.44]') embedding FROM dual) source
|
||||||
|
ON (target.chunk_id = source.chunk_id)
|
||||||
|
WHEN MATCHED THEN UPDATE SET target.document_id = source.document_id, target.chunk_no = source.chunk_no,
|
||||||
|
target.title = source.title, target.chunk_text = source.chunk_text, target.source_uri = source.source_uri, target.embedding = source.embedding
|
||||||
|
WHEN NOT MATCHED THEN INSERT (chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding)
|
||||||
|
VALUES (source.chunk_id, source.document_id, source.chunk_no, source.title, source.chunk_text, source.source_uri, source.embedding);
|
||||||
|
|
||||||
|
MERGE INTO cb_vector_document_chunk target
|
||||||
|
USING (SELECT 29041 chunk_id, 'dds-fixture-service-deny' document_id, 1 chunk_no,
|
||||||
|
'DDS verification deny fixture' title, TO_CLOB('Deterministic deny fixture.') chunk_text,
|
||||||
|
'fixture://dds/service/deny' source_uri, TO_VECTOR('[0.12,0.23,0.34,0.45]') embedding FROM dual) source
|
||||||
|
ON (target.chunk_id = source.chunk_id)
|
||||||
|
WHEN MATCHED THEN UPDATE SET target.document_id = source.document_id, target.chunk_no = source.chunk_no,
|
||||||
|
target.title = source.title, target.chunk_text = source.chunk_text, target.source_uri = source.source_uri, target.embedding = source.embedding
|
||||||
|
WHEN NOT MATCHED THEN INSERT (chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding)
|
||||||
|
VALUES (source.chunk_id, source.document_id, source.chunk_no, source.title, source.chunk_text, source.source_uri, source.embedding);
|
||||||
|
|
||||||
|
MERGE INTO cb_vector_document_chunk target
|
||||||
|
USING (SELECT 29042 chunk_id, 'dds-fixture-direct' document_id, 1 chunk_no,
|
||||||
|
'DDS direct comparison fixture' title, TO_CLOB('Deterministic direct fixture.') chunk_text,
|
||||||
|
'fixture://dds/direct' source_uri, TO_VECTOR('[0.13,0.24,0.35,0.46]') embedding FROM dual) source
|
||||||
|
ON (target.chunk_id = source.chunk_id)
|
||||||
|
WHEN MATCHED THEN UPDATE SET target.document_id = source.document_id, target.chunk_no = source.chunk_no,
|
||||||
|
target.title = source.title, target.chunk_text = source.chunk_text, target.source_uri = source.source_uri, target.embedding = source.embedding
|
||||||
|
WHEN NOT MATCHED THEN INSERT (chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding)
|
||||||
|
VALUES (source.chunk_id, source.document_id, source.chunk_no, source.title, source.chunk_text, source.source_uri, source.embedding);
|
||||||
|
|
||||||
|
MERGE INTO cb_vector_document_tag target USING (SELECT 29040 chunk_id, 'DDS_VERIFY_ALLOW' tech_tag FROM dual) source
|
||||||
|
ON (target.chunk_id = source.chunk_id AND target.tech_tag = source.tech_tag)
|
||||||
|
WHEN NOT MATCHED THEN INSERT (chunk_id, tech_tag) VALUES (source.chunk_id, source.tech_tag);
|
||||||
|
MERGE INTO cb_vector_document_tag target USING (SELECT 29041 chunk_id, 'DDS_VERIFY_DENY' tech_tag FROM dual) source
|
||||||
|
ON (target.chunk_id = source.chunk_id AND target.tech_tag = source.tech_tag)
|
||||||
|
WHEN NOT MATCHED THEN INSERT (chunk_id, tech_tag) VALUES (source.chunk_id, source.tech_tag);
|
||||||
|
MERGE INTO cb_vector_document_tag target USING (SELECT 29042 chunk_id, 'ORACLE_DDS' tech_tag FROM dual) source
|
||||||
|
ON (target.chunk_id = source.chunk_id AND target.tech_tag = source.tech_tag)
|
||||||
|
WHEN NOT MATCHED THEN INSERT (chunk_id, tech_tag) VALUES (source.chunk_id, source.tech_tag);
|
||||||
|
|
||||||
|
PROMPT === 3. Registering fixture manifest ===
|
||||||
|
MERGE INTO cb_dds_validation_fixture target
|
||||||
|
USING (SELECT 'SERVICE_ALLOW' fixture_id, 'SERVICE' enforcement_path, 'service' subject_key, 9904 application_user_id,
|
||||||
|
'dds-fixture-service-allow' document_id, 'Y' expected_visible, 'Temporary token sees allowed tag' description FROM dual) source
|
||||||
|
ON (target.fixture_id = source.fixture_id)
|
||||||
|
WHEN MATCHED THEN UPDATE SET target.enforcement_path = source.enforcement_path, target.subject_key = source.subject_key,
|
||||||
|
target.application_user_id = source.application_user_id, target.document_id = source.document_id,
|
||||||
|
target.expected_visible = source.expected_visible, target.description = source.description, target.active_yn = 'Y'
|
||||||
|
WHEN NOT MATCHED THEN INSERT (fixture_id, enforcement_path, subject_key, application_user_id, document_id, expected_visible, description)
|
||||||
|
VALUES (source.fixture_id, source.enforcement_path, source.subject_key, source.application_user_id, source.document_id, source.expected_visible, source.description);
|
||||||
|
MERGE INTO cb_dds_validation_fixture target
|
||||||
|
USING (SELECT 'SERVICE_DENY' fixture_id, 'SERVICE' enforcement_path, 'service' subject_key, 9904 application_user_id,
|
||||||
|
'dds-fixture-service-deny' document_id, 'N' expected_visible, 'Temporary token cannot see denied tag' description FROM dual) source
|
||||||
|
ON (target.fixture_id = source.fixture_id)
|
||||||
|
WHEN MATCHED THEN UPDATE SET target.enforcement_path = source.enforcement_path, target.subject_key = source.subject_key,
|
||||||
|
target.application_user_id = source.application_user_id, target.document_id = source.document_id,
|
||||||
|
target.expected_visible = source.expected_visible, target.description = source.description, target.active_yn = 'Y'
|
||||||
|
WHEN NOT MATCHED THEN INSERT (fixture_id, enforcement_path, subject_key, application_user_id, document_id, expected_visible, description)
|
||||||
|
VALUES (source.fixture_id, source.enforcement_path, source.subject_key, source.application_user_id, source.document_id, source.expected_visible, source.description);
|
||||||
|
MERGE INTO cb_dds_validation_fixture target
|
||||||
|
USING (SELECT 'DIRECT_ALLOW' fixture_id, 'DIRECT' enforcement_path, 'both' subject_key, 0 application_user_id,
|
||||||
|
'dds-fixture-direct' document_id, 'Y' expected_visible, 'Combined DDS profile sees direct fixture' description FROM dual) source
|
||||||
|
ON (target.fixture_id = source.fixture_id)
|
||||||
|
WHEN MATCHED THEN UPDATE SET target.subject_key = source.subject_key, target.document_id = source.document_id,
|
||||||
|
target.expected_visible = source.expected_visible, target.description = source.description, target.active_yn = 'Y'
|
||||||
|
WHEN NOT MATCHED THEN INSERT (fixture_id, enforcement_path, subject_key, application_user_id, document_id, expected_visible, description)
|
||||||
|
VALUES (source.fixture_id, source.enforcement_path, source.subject_key, source.application_user_id, source.document_id, source.expected_visible, source.description);
|
||||||
|
MERGE INTO cb_dds_validation_fixture target
|
||||||
|
USING (SELECT 'DIRECT_DENY' fixture_id, 'DIRECT' enforcement_path, 'none' subject_key, 0 application_user_id,
|
||||||
|
'dds-fixture-direct' document_id, 'N' expected_visible, 'Default deny DDS profile cannot see fixture' description FROM dual) source
|
||||||
|
ON (target.fixture_id = source.fixture_id)
|
||||||
|
WHEN MATCHED THEN UPDATE SET target.subject_key = source.subject_key, target.document_id = source.document_id,
|
||||||
|
target.expected_visible = source.expected_visible, target.description = source.description, target.active_yn = 'Y'
|
||||||
|
WHEN NOT MATCHED THEN INSERT (fixture_id, enforcement_path, subject_key, application_user_id, document_id, expected_visible, description)
|
||||||
|
VALUES (source.fixture_id, source.enforcement_path, source.subject_key, source.application_user_id, source.document_id, source.expected_visible, source.description);
|
||||||
|
|
||||||
|
PROMPT === 4. Recording the service baseline ===
|
||||||
|
MERGE INTO cb_dds_protection_snapshot target
|
||||||
|
USING (
|
||||||
|
SELECT 'SERVICE' enforcement_path, 'service' subject_key,
|
||||||
|
STANDARD_HASH('SERVICE|ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS|CB_DDS_TOKEN_ROLE|DDS_DEMO_TOKEN_VECTOR_GRANT|ADMIN.CB_DDS_VECTOR_TAG_ALLOWED', 'SHA256') fingerprint
|
||||||
|
FROM dual
|
||||||
|
) source
|
||||||
|
ON (target.enforcement_path = source.enforcement_path AND target.subject_key = source.subject_key)
|
||||||
|
WHEN NOT MATCHED THEN INSERT (snapshot_id, object_name, enforcement_path, subject_key, fingerprint, actor, note, published_at)
|
||||||
|
VALUES (cb_dds_protection_snapshot_seq.NEXTVAL, 'ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS', source.enforcement_path,
|
||||||
|
source.subject_key, source.fingerprint, 'DDS migration', 'Initial token-service baseline', SYSTIMESTAMP);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
SELECT fixture_id, enforcement_path, subject_key, expected_visible
|
||||||
|
FROM cb_dds_validation_fixture
|
||||||
|
ORDER BY fixture_id;
|
||||||
|
|
||||||
|
PROMPT === DDS protection evidence setup complete ===
|
||||||
|
EXIT;
|
||||||
Reference in New Issue
Block a user