feat: add DDS protection status dashboard

This commit is contained in:
devmrko
2026-06-30 20:31:31 +09:00
parent 2f67587b40
commit 1766696ab4
21 changed files with 637 additions and 123 deletions

View File

@@ -67,6 +67,7 @@ dds:
token:
username: ${DDS_BACKOFFICE_TOKEN_USERNAME:dds_demo_token}
password: ${DDS_BACKOFFICE_TOKEN_PASSWORD:${DDSUSER_TOKEN_PASSWORD:}}
data-role: ${DDS_BACKOFFICE_TOKEN_DATA_ROLE:cb_dds_token_role}
users:
my:
label: MY 전용 사용자

View File

@@ -67,12 +67,41 @@ body { margin: 0; background: var(--dds-bg); color: var(--dds-ink); font-family:
.journey-card-compact h2 { margin: 0 0 .25rem; }
.journey-card-compact strong { font-size: .8rem; }
.compact-context { padding: .75rem; }
.protection-summary { border-top: 3px solid #3157d5; }
.protection-status-card { background: #fbfcff; border: 1px solid #dbe3f4; border-radius: 14px; margin-top: 1rem; padding: 1.25rem; }
.protection-status-card.status-danger { background: #fffafa; border-color: #f2c8c8; }
.protection-status-card.status-warning { background: #fffdf8; border-color: #ead9ad; }
.protection-status-topline { align-items: flex-start; display: flex; gap: 1rem; justify-content: space-between; }
.protection-path-label { color: var(--dds-accent); display: block; font-size: .78rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
.protection-status-topline h3 { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1rem; margin: .35rem 0; overflow-wrap: anywhere; }
.protection-status-topline p, .protection-primary-description { color: var(--dds-muted); margin: 0; }
.protection-primary-description { line-height: 1.55; margin-top: .8rem; }
.protection-primary-badge, .protection-table-badge { border-radius: 999px; display: inline-block; font-size: .8rem; font-weight: 800; padding: .35rem .65rem; white-space: nowrap; }
.tone-danger { background: #fbe8e8; color: #9e3030; }
.tone-warning { background: #fff1cf; color: #8a5d12; }
.tone-success { background: #e3f6ed; color: #11765a; }
.tone-neutral { background: #edf1f7; color: #43516a; }
.protection-evidence-grid { display: grid; gap: .8rem; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 1.2rem 0; }
.protection-evidence-grid > div { background: #fff; border: 1px solid var(--dds-line); border-radius: 10px; min-width: 0; padding: .85rem; }
.protection-evidence-grid dt { color: var(--dds-muted); font-size: .72rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; }
.protection-evidence-grid dd { margin: .35rem 0 0; }
.protection-evidence-grid dd strong, .protection-evidence-grid dd span { display: block; }
.protection-evidence-grid dd span { color: var(--dds-muted); font-size: .82rem; line-height: 1.45; margin-top: .35rem; }
.protection-status-footer { align-items: center; border-top: 1px solid var(--dds-line); color: var(--dds-muted); display: flex; font-size: .82rem; gap: 1rem; justify-content: space-between; padding-top: .9rem; }
.protection-path-comparison { display: grid; gap: .8rem; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: .8rem; }
.protection-path-comparison > div { background: #f7f9fc; border: 1px solid var(--dds-line); border-radius: 10px; padding: .9rem; }
.protection-path-comparison strong, .protection-path-comparison span { display: block; }
.protection-path-comparison span { color: var(--dds-muted); font-size: .86rem; line-height: 1.5; margin-top: .35rem; }
.advanced-protection > summary { cursor: pointer; font-weight: 800; }
.protection-direct-table small { color: var(--dds-muted); display: block; font-size: .78rem; line-height: 1.4; margin-top: .3rem; max-width: 20rem; }
.protection-direct-table td { vertical-align: top; }
@media (max-width: 800px) {
.flow-grid, .matrix-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.query-grid, .context-grid { grid-template-columns: 1fr; }
.query-grid, .context-grid, .protection-evidence-grid, .protection-path-comparison { grid-template-columns: 1fr; }
}
@media (max-width: 540px) {
.flow-grid, .matrix-grid { grid-template-columns: 1fr; }
.panel { padding: 19px; }
.hero { padding-top: 30px; }
.protection-status-topline, .protection-status-footer { align-items: flex-start; flex-direction: column; }
}

View File

@@ -19,7 +19,7 @@
<section class="journey-grid" aria-label="DDS 권한 적용 네 단계">
<a class="journey-card journey-card-compact" href="/permissions"><span class="journey-number">1</span><div><h2>권한 설계</h2><strong>열기 →</strong></div></a>
<a class="journey-card journey-card-compact" href="/vpd-policies"><span class="journey-number">2</span><div><h2>DDS 보호 연결</h2><strong>열기 →</strong></div></a>
<a class="journey-card journey-card-compact" href="/dds-protection"><span class="journey-number">2</span><div><h2>DDS 보호 상태</h2><strong>열기 →</strong></div></a>
<a class="journey-card journey-card-compact" href="/dds-provision"><span class="journey-number">3</span><div><h2>DDS 권한 연결</h2><strong>열기 →</strong></div></a>
<a class="journey-card journey-card-compact" href="/vector-knowledge"><span class="journey-number">4</span><div><h2>권한 기반 검색</h2><strong>열기 →</strong></div></a>
<a class="journey-card journey-card-compact" href="/dds"><span class="journey-number">5</span><div><h2>보호 객체 확인</h2><strong>열기 →</strong></div></a>

View File

@@ -15,7 +15,7 @@
</details>
</div>
<section th:replace="~{fragments/layout :: architectureStrip('vpd')}"></section>
<section th:replace="~{fragments/layout :: architectureStrip('protection')}"></section>
<section class="content-band">
<details class="explanation-details">

View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="directComparison" class="table-responsive">
<table class="table table-sm align-middle protection-direct-table">
<thead><tr><th>검증 프로필</th><th>보호 상태</th><th>권한 반영</th><th>검색 검증</th><th>조치</th></tr></thead>
<tbody>
<tr th:each="status : ${directPaths}">
<td>
<strong th:text="${status.subjectLabel()}">검증 프로필</strong>
<small th:text="${status.objectName()}">보호 객체</small>
</td>
<td>
<span class="protection-table-badge" th:classappend="${' tone-' + status.primaryTone()}"
th:text="${status.primaryLabel()}">재검증 필요</span>
<small th:text="${status.primaryDescription()}">설명</small>
</td>
<td><strong th:text="${status.synchronizationLabel()}">선언 관측</strong><small th:text="${status.synchronizationDetail()}">설명</small></td>
<td><strong th:text="${status.verificationLabel()}">검증 기록 없음</strong><small th:text="${status.verificationDetail()}">설명</small></td>
<td><a class="btn btn-sm rw-btn-secondary" th:href="${status.actionHref()}" th:text="${status.actionLabel()}">실행</a></td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -31,7 +31,7 @@
<button class="rw-menu-trigger" type="button" aria-expanded="false"
th:text="${backofficeTrack == 'DDS' ? '2. DDS 보호·검증' : '2. 보호·검증'}">2. 보호·검증</button>
<div class="rw-menu-panel">
<a class="nav-link" href="/vpd-policies"
<a class="nav-link" th:href="${backofficeTrack == 'DDS' ? '/dds-protection' : '/vpd-policies'}"
th:text="${backofficeTrack == 'DDS' ? 'DDS 보호 연결' : 'DB 보호 연결'}">DB 보호 연결</a>
<a class="nav-link" href="/dds-provision" th:if="${backofficeTrack == 'DDS'}">DDS 권한 반영</a>
<a class="nav-link" href="/tokens" th:if="${backofficeTrack != 'DDS'}">검증 세션 발급</a>
@@ -80,7 +80,7 @@
<p th:if="${backofficeTrack != 'DDS'}">누가 어떤 데이터의 어느 행과 컬럼을 볼지 정합니다.</p>
</div>
<div class="architecture-arrow"></div>
<div class="architecture-step" th:classappend="${activeLayer == 'vpd'} ? ' active'">
<div class="architecture-step" th:classappend="${activeLayer == 'vpd' or activeLayer == 'protection'} ? ' active'">
<span class="architecture-kicker" th:text="${backofficeTrack == 'DDS' ? '2 · ENFORCE' : '2 · ENFORCE'}">2 · ENFORCE</span>
<strong th:text="${backofficeTrack == 'DDS' ? 'DDS 보호 연결' : 'DB 보호 연결'}">DB 보호 연결</strong>
<p th:if="${backofficeTrack != 'DDS'}" th:text="${'VPD가 저장된 권한체계를 매번 읽어 DB에서 행을 자동 제한합니다.'}">DB 보호 정책이 권한을 적용합니다.</p>

View File

@@ -1,109 +1,100 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('DDS 보호 연결')}"></head>
<head th:replace="~{fragments/layout :: head('DDS 보호 상태')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<span class="architecture-kicker">DDS · ENFORCEMENT</span>
<h1>DDS 보호 연결</h1>
<p class="context-summary">권한 관리에서 만든 규칙을 DDS DATA ROLE/DATA GRANT로 연결하고 실제 검색 결과로 확인합니다.</p>
<details class="explanation-details">
<summary>VPD의 Policy/Filter와 무엇이 다른가요?</summary>
<p>VPD는 Policy가 Filter function을 호출해 요청마다 predicate를 계산합니다. DDS는 보호 VIEW/TABLE별 DATA GRANT에 행·컬럼 조건을 선언하고, 필요하면 predicate가 공통 권한을 읽는 Definer-rights 함수를 호출합니다.</p>
<p class="mb-0">따라서 이 화면에서는 VPD Filter를 수정하지 않습니다. 일상적인 변경은 <a href="/permissions">권한 관리</a>에서 하고, DDS grant 반영은 승인된 SQL/배포 절차로 수행합니다.</p>
</details>
</div>
<header class="page-title">
<span class="architecture-kicker">DDS · PROTECTION STATUS</span>
<h1>DDS 보호 상태</h1>
<p class="context-summary">지식 검색에 쓰는 보호 객체가 현재 권한 기준대로 연결되어 있는지 확인합니다.</p>
</header>
<section th:replace="~{fragments/layout :: architectureStrip('vpd')}"></section>
<div class="alert alert-warning" th:if="${runtimeError}">
DDS 권한 규칙을 불러오지 못했습니다. <span th:text="${runtimeError}"></span>
</div>
<section th:replace="~{fragments/layout :: architectureStrip('protection')}"></section>
<section class="content-band protection-summary" aria-labelledby="service-path-heading">
<div class="section-heading">
<div>
<span class="architecture-kicker">기본 서비스 경로</span>
<h2 id="service-path-heading">토큰 기반 지식 검색</h2>
<p class="section-subtitle">일상 운영에서는 이 경로만 확인하면 됩니다. 직접 DDS END USER 비교는 아래 고급 검증에 분리했습니다.</p>
</div>
<span class="badge text-bg-danger" th:if="${protection.actionRequiredCount() > 0}"
th:text="${protection.actionRequiredCount() + '개 조치 필요'}">조치 필요</span>
<span class="badge text-bg-warning" th:unless="${protection.actionRequiredCount() > 0}">검증 확인 필요</span>
</div>
<article class="protection-status-card"
th:classappend="${' status-' + protection.servicePath.primaryTone()}"
aria-label="토큰 기반 서비스 경로 보호 상태">
<div class="protection-status-topline">
<div>
<span class="protection-path-label" th:text="${protection.servicePath.pathLabel()}">토큰 기반 서비스 경로</span>
<h3 th:text="${protection.servicePath.objectName()}">ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS</h3>
<p th:text="${protection.servicePath.subjectLabel()}">기술 사용자</p>
</div>
<span class="protection-primary-badge"
th:classappend="${' tone-' + protection.servicePath.primaryTone()}"
th:text="${protection.servicePath.primaryLabel()}">재검증 필요</span>
</div>
<p class="protection-primary-description" th:text="${protection.servicePath.primaryDescription()}">설명</p>
<dl class="protection-evidence-grid">
<div>
<dt>DB 관측</dt>
<dd><strong th:text="${protection.servicePath.observationLabel()}">일부 확인</strong><span th:text="${protection.servicePath.observationDetail()}">설명</span></dd>
</div>
<div>
<dt>권한 반영</dt>
<dd><strong th:text="${protection.servicePath.synchronizationLabel()}">선언 관측</strong><span th:text="${protection.servicePath.synchronizationDetail()}">설명</span></dd>
</div>
<div>
<dt>검색 검증</dt>
<dd><strong th:text="${protection.servicePath.verificationLabel()}">검증 기록 없음</strong><span th:text="${protection.servicePath.verificationDetail()}">설명</span></dd>
</div>
</dl>
<footer class="protection-status-footer">
<span th:text="${'마지막 관측 ' + protection.servicePath.observedAtLabel()}">마지막 관측</span>
<a class="btn rw-btn-primary" th:href="${protection.servicePath.actionHref()}"
th:text="${protection.servicePath.actionLabel()}">토큰 권한으로 검색</a>
</footer>
</article>
</section>
<section class="content-band">
<div class="section-heading">
<div>
<span class="architecture-kicker">공통 관리 기준</span>
<h2>VPD와 동일한 권한 관리 대상을 사용합니다.</h2>
<p class="section-subtitle">사용자·그룹·역할·행 규칙·TAG 규칙은 한 권한 화면에서 관리합니다.</p>
<span class="architecture-kicker">권한 변경</span>
<h2>권한을 바꾸려면</h2>
<p class="section-subtitle">권한 규칙을 먼저 수정하고, 직접 비교 경로의 변경은 DDS 권한 반영에서 미리 확인합니다.</p>
</div>
<div class="d-flex gap-2">
<a class="btn btn-sm rw-btn-secondary" href="/permissions">권한 규칙 열기</a>
<a class="btn btn-sm rw-btn-primary" href="/dds-provision">DDS 권한 반영</a>
<a class="btn btn-sm rw-btn-primary" href="/dds-provision">권한 반영 미리보기</a>
</div>
</div>
<div class="policy-apply-flow" aria-label="DDS 적용 계층">
<span>사용자·그룹·역할</span><strong></strong>
<span>객체별 행·TAG·컬럼 권한</span><strong></strong>
<span>DATA ROLE / DATA GRANT</span><strong></strong>
<span>토큰 Context 또는 END USER 결과</span>
</div>
<p class="text-muted mb-0">관리 대상 이름: <code th:text="${managementObject}">CB_VECTOR_SEARCH_DOCUMENTS</code></p>
</section>
<section class="content-band">
<div class="section-heading">
<div>
<h2>DDS 보호 객체</h2>
<p class="section-subtitle">VPD용 객체와 분리된 DDS 전용 VIEW입니다. 같은 청크·태그 저장소를 읽지만 VPD 정책은 붙이지 않습니다.</p>
<details class="explanation-details m-0">
<summary>두 집행 경로의 차이 보기</summary>
<div class="protection-path-comparison">
<div><strong>토큰 기반 서비스</strong><span>기술 사용자 연결에서 Bearer 토큰으로 업무 사용자를 식별합니다. 객체별 DATA GRANT가 현재 공통 권한을 평가합니다.</span></div>
<div><strong>DDS END USER 직접 비교</strong><span>DATA ROLE과 게시된 DATA GRANT를 직접 확인하는 고급 진단 경로입니다. 권한 변경 뒤에는 별도 반영이 필요합니다.</span></div>
</div>
<a class="btn btn-sm rw-btn-secondary" href="/vector-knowledge">지식 검색 열기</a>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead><tr><th>보호 VIEW</th><th>적용 방식</th><th>기본 거부</th><th>검증</th></tr></thead>
<tbody>
<tr>
<td><code th:text="${ddsVectorObject}">ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS</code></td>
<td>토큰 Context/END USER → DATA ROLE → 객체별 DATA GRANT</td>
<td>Grant predicate가 없거나 조건 불일치 시 차단</td>
<td><a class="btn btn-sm btn-outline-primary" href="/vector-knowledge">검색 결과 확인</a></td>
</tr>
</tbody>
</table>
</div>
<details class="explanation-details mt-3">
<summary>추가·수정 가이드</summary>
<p>새 보호 대상을 추가할 때는 (1) 권한 관리에 논리 객체와 TAG 규칙을 등록하고 (2) DDS 전용 VIEW/TABLE을 만들고 (3) 해당 객체에 <code>CREATE OR REPLACE DATA GRANT ... WHERE ...</code>를 연결하고 (4) 토큰 Context와 직접 END USER의 허용·거부·권한 없음을 각각 검증합니다.</p>
<p class="mb-0">벡터 기준 SQL은 <code>sql/adb/32_dds_vector_tag_setup.sql</code>, 토큰 기반 공통 predicate 기준은 <code>sql/adb/34_dds_token_data_grant_common_auth.sql</code>입니다.</p>
</details>
</section>
<section class="content-band">
<div class="section-heading">
<div>
<h2>역할별 관리 규칙</h2>
<p class="section-subtitle">아래 내용은 공통 권한 화면의 현재 규칙입니다. DDS에서는 이 규칙을 DATA GRANT 조건으로 반영합니다.</p>
<details class="advanced-protection" aria-labelledby="direct-check-heading">
<summary id="direct-check-heading">고급 검증: DDS END USER 직접 비교 보기</summary>
<p class="section-subtitle mt-3">아래 항목은 제품 계정 목록이 아니라 DDS 선언형 권한을 비교하기 위한 검증 프로필입니다. 평소 운영에서는 열 필요가 없습니다.</p>
<button class="btn btn-sm rw-btn-secondary mt-2" type="button"
hx-get="/dds-protection/direct" hx-target="#direct-comparison-result" hx-swap="innerHTML">
고급 검증 불러오기
</button>
<div id="direct-comparison-result" class="mt-3" aria-live="polite">
<p class="text-muted mb-0">직접 비교는 필요할 때만 불러옵니다. 기본 서비스 검색에는 영향을 주지 않습니다.</p>
</div>
<span class="badge text-bg-secondary" th:text="${#lists.size(permissions)} + '개 규칙'">0개 규칙</span>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead><tr><th>역할</th><th>효과</th><th>행·태그 규칙</th><th>원문 허용 컬럼</th></tr></thead>
<tbody>
<tr th:each="permission : ${permissions}">
<td><strong th:text="${permission.roleName()}">ROLE</strong></td>
<td><span class="badge" th:classappend="${permission.permissionEffect() == 'ALLOW'} ? ' text-bg-success' : ' text-bg-danger'" th:text="${permission.permissionEffect()}">ALLOW</span></td>
<td><code th:text="${permission.rules() ?: '등록된 규칙 없음'}">TAG SPRING_BOOT</code></td>
<td th:text="${permission.visibleColumns() ?: '없음'}">없음</td>
</tr>
<tr th:if="${#lists.isEmpty(permissions)}"><td colspan="4" class="text-muted">아직 이 객체에 연결된 권한 규칙이 없습니다. 권한 관리에서 역할과 TAG 규칙을 먼저 등록하세요.</td></tr>
</tbody>
</table>
</div>
</section>
<section class="content-band">
<div class="section-heading">
<div><h2>DDS END USER 검증 주체</h2><p class="section-subtitle">비밀번호는 서버 환경 변수로만 관리하고 화면에는 표시하지 않습니다.</p></div>
</div>
<div class="summary-grid">
<div class="summary-tile" th:each="user : ${ddsUsers}">
<span class="label" th:text="${user.label()}">사용자</span>
<strong th:text="${user.username()}">dds_demo</strong>
<small th:text="${user.configured()} ? '연결 설정됨' : '비밀번호 설정 필요'">상태</small>
</div>
</div>
</details>
</section>
</main>
</body>