feat: add dedicated DDS permission backoffice instance

This commit is contained in:
devmrko
2026-06-29 21:19:48 +09:00
parent 6cf75dc42b
commit c4968a0313
22 changed files with 1128 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
spring:
application:
name: dds-permission-backoffice
thymeleaf:
cache: false
server:
address: ${DDS_BACKOFFICE_BIND_ADDRESS:0.0.0.0}
port: ${DDS_BACKOFFICE_PORT:8083}
forward-headers-strategy: ${DDS_BACKOFFICE_FORWARD_HEADERS_STRATEGY:none}
servlet:
session:
cookie:
name: DDS_BACKOFFICE_SESSION
secure: ${DDS_BACKOFFICE_SESSION_COOKIE_SECURE:false}
http-only: true
same-site: lax
backoffice:
security:
admin-user: ${DDS_BACKOFFICE_ADMIN_USER:${BACKOFFICE_ADMIN_USER:admin}}
admin-password: ${DDS_BACKOFFICE_ADMIN_PASSWORD:${BACKOFFICE_ADMIN_PASSWORD:admin}}
require-https: ${DDS_BACKOFFICE_REQUIRE_HTTPS:false}
dds:
db-url: ${DDS_BACKOFFICE_DB_URL:${BACKOFFICE_DB_URL:jdbc:oracle:thin:@localhost:1521/FREEPDB1}}
query-timeout: ${DDS_BACKOFFICE_QUERY_TIMEOUT:10s}
pg-object: ${DDS_BACKOFFICE_PG_OBJECT:ADMIN.V_DDS_CUSTOMERS_PG}
my-object: ${DDS_BACKOFFICE_MY_OBJECT:ADMIN.V_DDS_CUSTOMERS_MY}
users:
my:
label: MY 전용 사용자
description: MySQL 원본만 허용하는 DATA ROLE
username: ${DDS_BACKOFFICE_MY_USERNAME:ddsuser_my}
password: ${DDS_BACKOFFICE_MY_PASSWORD:${DDSUSER_MY_PASSWORD:}}
pg:
label: PG 전용 사용자
description: PostgreSQL 원본만 허용하는 DATA ROLE
username: ${DDS_BACKOFFICE_PG_USERNAME:ddsuser_pg}
password: ${DDS_BACKOFFICE_PG_PASSWORD:${DDSUSER_PG_PASSWORD:}}
both:
label: 통합 사용자
description: 두 원본을 모두 허용하는 DATA ROLE
username: ${DDS_BACKOFFICE_BOTH_USERNAME:ddsuser_both}
password: ${DDS_BACKOFFICE_BOTH_PASSWORD:${DDSUSER_BOTH_PASSWORD:}}
none:
label: 차단 사용자
description: 접속만 가능하고 DATA GRANT가 없는 사용자
username: ${DDS_BACKOFFICE_NONE_USERNAME:ddsuser_none}
password: ${DDS_BACKOFFICE_NONE_PASSWORD:${DDSUSER_NONE_PASSWORD:}}

View File

@@ -0,0 +1,74 @@
:root {
--dds-ink: #17213a;
--dds-muted: #68738a;
--dds-line: #dfe5ef;
--dds-surface: #ffffff;
--dds-bg: #f4f7fb;
--dds-accent: #3157d5;
--dds-accent-dark: #203a9d;
--dds-success: #11765a;
--dds-warning: #9a6512;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--dds-bg); color: var(--dds-ink); font-family: Inter, "Noto Sans KR", system-ui, -apple-system, sans-serif; }
.dds-nav { background: #101a36; color: #fff; padding: 15px 0; box-shadow: 0 2px 12px rgba(16, 26, 54, .18); }
.brand { color: #fff; text-decoration: none; font-weight: 700; letter-spacing: -.02em; }
.track-badge { border: 1px solid rgba(255,255,255,.28); border-radius: 999px; color: #dbe4ff; font-size: .78rem; padding: 4px 10px; }
.hero { padding: 48px 0 26px; max-width: 920px; }
.hero h1 { font-size: clamp(2rem, 4vw, 3.2rem); letter-spacing: -.05em; line-height: 1.15; margin: 10px 0 15px; }
.hero .lead { color: var(--dds-muted); font-size: 1.1rem; line-height: 1.75; max-width: 820px; }
.eyebrow { color: var(--dds-accent); font-size: .72rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
.dds-nav .track-badge, .dds-nav .eyebrow { color: #dbe4ff; }
.explanation { background: #edf2ff; border: 1px solid #d4defe; border-radius: 12px; color: #34466e; margin-top: 22px; padding: 14px 17px; }
.explanation summary { cursor: pointer; font-weight: 700; }
.explanation p { line-height: 1.65; margin: 12px 0 0; }
.flow-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 5px 0 28px; }
.flow-card { align-items: flex-start; background: var(--dds-surface); border: 1px solid var(--dds-line); border-radius: 14px; display: flex; gap: 14px; min-height: 142px; padding: 20px; }
.flow-number { align-items: center; background: #e8edff; border-radius: 50%; color: var(--dds-accent); display: inline-flex; flex: 0 0 32px; font-weight: 800; height: 32px; justify-content: center; }
.result-number { background: #e2f4ed; color: var(--dds-success); }
.flow-card h2 { font-size: 1rem; margin: 5px 0 7px; }
.flow-card p { color: var(--dds-muted); font-size: .9rem; line-height: 1.55; margin: 0; }
.panel { background: var(--dds-surface); border: 1px solid var(--dds-line); border-radius: 16px; margin: 18px 0; padding: 25px; box-shadow: 0 5px 18px rgba(31, 51, 93, .04); }
.panel-heading { align-items: flex-start; display: flex; justify-content: space-between; gap: 16px; }
.panel-heading h2 { font-size: 1.35rem; letter-spacing: -.03em; margin: 6px 0 6px; }
.panel-heading p { color: var(--dds-muted); margin: 0; }
.query-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; margin-top: 22px; }
.query-grid label { color: #34415c; font-size: .9rem; font-weight: 700; }
.query-grid .form-control, .query-grid .form-select { margin-top: 7px; }
.field-help { color: var(--dds-muted); display: block; font-size: .78rem; font-weight: 400; margin-top: 6px; }
.query-submit { align-items: end; display: flex; }
.query-submit .btn { min-height: 45px; width: 100%; }
.matrix-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 20px; }
.matrix-card { background: #f5f7ff; border: 1px solid #dce3ff; border-radius: 12px; display: flex; flex-direction: column; gap: 6px; min-height: 118px; padding: 16px; }
.matrix-card strong { color: var(--dds-accent-dark); }
.matrix-card span { font-size: .9rem; font-weight: 700; }
.matrix-card small { color: var(--dds-muted); line-height: 1.4; }
.matrix-card.muted { background: #f8f8f8; border-color: #e5e5e5; }
.matrix-card.muted strong { color: #5b6270; }
.result-heading { align-items: center; }
.result-heading p { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; }
.result-status { border-radius: 999px; color: var(--dds-warning); background: #fff4dc; font-size: .8rem; font-weight: 800; padding: 7px 12px; }
.result-status.success { color: var(--dds-success); background: #e3f6ed; }
.result-status.blocked { color: #a34242; background: #fde8e8; }
.technical-code { color: var(--dds-muted); display: block; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .78rem; margin-top: 5px; }
.context-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 18px 0; }
.context-grid > div { background: #f7f9fc; border: 1px solid var(--dds-line); border-radius: 10px; padding: 12px 14px; }
.context-grid span { color: var(--dds-muted); display: block; font-size: .72rem; font-weight: 700; letter-spacing: .05em; }
.context-grid strong { display: block; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; margin-top: 5px; }
.table { margin-top: 8px; }
.table thead th { color: var(--dds-muted); font-size: .75rem; letter-spacing: .05em; text-transform: uppercase; }
.empty-result { background: #f7f9fc; border-radius: 10px; color: var(--dds-muted); padding: 18px; text-align: center; }
.login-page { align-items: center; display: flex; justify-content: center; min-height: 100vh; padding: 22px; }
.login-card { background: #fff; border: 1px solid var(--dds-line); border-radius: 16px; box-shadow: 0 15px 45px rgba(31, 51, 93, .12); max-width: 420px; padding: 35px; width: 100%; }
.login-card h1 { font-size: 1.65rem; margin: 8px 0; }
.login-card p { color: var(--dds-muted); margin-bottom: 26px; }
@media (max-width: 800px) {
.flow-grid, .matrix-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.query-grid, .context-grid { grid-template-columns: 1fr; }
}
@media (max-width: 540px) {
.flow-grid, .matrix-grid { grid-template-columns: 1fr; }
.panel { padding: 19px; }
.hero { padding-top: 30px; }
}

View File

@@ -0,0 +1,151 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Deep Data Security 접근 관리</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/css/dds.css" rel="stylesheet">
</head>
<body>
<nav class="dds-nav">
<div class="container d-flex align-items-center gap-3">
<a class="brand" href="/">DDS Permission Console</a>
<span class="track-badge">별도 인스턴스 · :8083</span>
<form method="post" action="/logout" class="ms-auto">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<button class="btn btn-sm btn-outline-light" type="submit">로그아웃</button>
</form>
</div>
</nav>
<main class="container py-4">
<header class="hero">
<span class="eyebrow">DEEP DATA SECURITY · DIRECT END USER</span>
<h1>선언형 데이터 권한을 실제 조회로 확인합니다.</h1>
<p class="lead">VPD 트랙과 분리된 DDS 전용 인스턴스입니다. 선택한 END USER로 직접 접속해 Oracle의 DATA ROLE과 DATA GRANT가 적용된 결과를 보여줍니다.</p>
<details class="explanation">
<summary>이 화면의 보안 경계</summary>
<p>이 인스턴스는 VPD 권한 테이블을 읽어 조건을 계산하지 않습니다. <code>END USER → DATA ROLE → DATA GRANT → 보호 VIEW</code>가 전부 Oracle DDS에 선언되어 있고, 조회 결과는 그 선언의 적용 결과입니다.</p>
<p class="mb-0">Bearer 키를 ORDS Handler에서 사용자 이름으로 바꾸는 것만으로는 DDS 보안 컨텍스트가 만들어지지 않습니다. 그래서 이 비교 트랙은 직접 END USER logon을 검증합니다.</p>
</details>
</header>
<section class="flow-grid" aria-label="DDS 접근 흐름">
<article class="flow-card">
<span class="flow-number">1</span>
<div><span class="eyebrow">IDENTITY</span><h2>END USER</h2><p>실제 DDS 보안 사용자로 DB에 접속합니다.</p></div>
</article>
<article class="flow-card">
<span class="flow-number">2</span>
<div><span class="eyebrow">POLICY</span><h2>DATA ROLE / GRANT</h2><p>원본별 허용 범위가 선언형으로 연결됩니다.</p></div>
</article>
<article class="flow-card">
<span class="flow-number">3</span>
<div><span class="flow-number result-number">3</span><span class="eyebrow">EVIDENCE</span><h2>조회 결과</h2><p>허용된 VIEW와 행만 반환됩니다.</p></div>
</article>
</section>
<div class="alert alert-warning" th:if="${configuredUserCount == 0}">
DDS 사용자 비밀번호가 설정되지 않았습니다. VM에서는 <code>DDSUSER_*_PASSWORD</code> 또는 <code>DDS_BACKOFFICE_*_PASSWORD</code> 환경 변수를 설정해야 조회할 수 있습니다.
</div>
<section class="panel">
<div class="panel-heading">
<div>
<span class="eyebrow">1 · SECURITY SUBJECT</span>
<h2>조회 주체와 데이터 원본 선택</h2>
<p>선택한 DDS END USER의 권한으로 직접 연결합니다. 비밀번호는 화면에 표시하거나 저장하지 않습니다.</p>
</div>
</div>
<form method="post" action="/dds/query" class="query-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
DDS END USER
<select class="form-select" name="userKey" required>
<option th:each="user : ${users}" th:value="${user.key()}" th:selected="${user.key() == selectedUser}"
th:text="${user.label() + ' · ' + user.username()}"></option>
</select>
<span class="field-help" th:each="user : ${users}" th:if="${user.key() == selectedUser}" th:text="${user.description()}"></span>
</label>
<label>
보호 데이터 원본
<select class="form-select" name="sourceKey" required>
<option th:each="source : ${sources}" th:value="${source.key()}" th:selected="${source.key() == selectedSource}"
th:text="${source.label()}"></option>
</select>
<span class="field-help" th:each="source : ${sources}" th:if="${source.key() == selectedSource}" th:text="${source.objectName()}"></span>
</label>
<label>
이름 검색 (선택)
<input class="form-control" name="searchText" placeholder="예: Alice">
<span class="field-help">검색 조건도 보호 VIEW 안에서만 평가됩니다.</span>
</label>
<label>
최대 행 수
<input class="form-control" name="limit" type="number" min="1" max="100" value="20">
</label>
<div class="query-submit"><button class="btn btn-primary" type="submit">DDS 권한으로 조회</button></div>
</form>
</section>
<section class="panel">
<div class="panel-heading">
<div>
<span class="eyebrow">2 · EXPECTED MATRIX</span>
<h2>원본별 권한 매트릭스</h2>
<p>VPD와 같은 4가지 업무 사례를 DDS의 선언형 권한으로 재현합니다.</p>
</div>
</div>
<div class="matrix-grid">
<div class="matrix-card"><strong>ddsuser_my</strong><span>MY 원본 허용</span><small>PG 객체는 ORA-00942</small></div>
<div class="matrix-card"><strong>ddsuser_pg</strong><span>PG 원본 허용</span><small>MY 객체는 ORA-00942</small></div>
<div class="matrix-card"><strong>ddsuser_both</strong><span>PG + MY 허용</span><small>두 DATA GRANT 모두 적용</small></div>
<div class="matrix-card muted"><strong>ddsuser_none</strong><span>접속만 허용</span><small>데이터 권한 없음 · default deny</small></div>
</div>
</section>
<section class="panel" th:if="${queryResult != null}">
<div class="panel-heading result-heading">
<div>
<span class="eyebrow">3 · EVIDENCE</span>
<h2 th:text="${queryResult.userLabel() + ' · ' + queryResult.sourceLabel()}">조회 결과</h2>
<p th:text="${queryResult.objectName()}">ADMIN.V_DDS_CUSTOMERS_PG</p>
</div>
<span class="result-status" th:classappend="${queryResult.success()} ? ' success' : ' blocked'"
th:text="${queryResult.success()} ? '정책 적용됨' : '접근 차단'">상태</span>
</div>
<div class="alert" th:classappend="${queryResult.success()} ? ' alert-success' : ' alert-warning'">
<strong th:text="${queryResult.title()}">결과</strong>
<span th:text="${queryResult.message()}">메시지</span>
<span th:if="${queryResult.oracleCode() != null}" class="technical-code" th:text="${'Oracle code: ' + queryResult.oracleCode()}"></span>
</div>
<div class="context-grid" th:if="${queryResult.success()}">
<div><span>SESSION_USER</span><strong th:text="${queryResult.sessionUser() ?: '-'}">-</strong></div>
<div><span>ORA_END_USER_CONTEXT</span><strong th:text="${queryResult.endUser() ?: '-'}">-</strong></div>
<div><span>반환 행</span><strong th:text="${#lists.size(queryResult.rows())}">0</strong></div>
</div>
<div class="table-responsive" th:if="${queryResult.success() and queryResult.hasRows()}">
<table class="table align-middle">
<thead><tr><th>ID</th><th>이름</th><th>이메일</th><th>가입일</th><th>지역</th></tr></thead>
<tbody>
<tr th:each="row : ${queryResult.rows()}">
<td th:text="${row.customerId()}">1</td>
<td th:text="${row.fullName()}">Alice</td>
<td th:text="${row.email() ?: 'NULL'}">alice@example.com</td>
<td th:text="${row.signupDate() ?: '-'}">2026-01-01</td>
<td th:text="${row.region()}">APAC</td>
</tr>
</tbody>
</table>
</div>
<div class="empty-result" th:if="${queryResult.success() and !queryResult.hasRows()}">조회 조건을 통과한 행이 없습니다.</div>
<details class="explanation">
<summary>판정 근거와 운영 메모</summary>
<p>성공한 경우 연결 시점의 <code>SESSION_USER</code><code>ORA_END_USER_CONTEXT</code>를 함께 확인할 수 있습니다. 차단된 경우 DDS가 보호 객체 자체를 숨겨 <code>ORA-00942</code>를 반환하는 것이 정상입니다.</p>
<p class="mb-0">현재 앱은 결과만 읽습니다. 권한 변경은 <code>CREATE DATA ROLE</code>, <code>GRANT DATA ROLE</code>, <code>CREATE DATA GRANT</code> 승인 절차를 거쳐 SQL로 관리합니다.</p>
</details>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,27 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DDS Permission Console 로그인</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/css/dds.css" rel="stylesheet">
</head>
<body class="login-page">
<main class="login-card">
<span class="eyebrow">DEEP DATA SECURITY</span>
<h1>DDS Permission Console</h1>
<p>VPD와 분리된 DDS 전용 인스턴스입니다.</p>
<div class="alert alert-danger" th:if="${param.error}">관리자 인증에 실패했습니다.</div>
<div class="alert alert-success" th:if="${param.logout}">로그아웃되었습니다.</div>
<form method="post" action="/login">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label class="form-label" for="username">관리자 ID</label>
<input class="form-control mb-3" id="username" name="username" autocomplete="username" required autofocus>
<label class="form-label" for="password">비밀번호</label>
<input class="form-control mb-4" id="password" name="password" type="password" autocomplete="current-password" required>
<button class="btn btn-primary w-100" type="submit">로그인</button>
</form>
</main>
</body>
</html>