feat: record DDS protection evidence

This commit is contained in:
devmrko
2026-06-30 20:56:52 +09:00
parent f162851bc6
commit 22a7eda219
17 changed files with 985 additions and 18 deletions

View File

@@ -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
) {
}

View File

@@ -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);
}
}

View File

@@ -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
) {
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -3,10 +3,14 @@ package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
import com.cloudhandson.ddsbackoffice.domain.DdsGrantInventorySnapshot;
import com.cloudhandson.ddsbackoffice.domain.DdsGrantPlan;
import com.cloudhandson.ddsbackoffice.domain.DdsPublishedProtectionSpec;
import com.cloudhandson.ddsbackoffice.domain.DdsProtectionOverview;
import com.cloudhandson.ddsbackoffice.domain.DdsProtectionPathStatus;
import com.cloudhandson.ddsbackoffice.domain.DdsValidationEvidence;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;
@@ -23,15 +27,18 @@ public class DdsProtectionStatusService {
private final DdsProperties properties;
private final DdsGrantInventory inventory;
private final DdsProvisionPlanProvider planProvider;
private final DdsProtectionEvidenceStore evidenceStore;
public DdsProtectionStatusService(
DdsProperties properties,
DdsGrantInventory inventory,
DdsProvisionPlanProvider planProvider
DdsProvisionPlanProvider planProvider,
DdsProtectionEvidenceStore evidenceStore
) {
this.properties = properties;
this.inventory = inventory;
this.planProvider = planProvider;
this.evidenceStore = evidenceStore;
}
public DdsProtectionOverview overview() {
@@ -75,13 +82,14 @@ public class DdsProtectionStatusService {
);
}
Verification verification = verification("SERVICE", "service", servicePublished());
return status(
"service", "토큰 기반 서비스 경로", "기술 사용자 · " + token.username(), false,
"재검증 필요", "warning", "Grant 존재는 확인했지만 최근 권한 결과 검증 기록이 없습니다.",
verification.primaryLabel(), verification.primaryTone(), verification.primaryDescription(),
"일부 확인", partialObservationDetail(snapshot),
"선언 관측", "Grant 이름·보호 객체·DATA ROLE이 catalog에서 확인되었습니다.",
"검증 기록 없음", "허용·거부 TAG fixture를 사용해 Bearer 토큰 검색을 실행하세요.", snapshot,
"토큰 권한으로 검색", "/vector-knowledge"
serviceSynchronizationLabel(), serviceSynchronizationDetail(),
verification.label(), verification.detail(), snapshot,
verification.actionLabel(), verification.actionHref()
);
}
@@ -133,6 +141,19 @@ public class DdsProtectionStatusService {
"권한 반영 미리보기", "/dds-provision"
);
}
Optional<DdsPublishedProtectionSpec> published = published("DIRECT", plan.userKey());
boolean sourceChanged = published.isPresent()
&& !published.get().fingerprint().equalsIgnoreCase(DdsProtectionFingerprint.direct(plan));
if (sourceChanged) {
return status(
"direct-" + plan.userKey(), "DDS END USER 직접 비교", subject, true,
"조치 필요", "danger", "권한 규칙이 마지막 DDS 게시 뒤 변경되었습니다.",
"일부 확인", partialObservationDetail(snapshot),
"반영 필요", "현재 권한 계획이 마지막 게시 기준과 다릅니다.",
"검증 만료", "새 권한을 게시한 뒤 fixture 검증을 다시 실행하세요.", snapshot,
"권한 반영 미리보기", "/dds-provision"
);
}
if (!plan.publishable() && actualGrant) {
return status(
"direct-" + plan.userKey(), "DDS END USER 직접 비교", subject, true,
@@ -153,13 +174,16 @@ public class DdsProtectionStatusService {
"직접 비교 실행", "/vector-knowledge"
);
}
Verification verification = verification("DIRECT", plan.userKey(), published);
return status(
"direct-" + plan.userKey(), "DDS END USER 직접 비교", subject, true,
"재검증 필요", "warning", "현재 권한 계획과 같은 이름의 Grant가 관측되었습니다.",
verification.primaryLabel(), verification.primaryTone(), verification.primaryDescription(),
"일부 확인", partialObservationDetail(snapshot),
"선언 관측", "Grant 이름·보호 객체·DATA ROLE이 catalog에서 확인되었습니다.",
"검증 기록 없음", "직접 비교 fixture로 허용·거부 결과를 확인하세요.", snapshot,
"직접 비교 실행", "/vector-knowledge"
published.isEmpty() ? "게시 기준 미등록" : "선언 관측",
published.isEmpty() ? "다음 DDS 권한 반영 시 immutable 게시 기준을 기록합니다."
: "Grant 이름·보호 객체·DATA ROLE이 catalog에서 확인되었습니다.",
verification.label(), verification.detail(), snapshot,
verification.actionLabel(), verification.actionHref()
);
}
@@ -211,4 +235,81 @@ public class DdsProtectionStatusService {
return snapshot.grants().size() + "개 Grant의 이름·보호 객체·DATA ROLE만 확인했습니다. "
+ "predicate와 컬럼 범위는 이 화면에서 비교하지 않습니다.";
}
private Optional<DdsPublishedProtectionSpec> servicePublished() {
return published("SERVICE", "service");
}
private Optional<DdsPublishedProtectionSpec> published(String path, String subject) {
try {
return evidenceStore.latestPublished(path, subject);
} catch (DataAccessException exception) {
return Optional.empty();
}
}
private String serviceSynchronizationLabel() {
Optional<DdsPublishedProtectionSpec> published = servicePublished();
if (published.isEmpty()) {
return "게시 기준 미등록";
}
return published.get().fingerprint().equalsIgnoreCase(DdsProtectionFingerprint.service(properties))
? "선언 기준 일치" : "기준 갱신 필요";
}
private String serviceSynchronizationDetail() {
return "Grant 이름·보호 객체·DATA ROLE이 catalog에서 확인되었습니다."
+ (servicePublished().isEmpty() ? " 다음 기준 등록 시 immutable fingerprint를 남깁니다." : "");
}
private Verification verification(
String path,
String subject,
Optional<DdsPublishedProtectionSpec> published
) {
Optional<DdsValidationEvidence> evidence;
try {
evidence = evidenceStore.latestValidation(path, subject);
} catch (DataAccessException exception) {
evidence = Optional.empty();
}
if (evidence.isEmpty()) {
return new Verification(
"검증 기록 없음", "결정적 허용·거부 fixture를 아직 실행하지 않았습니다.",
"재검증 필요", "warning", "Grant 존재는 확인했지만 최근 권한 결과 검증 기록이 없습니다.",
"보호 검증 실행", "SERVICE".equals(path) ? "/dds-protection#service-verify" : "/dds-protection/direct"
);
}
DdsValidationEvidence latest = evidence.get();
if (!latest.passed()) {
return new Verification(
"검증 실패", latest.failedCount() + "개 fixture가 기대 결과와 달랐습니다.",
"조치 필요", "danger", "최근 결정적 권한 검증이 실패했습니다.",
"권한 반영 미리보기", "/dds-provision"
);
}
if (published.isPresent() && latest.validatedAt().isBefore(published.get().publishedAt())) {
return new Verification(
"검증 만료", "마지막 권한 게시 뒤 fixture 검증이 필요합니다.",
"재검증 필요", "warning", "최근 게시 이후 검증 기록이 없습니다.",
"보호 검증 실행", "SERVICE".equals(path) ? "/dds-protection#service-verify" : "/dds-protection/direct"
);
}
return new Verification(
"검증 통과", latest.fixtureCount() + "개 결정적 fixture가 기대 결과와 일치했습니다.",
"검증 통과", "success", "최근 fixture 검증이 권한 결과와 일치했습니다.",
"보호 검증 실행", "SERVICE".equals(path) ? "/dds-protection#service-verify" : "/dds-protection/direct"
);
}
private record Verification(
String label,
String detail,
String primaryLabel,
String primaryTone,
String primaryDescription,
String actionLabel,
String actionHref
) {
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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() {

View File

@@ -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()));
}

View File

@@ -2,6 +2,10 @@
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="directComparison" class="table-responsive">
<form hx-post="/dds-protection/verify/direct" hx-target="#direct-comparison-result" hx-swap="innerHTML" class="mb-3">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<button class="btn btn-sm rw-btn-primary" type="submit">직접 비교 fixture 검증 실행</button>
</form>
<table class="table table-sm align-middle protection-direct-table">
<thead><tr><th>검증 프로필</th><th>보호 상태</th><th>권한 반영</th><th>검색 검증</th><th>조치</th></tr></thead>
<tbody>

View File

@@ -12,6 +12,13 @@
<section th:replace="~{fragments/layout :: architectureStrip('protection')}"></section>
<div class="alert alert-success" th:if="${successMessage}" th:text="${successMessage}"></div>
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
<form method="post" action="/dds-protection/setup" class="mb-3">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<button class="btn btn-sm rw-btn-secondary" type="submit">DDS 검증 이력·fixture 구성 확인</button>
</form>
<section class="content-band protection-summary" aria-labelledby="service-path-heading">
<div class="section-heading">
<div>
@@ -24,7 +31,7 @@
<span class="badge text-bg-warning" th:unless="${protection.actionRequiredCount() > 0}">검증 확인 필요</span>
</div>
<article class="protection-status-card"
<article id="service-verify" class="protection-status-card"
th:classappend="${' status-' + protection.servicePath.primaryTone()}"
aria-label="토큰 기반 서비스 경로 보호 상태">
<div class="protection-status-topline">
@@ -56,8 +63,13 @@
<footer class="protection-status-footer">
<span th:text="${'마지막 관측 ' + protection.servicePath.observedAtLabel()}">마지막 관측</span>
<a class="btn rw-btn-primary" th:href="${protection.servicePath.actionHref()}"
th:text="${protection.servicePath.actionLabel()}">토큰 권한으로 검색</a>
<div class="d-flex gap-2 flex-wrap">
<a class="btn rw-btn-secondary" href="/vector-knowledge">지식 검색 열기</a>
<form method="post" action="/dds-protection/verify/service" class="d-inline">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<button class="btn rw-btn-primary" type="submit">결정적 보호 검증 실행</button>
</form>
</div>
</footer>
</article>
</section>

View File

@@ -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<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) {
}
};
}
}

View 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"

View 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;