[Developer] #424 implement VPD ORDS backoffice

Refs #424
This commit is contained in:
devmrko
2026-06-23 11:04:46 +09:00
parent 5cc8026538
commit 7b45b3a028
64 changed files with 2247 additions and 5 deletions

View File

@@ -0,0 +1,33 @@
spring:
application:
name: vpd-permission-backoffice
datasource:
url: ${BACKOFFICE_DB_URL:jdbc:oracle:thin:@localhost:1521/FREEPDB1}
username: ${BACKOFFICE_DB_USERNAME:backoffice}
password: ${BACKOFFICE_DB_PASSWORD:backoffice}
driver-class-name: oracle.jdbc.OracleDriver
hikari:
pool-name: vpd-backoffice-pool
maximum-pool-size: ${BACKOFFICE_DB_POOL_MAX:5}
minimum-idle: ${BACKOFFICE_DB_POOL_MIN:1}
connection-timeout: 10000
thymeleaf:
cache: false
mybatis:
mapper-locations: classpath:/mapper/*.xml
configuration:
map-underscore-to-camel-case: true
server:
port: ${BACKOFFICE_PORT:8080}
backoffice:
security:
admin-user: ${BACKOFFICE_ADMIN_USER:admin}
admin-password: ${BACKOFFICE_ADMIN_PASSWORD:admin}
token:
max-days: ${BACKOFFICE_TOKEN_MAX_DAYS:365}
ords:
base-url: ${BACKOFFICE_ORDS_BASE_URL:http://localhost:8081/ords}
timeout-seconds: ${BACKOFFICE_ORDS_TIMEOUT_SECONDS:10}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.AuditMapper">
<insert id="insert" parameterType="com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent">
INSERT INTO cb_ords_probe_audit (
audit_id, event_type, key_id, object_id, status, row_count, error_code, message, created_at
) VALUES (
cb_ords_probe_audit_seq.NEXTVAL,
#{eventType}, #{keyId}, #{objectId}, #{status}, #{rowCount}, #{errorCode}, #{message}, SYSTIMESTAMP
)
</insert>
</mapper>

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper">
<select id="findAll" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
SELECT k.key_id, k.user_id, u.user_name AS username, k.key_prefix, k.key_hash,
k.expires_at, k.revoked_at, k.description
FROM cb_agent_bearer_key k
JOIN cb_app_user u ON u.user_id = k.user_id
ORDER BY k.key_id DESC
</select>
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
SELECT k.key_id, k.user_id, u.user_name AS username, k.key_prefix, k.key_hash,
k.expires_at, k.revoked_at, k.description
FROM cb_agent_bearer_key k
JOIN cb_app_user u ON u.user_id = k.user_id
WHERE k.key_id = #{keyId}
</select>
<select id="nextKeyId" resultType="long">
SELECT cb_agent_bearer_key_seq.NEXTVAL FROM dual
</select>
<insert id="insertToken" parameterType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
INSERT INTO cb_agent_bearer_key (
key_id, user_id, key_prefix, key_hash, expires_at, revoked_at, description
) VALUES (
#{keyId}, #{userId}, #{keyPrefix}, #{keyHash}, #{expiresAt}, #{revokedAt}, #{description}
)
</insert>
<update id="revokeToken">
UPDATE cb_agent_bearer_key
SET revoked_at = #{revokedAt},
description = CASE
WHEN #{reason} IS NULL THEN description
ELSE SUBSTR(COALESCE(description, '') || ' revoked: ' || #{reason}, 1, 200)
END
WHERE key_id = #{keyId}
AND revoked_at IS NULL
</update>
</mapper>

View File

@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.PermissionMapper">
<select id="findRoles" resultType="com.cloudhandson.vpdbackoffice.domain.permission.AppRole">
SELECT role_id, role_name, CAST(NULL AS VARCHAR2(200)) AS description
FROM cb_app_role
ORDER BY role_name
</select>
<select id="findRole" resultType="com.cloudhandson.vpdbackoffice.domain.permission.AppRole">
SELECT role_id, role_name, CAST(NULL AS VARCHAR2(200)) AS description
FROM cb_app_role
WHERE role_id = #{roleId}
</select>
<select id="findPermissionId" resultType="long">
SELECT p.perm_id
FROM cb_permission p
JOIN cb_protected_object o ON o.object_name = p.target_name
WHERE p.role_id = #{roleId}
AND o.object_id = #{objectId}
</select>
<select id="findPermissionSet" resultType="com.cloudhandson.vpdbackoffice.domain.permission.PermissionSet">
SELECT p.perm_id AS permission_id,
p.role_id,
o.object_id,
p.action_name AS action
FROM cb_permission p
JOIN cb_protected_object o ON o.object_name = p.target_name
WHERE p.role_id = #{roleId}
AND o.object_id = #{objectId}
</select>
<select id="nextPermissionId" resultType="long">
SELECT cb_permission_seq.NEXTVAL FROM dual
</select>
<select id="nextRuleId" resultType="long">
SELECT cb_permission_rule_seq.NEXTVAL FROM dual
</select>
<insert id="insertPermission">
INSERT INTO cb_permission (perm_id, role_id, target_name, action_name)
SELECT #{permissionId}, #{roleId}, object_name, #{action}
FROM cb_protected_object
WHERE object_id = #{objectId}
</insert>
<update id="updatePermissionAction">
UPDATE cb_permission
SET action_name = #{action}
WHERE perm_id = #{permissionId}
</update>
<delete id="deleteRules">
DELETE FROM cb_permission_rule
WHERE perm_id = #{permissionId}
</delete>
<insert id="insertRule" parameterType="com.cloudhandson.vpdbackoffice.domain.permission.PermissionRule">
INSERT INTO cb_permission_rule (rule_id, perm_id, rule_type, rule_value)
VALUES (#{ruleId}, #{permissionId}, #{ruleType}, #{ruleValue})
</insert>
<delete id="deleteVisibleColumns">
DELETE FROM cb_permission_column
WHERE permission_id = #{permissionId}
</delete>
<insert id="insertVisibleColumn">
INSERT INTO cb_permission_column (permission_id, column_name)
VALUES (#{permissionId}, #{columnName})
</insert>
</mapper>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.ProtectedObjectMapper">
<select id="findEnabled" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
SELECT object_id, owner, object_name, ords_path, enabled_yn
FROM cb_protected_object
WHERE enabled_yn = 'Y'
ORDER BY owner, object_name
</select>
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
SELECT object_id, owner, object_name, ords_path, enabled_yn
FROM cb_protected_object
WHERE object_id = #{objectId}
</select>
<select id="findColumns" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn">
SELECT column_id, object_id, column_name, sensitive_yn, visible_role_id
FROM cb_protected_column
WHERE object_id = #{objectId}
ORDER BY column_id
</select>
</mapper>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.UserMapper">
<select id="findAll" resultType="com.cloudhandson.vpdbackoffice.domain.user.AppUser">
SELECT user_id,
user_name AS username,
employee_no AS emp_no,
dept_code,
active AS active_yn
FROM cb_app_user
ORDER BY username
</select>
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.user.AppUser">
SELECT user_id,
user_name AS username,
employee_no AS emp_no,
dept_code,
active AS active_yn
FROM cb_app_user
WHERE user_id = #{userId}
</select>
</mapper>

View File

@@ -0,0 +1,99 @@
body {
background: #f7f8fa;
color: #20242a;
}
.page-title {
margin-bottom: 1.5rem;
}
.page-title h1 {
font-size: 1.65rem;
font-weight: 700;
margin: 0;
}
.page-title p {
color: #667085;
margin: .35rem 0 0;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.summary-tile {
background: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
color: inherit;
display: block;
padding: 1rem;
text-decoration: none;
}
.summary-tile .label {
color: #667085;
display: block;
font-size: .875rem;
}
.summary-tile strong {
display: block;
font-size: 1.75rem;
margin-top: .35rem;
}
.content-band {
background: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
margin-bottom: 1rem;
padding: 1rem;
}
.content-band h2,
.section-heading h2 {
font-size: 1rem;
font-weight: 700;
margin: 0 0 1rem;
}
.section-heading {
align-items: center;
display: flex;
justify-content: space-between;
gap: 1rem;
}
.form-grid {
align-items: end;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.form-grid label {
color: #344054;
font-size: .875rem;
font-weight: 600;
}
.inline-form {
display: inline;
}
.token-value {
display: block;
margin-top: .5rem;
overflow-wrap: anywhere;
}
.meta-row {
display: flex;
gap: 1rem;
margin-bottom: .75rem;
}

View File

@@ -0,0 +1,6 @@
document.body.addEventListener('htmx:responseError', (event) => {
const target = event.detail.target;
if (target) {
target.innerHTML = '<div class="alert alert-danger">요청 처리 중 오류가 발생했습니다.</div>';
}
});

View File

@@ -0,0 +1,58 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('VPD 백오피스')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>VPD 권한 백오피스</h1>
<p>보호 객체, Bearer Token, ORDS 검증 상태를 확인합니다.</p>
</div>
<section class="summary-grid">
<a class="summary-tile" href="/permissions">
<span class="label">보호 객체</span>
<strong th:text="${#lists.size(objects)}">0</strong>
</a>
<a class="summary-tile" href="/permissions">
<span class="label">역할</span>
<strong th:text="${#lists.size(roles)}">0</strong>
</a>
<a class="summary-tile" href="/tokens">
<span class="label">토큰</span>
<strong th:text="${#lists.size(tokens)}">0</strong>
</a>
</section>
<section class="content-band">
<div class="section-heading">
<h2>최근 보호 객체</h2>
<a class="btn btn-sm btn-outline-primary" href="/probe">검증 실행</a>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Owner</th>
<th>Object</th>
<th>ORDS Path</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr th:each="object : ${objects}">
<td th:text="${object.owner()}">ADMIN</td>
<td th:text="${object.objectName()}">CB_V_SEARCH_DOCUMENTS</td>
<td><code th:text="${object.ordsPath()}">search/documents</code></td>
<td><span class="badge text-bg-success">enabled</span></td>
</tr>
<tr th:if="${#lists.isEmpty(objects)}">
<td colspan="4" class="text-muted">등록된 보호 객체가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('오류')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="alert alert-danger">
<strong>처리할 수 없습니다.</strong>
<span th:text="${errorMessage} ?: '요청 처리 중 오류가 발생했습니다.'"></span>
</div>
<a class="btn btn-outline-primary" href="/">대시보드</a>
</main>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:fragment="head(title)">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${title}">VPD 백오피스</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/css/app.css" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
<script defer src="/js/app.js"></script>
</head>
<body>
<nav th:fragment="nav" class="navbar navbar-expand-lg bg-body border-bottom">
<div class="container">
<a class="navbar-brand" href="/">VPD Backoffice</a>
<div class="navbar-nav">
<a class="nav-link" href="/permissions">권한</a>
<a class="nav-link" href="/tokens">토큰</a>
<a class="nav-link" href="/probe">ORDS 검증</a>
</div>
<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-secondary" type="submit">로그아웃</button>
</form>
</div>
</nav>
</body>
</html>

View File

@@ -0,0 +1,39 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="result">
<div class="section-heading">
<h2>검증 결과</h2>
<span class="badge" th:classappend="${result.status().name() == 'SUCCESS'} ? ' text-bg-success' : ' text-bg-warning'"
th:text="${result.status()}">SUCCESS</span>
</div>
<div class="alert alert-warning" th:if="${result.errorCode()}">
<strong th:text="${result.errorCode()}">ERROR</strong>
<span th:text="${result.errorMessage()}">message</span>
</div>
<div class="meta-row">
<span>Rows: <strong th:text="${result.rowCount()}">0</strong></span>
<span th:if="${!#lists.isEmpty(result.maskedColumns())}">
Masked: <code th:text="${#strings.listJoin(result.maskedColumns(), ', ')}"></code>
</span>
</div>
<div class="table-responsive" th:if="${!#lists.isEmpty(result.rows())}">
<table class="table table-sm table-striped align-middle">
<thead>
<tr>
<th th:each="column : ${result.columns()}" th:text="${column}">column</th>
</tr>
</thead>
<tbody>
<tr th:each="row : ${result.rows()}">
<td th:each="column : ${result.columns()}" th:text="${row[column]}">value</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,47 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('권한 관리')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>권한 관리</h1>
<p>역할별 보호 객체 접근과 행 규칙을 저장합니다.</p>
</div>
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
<section class="content-band">
<form method="post" action="/permissions" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
역할
<select class="form-select" name="roleId" required>
<option th:each="role : ${roles}" th:value="${role.roleId()}" th:text="${role.roleName()}"></option>
</select>
</label>
<label>
보호 객체
<select class="form-select" name="objectId" required>
<option th:each="object : ${objects}" th:value="${object.objectId()}" th:text="${object.displayName()}"></option>
</select>
</label>
<label>
행 규칙
<select class="form-select" name="ruleType">
<option value="ALL">ALL</option>
<option value="REGION">REGION</option>
<option value="MY_DEPT">MY_DEPT</option>
<option value="SELF">SELF</option>
</select>
</label>
<label>
규칙 값
<input class="form-control" name="ruleValue" placeholder="APAC 또는 HR">
</label>
<button class="btn btn-primary" type="submit">저장</button>
</form>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,44 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('ORDS 검증')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>ORDS 검증</h1>
<p>Bearer Token과 보호 객체를 선택해 VPD/Redaction 적용 결과를 확인합니다.</p>
</div>
<section class="content-band">
<form hx-post="/probe" hx-target="#probe-result" hx-swap="innerHTML" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
Token ID
<select class="form-select" name="keyId" required>
<option th:each="token : ${tokens}" th:value="${token.keyId()}" th:text="${token.keyPrefix()}"></option>
</select>
</label>
<label>
Bearer Token 원문
<input class="form-control" name="bearerToken" type="password" autocomplete="off" required>
</label>
<label>
보호 객체
<select class="form-select" name="objectId" required>
<option th:each="object : ${objects}" th:value="${object.objectId()}" th:text="${object.displayName()}"></option>
</select>
</label>
<label>
Limit
<input class="form-control" name="limit" type="number" min="1" max="500" value="50">
</label>
<button class="btn btn-primary" type="submit">호출</button>
</form>
</section>
<section id="probe-result" class="content-band">
<div class="text-muted">검증 결과가 여기에 표시됩니다.</div>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,79 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('Bearer Token')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>Bearer Token</h1>
<p>토큰은 발급 직후 한 번만 원문을 표시합니다.</p>
</div>
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
<div class="alert alert-warning" th:if="${issued}">
<div class="fw-semibold">새 토큰이 발급되었습니다. 이 값은 다시 표시되지 않습니다.</div>
<code class="token-value" th:text="${issued.plainToken()}"></code>
</div>
<section class="content-band">
<h2>토큰 발급</h2>
<form method="post" action="/tokens" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
사용자
<select class="form-select" name="userId" required>
<option th:each="user : ${users}" th:value="${user.userId()}" th:text="${user.username()}"></option>
</select>
</label>
<label>
만료 시각
<input class="form-control" name="expiresAt" placeholder="2026-12-31T23:59:59+09:00" required>
</label>
<label>
설명
<input class="form-control" name="description" maxlength="200">
</label>
<button class="btn btn-primary" type="submit">발급</button>
</form>
</section>
<section class="content-band">
<h2>토큰 목록</h2>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>ID</th>
<th>User</th>
<th>Prefix</th>
<th>Expires</th>
<th>Revoked</th>
<th></th>
</tr>
</thead>
<tbody>
<tr th:each="token : ${tokens}">
<td th:text="${token.keyId()}">1</td>
<td th:text="${token.username()}">user</td>
<td><code th:text="${token.keyPrefix()}">vpd_live_x</code></td>
<td th:text="${token.expiresAt()}">2026</td>
<td th:text="${token.revokedAt()} ?: '-'">-</td>
<td>
<form method="post" action="/tokens/revoke" class="inline-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="keyId" th:value="${token.keyId()}">
<input type="hidden" name="reason" value="manual">
<button class="btn btn-sm btn-outline-danger" type="submit" th:disabled="${token.revokedAt() != null}">회수</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(tokens)}">
<td colspan="6" class="text-muted">등록된 토큰이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>