feat: add dedicated DDS permission backoffice instance
This commit is contained in:
50
dds-backoffice/src/main/resources/application.yml
Normal file
50
dds-backoffice/src/main/resources/application.yml
Normal 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:}}
|
||||
74
dds-backoffice/src/main/resources/static/css/dds.css
Normal file
74
dds-backoffice/src/main/resources/static/css/dds.css
Normal 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; }
|
||||
}
|
||||
151
dds-backoffice/src/main/resources/templates/dds.html
Normal file
151
dds-backoffice/src/main/resources/templates/dds.html
Normal 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>
|
||||
27
dds-backoffice/src/main/resources/templates/login.html
Normal file
27
dds-backoffice/src/main/resources/templates/login.html
Normal 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>
|
||||
Reference in New Issue
Block a user