From 22a7eda219d2d41b067335da60cdf8dc095658f4 Mon Sep 17 00:00:00 2001 From: devmrko Date: Tue, 30 Jun 2026 20:56:52 +0900 Subject: [PATCH] feat: record DDS protection evidence --- .../domain/DdsPublishedProtectionSpec.java | 12 + .../domain/DdsValidationEvidence.java | 18 ++ .../domain/DdsValidationFixture.java | 13 + .../domain/DdsValidationRunResult.java | 15 ++ .../service/DdsProtectionEvidenceStore.java | 22 ++ .../service/DdsProtectionFingerprint.java | 41 +++ .../service/DdsProtectionSchemaService.java | 56 +++++ .../service/DdsProtectionStatusService.java | 119 ++++++++- .../DdsProtectionValidationService.java | 238 ++++++++++++++++++ .../JdbcDdsProtectionEvidenceStore.java | 121 +++++++++ .../web/DdsProtectionController.java | 40 ++- .../web/DdsProvisionController.java | 17 +- .../fragments/dds-protection-direct.html | 4 + .../resources/templates/vpd-policies.html | 18 +- .../DdsProtectionStatusServiceTest.java | 41 ++- scripts/setup-dds-protection-evidence.sh | 17 ++ sql/adb/40_dds_protection_evidence.sql | 211 ++++++++++++++++ 17 files changed, 985 insertions(+), 18 deletions(-) create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsPublishedProtectionSpec.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsValidationEvidence.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsValidationFixture.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsValidationRunResult.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionEvidenceStore.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionFingerprint.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionSchemaService.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionValidationService.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/JdbcDdsProtectionEvidenceStore.java create mode 100644 scripts/setup-dds-protection-evidence.sh create mode 100644 sql/adb/40_dds_protection_evidence.sql diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsPublishedProtectionSpec.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsPublishedProtectionSpec.java new file mode 100644 index 0000000..c392091 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsPublishedProtectionSpec.java @@ -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 +) { +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsValidationEvidence.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsValidationEvidence.java new file mode 100644 index 0000000..16f8449 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsValidationEvidence.java @@ -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); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsValidationFixture.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsValidationFixture.java new file mode 100644 index 0000000..21134b4 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsValidationFixture.java @@ -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 +) { +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsValidationRunResult.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsValidationRunResult.java new file mode 100644 index 0000000..7f28cb7 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsValidationRunResult.java @@ -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; + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionEvidenceStore.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionEvidenceStore.java new file mode 100644 index 0000000..7d32b6d --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionEvidenceStore.java @@ -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 latestPublished(String path, String subjectKey); + + Optional latestValidation(String path, String subjectKey); + + List 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); +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionFingerprint.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionFingerprint.java new file mode 100644 index 0000000..f8b0112 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionFingerprint.java @@ -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); + } + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionSchemaService.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionSchemaService.java new file mode 100644 index 0000000..ce5ece2 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionSchemaService.java @@ -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; + } + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionStatusService.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionStatusService.java index 219588e..e09c0a1 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionStatusService.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionStatusService.java @@ -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 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 servicePublished() { + return published("SERVICE", "service"); + } + + private Optional published(String path, String subject) { + try { + return evidenceStore.latestPublished(path, subject); + } catch (DataAccessException exception) { + return Optional.empty(); + } + } + + private String serviceSynchronizationLabel() { + Optional 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 published + ) { + Optional 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 + ) { + } } diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionValidationService.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionValidationService.java new file mode 100644 index 0000000..0f44bc6 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionValidationService.java @@ -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 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 fixtures = evidenceStore.activeFixtures("DIRECT"); + return run(fixtures, "DIRECT"); + } + + private DdsValidationRunResult run(List 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); + } + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/JdbcDdsProtectionEvidenceStore.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/JdbcDdsProtectionEvidenceStore.java new file mode 100644 index 0000000..9e68488 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/JdbcDdsProtectionEvidenceStore.java @@ -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 latestPublished(String path, String subjectKey) { + List 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 latestValidation(String path, String subjectKey) { + List 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 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(); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java index 9e95ba3..cc7127b 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProtectionController.java @@ -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() { diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProvisionController.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProvisionController.java index f46b1e6..f753d40 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProvisionController.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProvisionController.java @@ -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())); } diff --git a/dds-backoffice/src/main/resources/templates/fragments/dds-protection-direct.html b/dds-backoffice/src/main/resources/templates/fragments/dds-protection-direct.html index 6242cb9..67e5ccd 100644 --- a/dds-backoffice/src/main/resources/templates/fragments/dds-protection-direct.html +++ b/dds-backoffice/src/main/resources/templates/fragments/dds-protection-direct.html @@ -2,6 +2,10 @@
+
+ + +
diff --git a/dds-backoffice/src/main/resources/templates/vpd-policies.html b/dds-backoffice/src/main/resources/templates/vpd-policies.html index 6c18d12..1f558d9 100644 --- a/dds-backoffice/src/main/resources/templates/vpd-policies.html +++ b/dds-backoffice/src/main/resources/templates/vpd-policies.html @@ -12,6 +12,13 @@
+
+
+ + + + +
@@ -24,7 +31,7 @@ 검증 확인 필요
-
diff --git a/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionStatusServiceTest.java b/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionStatusServiceTest.java index 9007104..233a39a 100644 --- a/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionStatusServiceTest.java +++ b/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/service/DdsProtectionStatusServiceTest.java @@ -6,11 +6,15 @@ import com.cloudhandson.ddsbackoffice.config.DdsProperties; import com.cloudhandson.ddsbackoffice.domain.DdsGrantInventoryEntry; import com.cloudhandson.ddsbackoffice.domain.DdsGrantInventorySnapshot; import com.cloudhandson.ddsbackoffice.domain.DdsGrantPlan; +import com.cloudhandson.ddsbackoffice.domain.DdsPublishedProtectionSpec; 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.Instant; import java.util.List; import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.Test; class DdsProtectionStatusServiceTest { @@ -20,7 +24,8 @@ class DdsProtectionStatusServiceTest { DdsGrantInventory inventory = object -> new DdsGrantInventorySnapshot( 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(); @@ -42,7 +47,7 @@ class DdsProtectionStatusServiceTest { "ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS", "DDS_DEMO_BOTH_VECTOR_GRANT", "1 = 1", "", "CREATE DATA GRANT", true, "게시 가능" )), List.of(), 1); - var service = new DdsProtectionStatusService(properties(), inventory, provider); + var service = new DdsProtectionStatusService(properties(), inventory, provider, emptyEvidenceStore()); var overview = service.overview(); @@ -51,7 +56,7 @@ class DdsProtectionStatusServiceTest { var direct = service.directComparison(); assertEquals(1, direct.size()); assertEquals("재검증 필요", direct.getFirst().primaryLabel()); - assertEquals("선언 관측", direct.getFirst().synchronizationLabel()); + assertEquals("게시 기준 미등록", direct.getFirst().synchronizationLabel()); } @Test @@ -67,7 +72,7 @@ class DdsProtectionStatusServiceTest { "ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS", "DDS_DEMO_BOTH_VECTOR_GRANT", "", "", "", false, "ALLOW 규칙 없음" )), List.of(), 1); - var service = new DdsProtectionStatusService(properties(), inventory, provider); + var service = new DdsProtectionStatusService(properties(), inventory, provider, emptyEvidenceStore()); var direct = service.directComparison().getFirst(); @@ -89,4 +94,32 @@ class DdsProtectionStatusServiceTest { private static DdsProvisionPlanProvider emptyPlanProvider() { return () -> new DdsProvisioningPlan(List.of(), List.of(), 0); } + + private static DdsProtectionEvidenceStore emptyEvidenceStore() { + return new DdsProtectionEvidenceStore() { + @Override + public Optional latestPublished(String path, String subjectKey) { + return Optional.empty(); + } + + @Override + public Optional latestValidation(String path, String subjectKey) { + return Optional.empty(); + } + + @Override + public List 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) { + } + }; + } } diff --git a/scripts/setup-dds-protection-evidence.sh b/scripts/setup-dds-protection-evidence.sh new file mode 100644 index 0000000..a82157d --- /dev/null +++ b/scripts/setup-dds-protection-evidence.sh @@ -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" diff --git a/sql/adb/40_dds_protection_evidence.sql b/sql/adb/40_dds_protection_evidence.sql new file mode 100644 index 0000000..4009378 --- /dev/null +++ b/sql/adb/40_dds_protection_evidence.sql @@ -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;
검증 프로필보호 상태권한 반영검색 검증조치