[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

3
.gitignore vendored
View File

@@ -14,6 +14,9 @@ wallet/
logs/ logs/
*.log *.log
# Java / Maven
target/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db

View File

@@ -144,6 +144,7 @@ src/test/java/com/cloudhandson/vpdbackoffice/
- `owner`, `object_name`, `column_name`은 Oracle identifier 허용 문자로 검증하되, 검증 후에도 SQL 문자열 직접 조합에 사용하지 않는다. - `owner`, `object_name`, `column_name`은 Oracle identifier 허용 문자로 검증하되, 검증 후에도 SQL 문자열 직접 조합에 사용하지 않는다.
- ORDS 검증 대상은 `CB_PROTECTED_OBJECT.enabled_yn = 'Y'`인 행만 허용한다. - ORDS 검증 대상은 `CB_PROTECTED_OBJECT.enabled_yn = 'Y'`인 행만 허용한다.
- Token 원문은 발급 응답 화면에서만 사용하고 DB, log, audit에는 저장하지 않는다. - Token 원문은 발급 응답 화면에서만 사용하고 DB, log, audit에는 저장하지 않는다.
- 기존 ORDS 패키지가 `STANDARD_HASH(p_bearer_key, 'SHA256')`로 Bearer Key를 검증하므로 `key_hash`는 SHA-256으로 저장한다. HMAC 또는 pepper 적용은 DB 패키지 검증 방식까지 함께 바꾸는 후속 작업으로 둔다.
- `rule_type``ALL`, `MY_DEPT`, `SELF`, `REGION`, `CUSTOM_PREDICATE` 중 허용 값만 저장한다. - `rule_type``ALL`, `MY_DEPT`, `SELF`, `REGION`, `CUSTOM_PREDICATE` 중 허용 값만 저장한다.
- `CUSTOM_PREDICATE`는 초기 구현에서 비활성화한다. - `CUSTOM_PREDICATE`는 초기 구현에서 비활성화한다.
@@ -182,7 +183,7 @@ src/test/java/com/cloudhandson/vpdbackoffice/
1. 관리자가 사용자와 만료일을 선택한다. 1. 관리자가 사용자와 만료일을 선택한다.
2. Service가 사용자 활성 상태와 만료일을 검증한다. 2. Service가 사용자 활성 상태와 만료일을 검증한다.
3. `vpd_live_` prefix와 충분한 난수 token body를 생성한다. 3. `vpd_live_` prefix와 충분한 난수 token body를 생성한다.
4. server-side pepper를 사용해 hash를 만든다. 4. 기존 ORDS 패키지와 호환되는 SHA-256 hash를 만든다.
5. prefix, hash, 만료일만 저장한다. 5. prefix, hash, 만료일만 저장한다.
6. 원문 token은 발급 완료 화면에 한 번만 표시한다. 6. 원문 token은 발급 완료 화면에 한 번만 표시한다.

View File

@@ -37,7 +37,7 @@ IssuedToken issueToken(TokenIssueCommand command)
3. 정책상 최대 유효 기간을 넘으면 실패한다. 3. 정책상 최대 유효 기간을 넘으면 실패한다.
4. `vpd_live_` prefix와 256bit 이상 난수 body를 생성한다. 4. `vpd_live_` prefix와 256bit 이상 난수 body를 생성한다.
5. 원문 token을 조합한다. 5. 원문 token을 조합한다.
6. server-side pepper를 읽어 HMAC-SHA256 hash를 만든다. 6. 기존 ORDS 패키지의 `STANDARD_HASH(..., 'SHA256')` 검증과 호환되는 SHA-256 hash를 만든다.
7. `key_prefix`, `key_hash`, `expires_at`, `created_by`를 저장한다. 7. `key_prefix`, `key_hash`, `expires_at`, `created_by`를 저장한다.
8. audit log를 저장한다. 8. audit log를 저장한다.
9. 원문 token을 포함한 `IssuedToken`을 반환한다. 9. 원문 token을 포함한 `IssuedToken`을 반환한다.
@@ -49,7 +49,6 @@ IssuedToken issueToken(TokenIssueCommand command)
| 사용자가 없음 | 발급 중단 | `UserNotFoundException` | | 사용자가 없음 | 발급 중단 | `UserNotFoundException` |
| 비활성 사용자 | 발급 중단 | `InactiveUserException` | | 비활성 사용자 | 발급 중단 | `InactiveUserException` |
| 만료일이 과거 | 발급 중단 | `InvalidTokenExpiryException` | | 만료일이 과거 | 발급 중단 | `InvalidTokenExpiryException` |
| pepper 설정 없음 | 발급 중단 | `TokenConfigurationException` |
| DB 저장 실패 | transaction rollback | `DataAccessException` | | DB 저장 실패 | transaction rollback | `DataAccessException` |
## 7. 엣지케이스 ## 7. 엣지케이스
@@ -78,7 +77,7 @@ IssuedToken issueToken(TokenIssueCommand command)
- [ ] 정상: 저장된 값에 원문 token이 포함되지 않음 - [ ] 정상: 저장된 값에 원문 token이 포함되지 않음
- [ ] 실패: 비활성 사용자 입력 시 예외 - [ ] 실패: 비활성 사용자 입력 시 예외
- [ ] 실패: 과거 만료일 입력 시 예외 - [ ] 실패: 과거 만료일 입력 시 예외
- [ ] 실패: pepper 설정 없음 - [ ] 정상: `cb_hr_key` hash가 Oracle `STANDARD_HASH('cb_hr_key', 'SHA256')`와 같은 값
## 11. 추적성 ## 11. 추적성

76
pom.xml Normal file
View File

@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.cloudhandson</groupId>
<artifactId>vpd-permission-backoffice</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>vpd-permission-backoffice</name>
<description>Spring Boot backoffice for VPD, bearer token, and ORDS permission probes</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.8</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<mybatis-spring-boot.version>3.0.4</mybatis-spring-boot.version>
<oracle.jdbc.version>23.6.0.24.10</oracle.jdbc.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis-spring-boot.version}</version>
</dependency>
<dependency>
<groupId>com.oracle.database.jdbc</groupId>
<artifactId>ojdbc11</artifactId>
<version>${oracle.jdbc.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

10
run.sh
View File

@@ -9,6 +9,7 @@
# ./run.sh adb # ADB 측 cleanup → dblinks → perm/ctx/view/policy → end_users # ./run.sh adb # ADB 측 cleanup → dblinks → perm/ctx/view/policy → end_users
# ./run.sh tests # 4-user (my/pg/both/none) 로 접속해서 행 필터 검증 # ./run.sh tests # 4-user (my/pg/both/none) 로 접속해서 행 필터 검증
# ./run.sh audit # admin 으로 정책/뷰/유저 상태 점검 # ./run.sh audit # admin 으로 정책/뷰/유저 상태 점검
# ./run.sh backoffice-support # Spring Boot 백오피스 보조 객체 생성
# ./run.sh all # source → adb → tests → audit # ./run.sh all # source → adb → tests → audit
# ./run.sh teardown # ADB 측 객체 + 원격 link/cred 만 정리 (원격 PG/MySQL 데이터는 보존) # ./run.sh teardown # ADB 측 객체 + 원격 link/cred 만 정리 (원격 PG/MySQL 데이터는 보존)
# #
@@ -167,6 +168,12 @@ do_audit() {
ok "audit 완료" ok "audit 완료"
} }
do_backoffice_support() {
log "=== backoffice-support: Spring Boot 백오피스 보조 객체 생성 ==="
run_sqlplus_file "$ROOT/sql/adb/25_agent_ords_security_backoffice_support.sql"
ok "backoffice-support 완료"
}
do_teardown() { do_teardown() {
log "=== teardown: ADB 측 객체 + dblink/credential 정리 ===" log "=== teardown: ADB 측 객체 + dblink/credential 정리 ==="
warn "원격 PG/MySQL 의 customers 테이블은 건드리지 않습니다 (수동으로 DROP 하세요)" warn "원격 PG/MySQL 의 customers 테이블은 건드리지 않습니다 (수동으로 DROP 하세요)"
@@ -218,6 +225,7 @@ case "$CMD" in
adb) do_prereq; do_adb ;; adb) do_prereq; do_adb ;;
tests) do_prereq; do_tests ;; tests) do_prereq; do_tests ;;
audit) do_prereq; do_audit ;; audit) do_prereq; do_audit ;;
backoffice-support) do_prereq; do_backoffice_support ;;
teardown) do_prereq; do_teardown ;; teardown) do_prereq; do_teardown ;;
all) all)
do_prereq do_prereq
@@ -237,6 +245,6 @@ case "$CMD" in
ok "=== DDS DONE — Deep Data Security 변형 셋업 + 검증 통과 ===" ok "=== DDS DONE — Deep Data Security 변형 셋업 + 검증 통과 ==="
;; ;;
*) *)
die "알 수 없는 명령: $CMD (사용: prereq|source|adb|tests|audit|all|teardown | dds|dds-setup|dds-tests|dds-teardown)" die "알 수 없는 명령: $CMD (사용: prereq|source|adb|tests|audit|backoffice-support|all|teardown | dds|dds-setup|dds-tests|dds-teardown)"
;; ;;
esac esac

View File

@@ -0,0 +1,115 @@
-- ============================================================
-- 25_agent_ords_security_backoffice_support.sql
-- Support objects for the Spring Boot VPD/ORDS backoffice.
--
-- Run as ADMIN after 17_agent_ords_security_local_vpd_setup.sql.
-- This script keeps the existing CB_* VPD model and adds only the
-- metadata/audit objects needed by the backoffice UI.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO ON
SET FEEDBACK ON
PROMPT === Adding optional description column to bearer keys ===
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE cb_agent_bearer_key ADD (description VARCHAR2(200))';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -1430 THEN -- ORA-01430: column being added already exists
RAISE;
END IF;
END;
/
PROMPT === Creating backoffice sequences ===
DECLARE
PROCEDURE create_seq(p_name IN VARCHAR2, p_start IN NUMBER) AS
BEGIN
EXECUTE IMMEDIATE
'CREATE SEQUENCE ' || p_name || ' START WITH ' || p_start || ' INCREMENT BY 1 NOCACHE';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -955 THEN
RAISE;
END IF;
END;
BEGIN
create_seq('cb_agent_bearer_key_seq', 1000);
create_seq('cb_permission_seq', 1000);
create_seq('cb_permission_rule_seq', 10000);
create_seq('cb_ords_probe_audit_seq', 1);
END;
/
PROMPT === Creating protected object whitelist ===
CREATE TABLE cb_protected_object (
object_id NUMBER PRIMARY KEY,
owner VARCHAR2(128) NOT NULL,
object_name VARCHAR2(128) NOT NULL UNIQUE,
ords_path VARCHAR2(300) NOT NULL,
enabled_yn CHAR(1) DEFAULT 'Y' CHECK (enabled_yn IN ('Y','N')) NOT NULL
);
CREATE TABLE cb_protected_column (
column_id NUMBER PRIMARY KEY,
object_id NUMBER NOT NULL REFERENCES cb_protected_object(object_id),
column_name VARCHAR2(128) NOT NULL,
sensitive_yn CHAR(1) DEFAULT 'N' CHECK (sensitive_yn IN ('Y','N')) NOT NULL,
visible_role_id NUMBER,
CONSTRAINT cb_protected_column_uk UNIQUE (object_id, column_name)
);
CREATE TABLE cb_permission_column (
permission_id NUMBER NOT NULL,
column_name VARCHAR2(128) NOT NULL,
CONSTRAINT cb_permission_column_pk PRIMARY KEY (permission_id, column_name)
);
CREATE TABLE cb_ords_probe_audit (
audit_id NUMBER PRIMARY KEY,
event_type VARCHAR2(50) NOT NULL,
key_id NUMBER,
object_id NUMBER,
status VARCHAR2(50),
row_count NUMBER,
error_code VARCHAR2(100),
message VARCHAR2(500),
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
);
PROMPT === Seeding default VPD ORDS protected object ===
MERGE INTO cb_protected_object dst
USING (
SELECT 1 AS object_id,
'ADMIN' AS owner,
'CB_V_SEARCH_DOCUMENTS' AS object_name,
'cb-agent-security/vpd/documents' AS ords_path,
'Y' AS enabled_yn
FROM dual
) src
ON (dst.object_id = src.object_id)
WHEN MATCHED THEN UPDATE SET
dst.owner = src.owner,
dst.object_name = src.object_name,
dst.ords_path = src.ords_path,
dst.enabled_yn = src.enabled_yn
WHEN NOT MATCHED THEN INSERT (object_id, owner, object_name, ords_path, enabled_yn)
VALUES (src.object_id, src.owner, src.object_name, src.ords_path, src.enabled_yn);
MERGE INTO cb_protected_column dst
USING (
SELECT 1 column_id, 1 object_id, 'DOC_ID' column_name, 'N' sensitive_yn FROM dual UNION ALL
SELECT 2, 1, 'TITLE', 'N' FROM dual UNION ALL
SELECT 3, 1, 'OWNER_EMP_NO', 'N' FROM dual UNION ALL
SELECT 4, 1, 'DEPT_CODE', 'N' FROM dual UNION ALL
SELECT 5, 1, 'CONTENTS', 'Y' FROM dual
) src
ON (dst.object_id = src.object_id AND dst.column_name = src.column_name)
WHEN MATCHED THEN UPDATE SET dst.sensitive_yn = src.sensitive_yn
WHEN NOT MATCHED THEN INSERT (column_id, object_id, column_name, sensitive_yn)
VALUES (src.column_id, src.object_id, src.column_name, src.sensitive_yn);
COMMIT;
PROMPT === Backoffice support setup complete ===
EXIT;

View File

@@ -0,0 +1,14 @@
package com.cloudhandson.vpdbackoffice;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@MapperScan("com.cloudhandson.vpdbackoffice.mapper")
@SpringBootApplication
public class VpdBackofficeApplication {
public static void main(String[] args) {
SpringApplication.run(VpdBackofficeApplication.class, args);
}
}

View File

@@ -0,0 +1,16 @@
package com.cloudhandson.vpdbackoffice.config;
import java.time.Clock;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(BackofficeProperties.class)
public class AppConfig {
@Bean
Clock clock() {
return Clock.systemUTC();
}
}

View File

@@ -0,0 +1,21 @@
package com.cloudhandson.vpdbackoffice.config;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "backoffice")
public record BackofficeProperties(
Security security,
Token token,
Ords ords
) {
public record Security(String adminUser, String adminPassword) {
}
public record Token(int maxDays) {
}
public record Ords(String baseUrl, Duration timeout) {
}
}

View File

@@ -0,0 +1,20 @@
package com.cloudhandson.vpdbackoffice.config;
import java.time.Duration;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class OrdsClientConfig {
@Bean
RestTemplate ordsRestTemplate(BackofficeProperties properties) {
Duration timeout = properties.ords().timeout();
return new RestTemplateBuilder()
.setConnectTimeout(timeout)
.setReadTimeout(timeout)
.build();
}
}

View File

@@ -0,0 +1,45 @@
package com.cloudhandson.vpdbackoffice.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/css/**", "/js/**", "/webjars/**").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.logout(logout -> logout.logoutSuccessUrl("/login?logout"))
.build();
}
@Bean
UserDetailsService userDetailsService(
BackofficeProperties properties,
PasswordEncoder passwordEncoder
) {
var security = properties.security();
var user = User.withUsername(security.adminUser())
.password(passwordEncoder.encode(security.adminPassword()))
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user);
}
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}

View File

@@ -0,0 +1,12 @@
package com.cloudhandson.vpdbackoffice.domain.audit;
public record AuditEvent(
String eventType,
Long keyId,
Long objectId,
String status,
Integer rowCount,
String errorCode,
String message
) {
}

View File

@@ -0,0 +1,8 @@
package com.cloudhandson.vpdbackoffice.domain.permission;
public record AppRole(
long roleId,
String roleName,
String description
) {
}

View File

@@ -0,0 +1,9 @@
package com.cloudhandson.vpdbackoffice.domain.permission;
public record PermissionRule(
long ruleId,
long permissionId,
String ruleType,
String ruleValue
) {
}

View File

@@ -0,0 +1,13 @@
package com.cloudhandson.vpdbackoffice.domain.permission;
import java.util.List;
public record PermissionSet(
long permissionId,
long roleId,
long objectId,
String action,
List<PermissionRule> rules,
List<String> visibleColumns
) {
}

View File

@@ -0,0 +1,15 @@
package com.cloudhandson.vpdbackoffice.domain.permission;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import java.util.List;
public record PermissionSetCommand(
@Positive long roleId,
@Positive long objectId,
@NotBlank String action,
@NotEmpty List<RuleCommand> rules,
List<String> visibleColumns
) {
}

View File

@@ -0,0 +1,9 @@
package com.cloudhandson.vpdbackoffice.domain.permission;
import jakarta.validation.constraints.NotBlank;
public record RuleCommand(
@NotBlank String ruleType,
String ruleValue
) {
}

View File

@@ -0,0 +1,14 @@
package com.cloudhandson.vpdbackoffice.domain.probe;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
public record ProbeCommand(
@Positive long keyId,
@Positive long objectId,
@NotBlank String bearerToken,
@Min(1) @Max(500) int limit
) {
}

View File

@@ -0,0 +1,19 @@
package com.cloudhandson.vpdbackoffice.domain.probe;
import java.util.List;
import java.util.Map;
public record ProbeResult(
ProbeStatus status,
List<String> columns,
List<Map<String, Object>> rows,
int rowCount,
List<String> maskedColumns,
String errorCode,
String errorMessage
) {
public static ProbeResult blocked(ProbeStatus status, String errorCode, String errorMessage) {
return new ProbeResult(status, List.of(), List.of(), 0, List.of(), errorCode, errorMessage);
}
}

View File

@@ -0,0 +1,14 @@
package com.cloudhandson.vpdbackoffice.domain.probe;
public enum ProbeStatus {
SUCCESS,
VPD_DENY_EMPTY_RESULT,
TOKEN_NOT_FOUND,
TOKEN_INACTIVE,
OBJECT_DISABLED,
INVALID_TOKEN,
OBJECT_NOT_ACCESSIBLE,
ORDS_TIMEOUT,
INVALID_ORDS_RESPONSE,
UNKNOWN_ERROR
}

View File

@@ -0,0 +1,14 @@
package com.cloudhandson.vpdbackoffice.domain.protectedobject;
public record ProtectedColumn(
long columnId,
long objectId,
String columnName,
String sensitiveYn,
Long visibleRoleId
) {
public boolean sensitive() {
return "Y".equalsIgnoreCase(sensitiveYn);
}
}

View File

@@ -0,0 +1,18 @@
package com.cloudhandson.vpdbackoffice.domain.protectedobject;
public record ProtectedObject(
long objectId,
String owner,
String objectName,
String ordsPath,
String enabledYn
) {
public boolean enabled() {
return "Y".equalsIgnoreCase(enabledYn);
}
public String displayName() {
return owner + "." + objectName;
}
}

View File

@@ -0,0 +1,19 @@
package com.cloudhandson.vpdbackoffice.domain.token;
import java.time.OffsetDateTime;
public record BearerTokenRecord(
long keyId,
long userId,
String username,
String keyPrefix,
String keyHash,
OffsetDateTime expiresAt,
OffsetDateTime revokedAt,
String description
) {
public boolean active(OffsetDateTime now) {
return revokedAt == null && expiresAt != null && expiresAt.isAfter(now);
}
}

View File

@@ -0,0 +1,11 @@
package com.cloudhandson.vpdbackoffice.domain.token;
import java.time.OffsetDateTime;
public record IssuedToken(
long keyId,
String prefix,
String plainToken,
OffsetDateTime expiresAt
) {
}

View File

@@ -0,0 +1,13 @@
package com.cloudhandson.vpdbackoffice.domain.token;
import jakarta.validation.constraints.Future;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.time.OffsetDateTime;
public record TokenIssueCommand(
@Positive long userId,
@Future OffsetDateTime expiresAt,
@Size(max = 200) String description
) {
}

View File

@@ -0,0 +1,14 @@
package com.cloudhandson.vpdbackoffice.domain.user;
public record AppUser(
long userId,
String username,
String empNo,
String deptCode,
String activeYn
) {
public boolean active() {
return "Y".equalsIgnoreCase(activeYn);
}
}

View File

@@ -0,0 +1,10 @@
package com.cloudhandson.vpdbackoffice.mapper;
import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AuditMapper {
void insert(AuditEvent event);
}

View File

@@ -0,0 +1,25 @@
package com.cloudhandson.vpdbackoffice.mapper;
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
import java.time.OffsetDateTime;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface BearerTokenMapper {
List<BearerTokenRecord> findAll();
BearerTokenRecord findById(@Param("keyId") long keyId);
long nextKeyId();
void insertToken(BearerTokenRecord token);
int revokeToken(
@Param("keyId") long keyId,
@Param("revokedAt") OffsetDateTime revokedAt,
@Param("reason") String reason
);
}

View File

@@ -0,0 +1,39 @@
package com.cloudhandson.vpdbackoffice.mapper;
import com.cloudhandson.vpdbackoffice.domain.permission.AppRole;
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionRule;
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionSet;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface PermissionMapper {
List<AppRole> findRoles();
AppRole findRole(@Param("roleId") long roleId);
PermissionSet findPermissionSet(@Param("roleId") long roleId, @Param("objectId") long objectId);
Long findPermissionId(@Param("roleId") long roleId, @Param("objectId") long objectId);
void insertPermission(@Param("permissionId") long permissionId,
@Param("roleId") long roleId,
@Param("objectId") long objectId,
@Param("action") String action);
void updatePermissionAction(@Param("permissionId") long permissionId, @Param("action") String action);
void deleteRules(@Param("permissionId") long permissionId);
void insertRule(PermissionRule rule);
void deleteVisibleColumns(@Param("permissionId") long permissionId);
void insertVisibleColumn(@Param("permissionId") long permissionId, @Param("columnName") String columnName);
long nextPermissionId();
long nextRuleId();
}

View File

@@ -0,0 +1,17 @@
package com.cloudhandson.vpdbackoffice.mapper;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface ProtectedObjectMapper {
List<ProtectedObject> findEnabled();
ProtectedObject findById(@Param("objectId") long objectId);
List<ProtectedColumn> findColumns(@Param("objectId") long objectId);
}

View File

@@ -0,0 +1,14 @@
package com.cloudhandson.vpdbackoffice.mapper;
import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface UserMapper {
List<AppUser> findAll();
AppUser findById(@Param("userId") long userId);
}

View File

@@ -0,0 +1,8 @@
package com.cloudhandson.vpdbackoffice.service;
public class AppException extends RuntimeException {
public AppException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,19 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent;
import com.cloudhandson.vpdbackoffice.mapper.AuditMapper;
import org.springframework.stereotype.Service;
@Service
public class AuditService {
private final AuditMapper auditMapper;
public AuditService(AuditMapper auditMapper) {
this.auditMapper = auditMapper;
}
public void record(AuditEvent event) {
auditMapper.insert(event);
}
}

View File

@@ -0,0 +1,107 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent;
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
import com.cloudhandson.vpdbackoffice.domain.token.IssuedToken;
import com.cloudhandson.vpdbackoffice.domain.token.TokenIssueCommand;
import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
import com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper;
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
import java.time.Clock;
import java.time.OffsetDateTime;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class BearerTokenService {
private final BearerTokenMapper tokenMapper;
private final UserMapper userMapper;
private final AuditService auditService;
private final TokenGenerator tokenGenerator;
private final TokenHasher tokenHasher;
private final BackofficeProperties properties;
private final Clock clock;
public BearerTokenService(
BearerTokenMapper tokenMapper,
UserMapper userMapper,
AuditService auditService,
TokenGenerator tokenGenerator,
TokenHasher tokenHasher,
BackofficeProperties properties,
Clock clock
) {
this.tokenMapper = tokenMapper;
this.userMapper = userMapper;
this.auditService = auditService;
this.tokenGenerator = tokenGenerator;
this.tokenHasher = tokenHasher;
this.properties = properties;
this.clock = clock;
}
public List<BearerTokenRecord> findAll() {
return tokenMapper.findAll();
}
public BearerTokenRecord findById(long keyId) {
return tokenMapper.findById(keyId);
}
public boolean matches(BearerTokenRecord record, String plainToken) {
if (record == null || plainToken == null || plainToken.isBlank()) {
return false;
}
String hash = tokenHasher.sha256(plainToken);
return hash.equalsIgnoreCase(record.keyHash());
}
@Transactional
public IssuedToken issueToken(TokenIssueCommand command) {
AppUser user = userMapper.findById(command.userId());
if (user == null) {
throw new AppException("사용자를 찾을 수 없습니다.");
}
if (!user.active()) {
throw new AppException("비활성 사용자에게는 토큰을 발급할 수 없습니다.");
}
OffsetDateTime now = OffsetDateTime.now(clock);
if (command.expiresAt() == null || !command.expiresAt().isAfter(now)) {
throw new AppException("만료일은 현재 시각 이후여야 합니다.");
}
if (command.expiresAt().isAfter(now.plusDays(properties.token().maxDays()))) {
throw new AppException("토큰 만료일이 최대 허용 기간을 초과했습니다.");
}
String plainToken = tokenGenerator.generate();
String prefix = tokenGenerator.prefix(plainToken);
String hash = tokenHasher.sha256(plainToken);
long keyId = tokenMapper.nextKeyId();
tokenMapper.insertToken(new BearerTokenRecord(
keyId,
user.userId(),
user.username(),
prefix,
hash,
command.expiresAt(),
null,
command.description()
));
auditService.record(new AuditEvent("TOKEN_ISSUED", keyId, null, "SUCCESS", null, null, "issued"));
return new IssuedToken(keyId, prefix, plainToken, command.expiresAt());
}
@Transactional
public void revokeToken(long keyId, String reason) {
int updated = tokenMapper.revokeToken(keyId, OffsetDateTime.now(clock), reason);
if (updated == 0) {
throw new AppException("회수할 활성 토큰을 찾을 수 없습니다.");
}
auditService.record(new AuditEvent("TOKEN_REVOKED", keyId, null, "SUCCESS", null, null, reason));
}
}

View File

@@ -0,0 +1,201 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeStatus;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.time.Clock;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
@Service
public class OrdsProbeService {
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {
};
private final BearerTokenService tokenService;
private final ProtectedObjectService protectedObjectService;
private final AuditService auditService;
private final ProbeErrorClassifier errorClassifier;
private final RestTemplate ordsRestTemplate;
private final ObjectMapper objectMapper;
private final BackofficeProperties properties;
private final Clock clock;
public OrdsProbeService(
BearerTokenService tokenService,
ProtectedObjectService protectedObjectService,
AuditService auditService,
ProbeErrorClassifier errorClassifier,
RestTemplate ordsRestTemplate,
ObjectMapper objectMapper,
BackofficeProperties properties,
Clock clock
) {
this.tokenService = tokenService;
this.protectedObjectService = protectedObjectService;
this.auditService = auditService;
this.errorClassifier = errorClassifier;
this.ordsRestTemplate = ordsRestTemplate;
this.objectMapper = objectMapper;
this.properties = properties;
this.clock = clock;
}
public ProbeResult runProbe(ProbeCommand command) {
BearerTokenRecord token = tokenService.findById(command.keyId());
if (token == null) {
return auditAndReturn(command, ProbeResult.blocked(
ProbeStatus.TOKEN_NOT_FOUND, "TOKEN_NOT_FOUND", "토큰을 찾을 수 없습니다."));
}
if (!token.active(OffsetDateTime.now(clock))) {
return auditAndReturn(command, ProbeResult.blocked(
ProbeStatus.TOKEN_INACTIVE, "TOKEN_INACTIVE", "만료되었거나 회수된 토큰입니다."));
}
if (!tokenService.matches(token, command.bearerToken())) {
return auditAndReturn(command, ProbeResult.blocked(
ProbeStatus.INVALID_TOKEN, "INVALID_TOKEN", "입력한 Bearer Token이 선택한 key와 일치하지 않습니다."));
}
ProtectedObject object;
try {
object = protectedObjectService.assertEnabled(command.objectId());
} catch (AppException e) {
return auditAndReturn(command, ProbeResult.blocked(
ProbeStatus.OBJECT_DISABLED, "OBJECT_DISABLED", e.getMessage()));
}
try {
URI uri = buildUri(object.ordsPath(), command.limit());
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(command.bearerToken());
ResponseEntity<String> response = ordsRestTemplate.exchange(
uri, HttpMethod.POST, new HttpEntity<>(headers), String.class);
ProbeResult result = parseSuccess(response.getBody(), object.objectId());
return auditAndReturn(command, result);
} catch (HttpStatusCodeException e) {
ProbeStatus status = errorClassifier.classify(e.getStatusCode(), e.getResponseBodyAsString());
return auditAndReturn(command, ProbeResult.blocked(
status, status.name(), trimMessage(e.getResponseBodyAsString())));
} catch (ResourceAccessException e) {
ProbeStatus status = errorClassifier.isTimeout(e) ? ProbeStatus.ORDS_TIMEOUT : ProbeStatus.UNKNOWN_ERROR;
return auditAndReturn(command, ProbeResult.blocked(status, status.name(), e.getMessage()));
} catch (Exception e) {
return auditAndReturn(command, ProbeResult.blocked(
ProbeStatus.INVALID_ORDS_RESPONSE, "INVALID_ORDS_RESPONSE", e.getMessage()));
}
}
private URI buildUri(String ordsPath, int limit) {
String baseUrl = properties.ords().baseUrl();
String path = ordsPath.startsWith("/") ? ordsPath.substring(1) : ordsPath;
return UriComponentsBuilder.fromUriString(baseUrl)
.path("/")
.path(path)
.queryParam("limit", limit)
.build()
.toUri();
}
private ProbeResult parseSuccess(String body, long objectId) throws Exception {
JsonNode root = objectMapper.readTree(body);
JsonNode rowsNode = root.has("rows") ? root.get("rows") : root;
rowsNode = root.has("items") ? root.get("items") : rowsNode;
if (!rowsNode.isArray()) {
throw new AppException("ORDS 응답에 rows 배열이 없습니다.");
}
List<Map<String, Object>> rows = new ArrayList<>();
Set<String> columns = new LinkedHashSet<>();
for (JsonNode rowNode : rowsNode) {
Map<String, Object> row = objectMapper.convertValue(rowNode, MAP_TYPE);
rows.add(row);
columns.addAll(row.keySet());
}
ProbeStatus status = rows.isEmpty() ? ProbeStatus.VPD_DENY_EMPTY_RESULT : ProbeStatus.SUCCESS;
return new ProbeResult(
status,
List.copyOf(columns),
rows,
rows.size(),
findMaskedColumns(objectId, rows),
null,
null
);
}
private List<String> findMaskedColumns(long objectId, List<Map<String, Object>> rows) {
if (rows.isEmpty()) {
return List.of();
}
List<String> sensitiveColumns = protectedObjectService.findColumns(objectId).stream()
.filter(ProtectedColumn::sensitive)
.map(column -> column.columnName().toLowerCase(Locale.ROOT))
.toList();
if (sensitiveColumns.isEmpty()) {
return List.of();
}
List<String> masked = new ArrayList<>();
for (String column : sensitiveColumns) {
boolean present = false;
boolean allNull = true;
for (Map<String, Object> row : rows) {
for (Map.Entry<String, Object> entry : row.entrySet()) {
if (entry.getKey().equalsIgnoreCase(column)) {
present = true;
allNull = allNull && entry.getValue() == null;
}
}
}
if (present && allNull) {
masked.add(column);
}
}
return masked;
}
private ProbeResult auditAndReturn(ProbeCommand command, ProbeResult result) {
auditService.record(new AuditEvent(
"ORDS_PROBE",
command.keyId(),
command.objectId(),
result.status().name(),
result.rowCount(),
result.errorCode(),
result.errorMessage()
));
return result;
}
private String trimMessage(String body) {
if (body == null) {
return null;
}
return body.length() <= 500 ? body : body.substring(0, 500);
}
}

View File

@@ -0,0 +1,132 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent;
import com.cloudhandson.vpdbackoffice.domain.permission.AppRole;
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionRule;
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionSet;
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionSetCommand;
import com.cloudhandson.vpdbackoffice.domain.permission.RuleCommand;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
import com.cloudhandson.vpdbackoffice.mapper.PermissionMapper;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PermissionService {
private static final Set<String> RULE_TYPES = Set.of("ALL", "MY_DEPT", "SELF", "REGION");
private final PermissionMapper permissionMapper;
private final ProtectedObjectService protectedObjectService;
private final AuditService auditService;
public PermissionService(
PermissionMapper permissionMapper,
ProtectedObjectService protectedObjectService,
AuditService auditService
) {
this.permissionMapper = permissionMapper;
this.protectedObjectService = protectedObjectService;
this.auditService = auditService;
}
public List<AppRole> findRoles() {
return permissionMapper.findRoles();
}
@Transactional
public PermissionSet savePermissionSet(PermissionSetCommand command) {
if (!"SELECT".equalsIgnoreCase(command.action())) {
throw new AppException("초기 구현에서는 SELECT 권한만 저장할 수 있습니다.");
}
AppRole role = permissionMapper.findRole(command.roleId());
if (role == null) {
throw new AppException("역할을 찾을 수 없습니다.");
}
protectedObjectService.assertEnabled(command.objectId());
validateRules(command.rules());
validateVisibleColumns(command.objectId(), command.visibleColumns());
Long existingId = permissionMapper.findPermissionId(command.roleId(), command.objectId());
long permissionId = existingId == null ? permissionMapper.nextPermissionId() : existingId;
if (existingId == null) {
permissionMapper.insertPermission(permissionId, command.roleId(), command.objectId(), "SELECT");
} else {
permissionMapper.updatePermissionAction(permissionId, "SELECT");
}
permissionMapper.deleteRules(permissionId);
for (RuleCommand rule : command.rules()) {
permissionMapper.insertRule(new PermissionRule(
permissionMapper.nextRuleId(),
permissionId,
normalize(rule.ruleType()),
clean(rule.ruleValue())
));
}
permissionMapper.deleteVisibleColumns(permissionId);
if (command.visibleColumns() != null) {
for (String columnName : command.visibleColumns()) {
permissionMapper.insertVisibleColumn(permissionId, columnName.trim().toUpperCase(Locale.ROOT));
}
}
auditService.record(new AuditEvent(
"PERMISSION_SAVED", null, command.objectId(), "SUCCESS", null, null,
"roleId=" + command.roleId()
));
return new PermissionSet(permissionId, command.roleId(), command.objectId(), "SELECT", List.of(), List.of());
}
private void validateRules(List<RuleCommand> rules) {
if (rules == null || rules.isEmpty()) {
throw new AppException("행 규칙은 하나 이상 필요합니다.");
}
Set<String> seen = new HashSet<>();
boolean hasAll = false;
for (RuleCommand rule : rules) {
String type = normalize(rule.ruleType());
if (!RULE_TYPES.contains(type)) {
throw new AppException("허용되지 않은 행 규칙입니다: " + type);
}
if (!seen.add(type + ":" + clean(rule.ruleValue()))) {
throw new AppException("중복된 행 규칙이 있습니다.");
}
hasAll = hasAll || "ALL".equals(type);
if (!"ALL".equals(type) && clean(rule.ruleValue()).isBlank()) {
throw new AppException(type + " 규칙에는 값이 필요합니다.");
}
}
if (hasAll && rules.size() > 1) {
throw new AppException("ALL 규칙은 다른 규칙과 함께 저장할 수 없습니다.");
}
}
private void validateVisibleColumns(long objectId, List<String> visibleColumns) {
if (visibleColumns == null || visibleColumns.isEmpty()) {
return;
}
Set<String> allowed = new HashSet<>();
for (ProtectedColumn column : protectedObjectService.findColumns(objectId)) {
allowed.add(column.columnName().toUpperCase(Locale.ROOT));
}
for (String columnName : visibleColumns) {
if (!allowed.contains(columnName.trim().toUpperCase(Locale.ROOT))) {
throw new AppException("등록되지 않은 컬럼입니다: " + columnName);
}
}
}
private String normalize(String value) {
return clean(value).toUpperCase(Locale.ROOT);
}
private String clean(String value) {
return value == null ? "" : value.trim();
}
}

View File

@@ -0,0 +1,36 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeStatus;
import java.net.SocketTimeoutException;
import org.springframework.http.HttpStatusCode;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
@Component
public class ProbeErrorClassifier {
public ProbeStatus classify(HttpStatusCode status, String body) {
String text = body == null ? "" : body;
if (text.contains("ORA-20002")) {
return ProbeStatus.INVALID_TOKEN;
}
if (text.contains("ORA-00942") || text.contains("ORA-01031")) {
return ProbeStatus.OBJECT_NOT_ACCESSIBLE;
}
if (status != null && (status.value() == 401 || status.value() == 403)) {
return ProbeStatus.INVALID_TOKEN;
}
return ProbeStatus.UNKNOWN_ERROR;
}
public boolean isTimeout(ResourceAccessException exception) {
Throwable cause = exception;
while (cause != null) {
if (cause instanceof SocketTimeoutException) {
return true;
}
cause = cause.getCause();
}
return false;
}
}

View File

@@ -0,0 +1,33 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import com.cloudhandson.vpdbackoffice.mapper.ProtectedObjectMapper;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class ProtectedObjectService {
private final ProtectedObjectMapper mapper;
public ProtectedObjectService(ProtectedObjectMapper mapper) {
this.mapper = mapper;
}
public List<ProtectedObject> findEnabled() {
return mapper.findEnabled();
}
public ProtectedObject assertEnabled(long objectId) {
ProtectedObject object = mapper.findById(objectId);
if (object == null || !object.enabled()) {
throw new AppException("활성 보호 객체를 찾을 수 없습니다.");
}
return object;
}
public List<ProtectedColumn> findColumns(long objectId) {
return mapper.findColumns(objectId);
}
}

View File

@@ -0,0 +1,24 @@
package com.cloudhandson.vpdbackoffice.service;
import java.security.SecureRandom;
import java.util.Base64;
import org.springframework.stereotype.Component;
@Component
public class TokenGenerator {
private final SecureRandom secureRandom = new SecureRandom();
public String generate() {
byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes);
return "vpd_live_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
public String prefix(String token) {
if (token == null || token.length() <= 16) {
return token;
}
return token.substring(0, 16);
}
}

View File

@@ -0,0 +1,19 @@
package com.cloudhandson.vpdbackoffice.service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import org.springframework.stereotype.Component;
@Component
public class TokenHasher {
public String sha256(String token) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(token.getBytes(StandardCharsets.UTF_8))).toUpperCase();
} catch (Exception e) {
throw new AppException("Failed to hash bearer token");
}
}
}

View File

@@ -0,0 +1,16 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.AppException;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ControllerAdvice
public class AppExceptionHandler {
@ExceptionHandler(AppException.class)
public String handleAppException(AppException exception, Model model) {
model.addAttribute("errorMessage", exception.getMessage());
return "error";
}
}

View File

@@ -0,0 +1,34 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
import com.cloudhandson.vpdbackoffice.service.PermissionService;
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class DashboardController {
private final ProtectedObjectService protectedObjectService;
private final PermissionService permissionService;
private final BearerTokenService bearerTokenService;
public DashboardController(
ProtectedObjectService protectedObjectService,
PermissionService permissionService,
BearerTokenService bearerTokenService
) {
this.protectedObjectService = protectedObjectService;
this.permissionService = permissionService;
this.bearerTokenService = bearerTokenService;
}
@GetMapping("/")
public String dashboard(Model model) {
model.addAttribute("objects", protectedObjectService.findEnabled());
model.addAttribute("roles", permissionService.findRoles());
model.addAttribute("tokens", bearerTokenService.findAll());
return "dashboard";
}
}

View File

@@ -0,0 +1,55 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionSetCommand;
import com.cloudhandson.vpdbackoffice.domain.permission.RuleCommand;
import com.cloudhandson.vpdbackoffice.service.PermissionService;
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
import java.util.List;
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.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
public class PermissionController {
private final PermissionService permissionService;
private final ProtectedObjectService protectedObjectService;
public PermissionController(
PermissionService permissionService,
ProtectedObjectService protectedObjectService
) {
this.permissionService = permissionService;
this.protectedObjectService = protectedObjectService;
}
@GetMapping("/permissions")
public String permissions(Model model) {
model.addAttribute("roles", permissionService.findRoles());
model.addAttribute("objects", protectedObjectService.findEnabled());
return "permissions";
}
@PostMapping("/permissions")
public String save(
@RequestParam long roleId,
@RequestParam long objectId,
@RequestParam String ruleType,
@RequestParam(required = false) String ruleValue,
@RequestParam(required = false) List<String> visibleColumns,
RedirectAttributes redirectAttributes
) {
permissionService.savePermissionSet(new PermissionSetCommand(
roleId,
objectId,
"SELECT",
List.of(new RuleCommand(ruleType, ruleValue)),
visibleColumns == null ? List.of() : visibleColumns
));
redirectAttributes.addFlashAttribute("message", "권한을 저장했습니다.");
return "redirect:/permissions";
}
}

View File

@@ -0,0 +1,48 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand;
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
import com.cloudhandson.vpdbackoffice.service.OrdsProbeService;
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
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.bind.annotation.RequestParam;
@Controller
public class ProbeController {
private final OrdsProbeService probeService;
private final BearerTokenService tokenService;
private final ProtectedObjectService protectedObjectService;
public ProbeController(
OrdsProbeService probeService,
BearerTokenService tokenService,
ProtectedObjectService protectedObjectService
) {
this.probeService = probeService;
this.tokenService = tokenService;
this.protectedObjectService = protectedObjectService;
}
@GetMapping("/probe")
public String probe(Model model) {
model.addAttribute("tokens", tokenService.findAll());
model.addAttribute("objects", protectedObjectService.findEnabled());
return "probe";
}
@PostMapping("/probe")
public String run(
@RequestParam long keyId,
@RequestParam long objectId,
@RequestParam String bearerToken,
@RequestParam(defaultValue = "50") int limit,
Model model
) {
model.addAttribute("result", probeService.runProbe(new ProbeCommand(keyId, objectId, bearerToken, limit)));
return "fragments/probe-result :: result";
}
}

View File

@@ -0,0 +1,58 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.token.TokenIssueCommand;
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
import java.time.OffsetDateTime;
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.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
public class TokenController {
private final BearerTokenService tokenService;
private final UserMapper userMapper;
public TokenController(BearerTokenService tokenService, UserMapper userMapper) {
this.tokenService = tokenService;
this.userMapper = userMapper;
}
@GetMapping("/tokens")
public String tokens(Model model) {
model.addAttribute("tokens", tokenService.findAll());
model.addAttribute("users", userMapper.findAll());
return "tokens";
}
@PostMapping("/tokens")
public String issue(
@RequestParam long userId,
@RequestParam String expiresAt,
@RequestParam(required = false) String description,
RedirectAttributes redirectAttributes
) {
var issued = tokenService.issueToken(new TokenIssueCommand(
userId,
OffsetDateTime.parse(expiresAt),
description
));
redirectAttributes.addFlashAttribute("issued", issued);
return "redirect:/tokens";
}
@PostMapping("/tokens/revoke")
public String revoke(
@RequestParam long keyId,
@RequestParam(required = false) String reason,
RedirectAttributes redirectAttributes
) {
tokenService.revokeToken(keyId, reason);
redirectAttributes.addFlashAttribute("message", "토큰을 회수했습니다.");
return "redirect:/tokens";
}
}

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>

View File

@@ -0,0 +1,134 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent;
import com.cloudhandson.vpdbackoffice.domain.permission.AppRole;
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionRule;
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionSet;
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionSetCommand;
import com.cloudhandson.vpdbackoffice.domain.permission.RuleCommand;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import com.cloudhandson.vpdbackoffice.mapper.AuditMapper;
import com.cloudhandson.vpdbackoffice.mapper.PermissionMapper;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class PermissionServiceTest {
private PermissionMapper permissionMapper;
private ProtectedObjectService protectedObjectService;
private PermissionService permissionService;
@BeforeEach
void setUp() {
permissionMapper = new FakePermissionMapper();
protectedObjectService = new ProtectedObjectService(null) {
@Override
public ProtectedObject assertEnabled(long objectId) {
return new ProtectedObject(1L, "ADMIN", "CB_V_SEARCH_DOCUMENTS", "cb-agent-security/vpd/documents", "Y");
}
@Override
public List<ProtectedColumn> findColumns(long objectId) {
return List.of();
}
};
AuditService auditService = new AuditService(new NoopAuditMapper());
permissionService = new PermissionService(permissionMapper, protectedObjectService, auditService);
}
@Test
void rejectsAllRuleMixedWithSpecificRule() {
var command = new PermissionSetCommand(
10L,
1L,
"SELECT",
List.of(new RuleCommand("ALL", null), new RuleCommand("REGION", "APAC")),
List.of()
);
assertThatThrownBy(() -> permissionService.savePermissionSet(command))
.isInstanceOf(AppException.class)
.hasMessageContaining("ALL");
}
@Test
void rejectsCustomPredicateRule() {
var command = new PermissionSetCommand(
10L,
1L,
"SELECT",
List.of(new RuleCommand("CUSTOM_PREDICATE", "1=1")),
List.of()
);
assertThatThrownBy(() -> permissionService.savePermissionSet(command))
.isInstanceOf(AppException.class)
.hasMessageContaining("허용되지 않은");
}
private static class NoopAuditMapper implements AuditMapper {
@Override
public void insert(AuditEvent event) {
}
}
private static class FakePermissionMapper implements PermissionMapper {
@Override
public List<AppRole> findRoles() {
return List.of(new AppRole(10L, "HR_DEPT_ROLE", null));
}
@Override
public AppRole findRole(long roleId) {
return roleId == 10L ? new AppRole(10L, "HR_DEPT_ROLE", null) : null;
}
@Override
public PermissionSet findPermissionSet(long roleId, long objectId) {
return null;
}
@Override
public Long findPermissionId(long roleId, long objectId) {
return null;
}
@Override
public void insertPermission(long permissionId, long roleId, long objectId, String action) {
}
@Override
public void updatePermissionAction(long permissionId, String action) {
}
@Override
public void deleteRules(long permissionId) {
}
@Override
public void insertRule(PermissionRule rule) {
}
@Override
public void deleteVisibleColumns(long permissionId) {
}
@Override
public void insertVisibleColumn(long permissionId, String columnName) {
}
@Override
public long nextPermissionId() {
return 1000L;
}
@Override
public long nextRuleId() {
return 10000L;
}
}
}

View File

@@ -0,0 +1,30 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeStatus;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
class ProbeErrorClassifierTest {
private final ProbeErrorClassifier classifier = new ProbeErrorClassifier();
@Test
void classifiesInvalidBearerKey() {
assertThat(classifier.classify(HttpStatus.FORBIDDEN, "{\"error\":\"ORA-20002\"}"))
.isEqualTo(ProbeStatus.INVALID_TOKEN);
}
@Test
void classifiesObjectAccessFailure() {
assertThat(classifier.classify(HttpStatus.FORBIDDEN, "ORA-00942: table or view does not exist"))
.isEqualTo(ProbeStatus.OBJECT_NOT_ACCESSIBLE);
}
@Test
void classifiesHttpUnauthorizedAsInvalidToken() {
assertThat(classifier.classify(HttpStatus.UNAUTHORIZED, "unauthorized"))
.isEqualTo(ProbeStatus.INVALID_TOKEN);
}
}

View File

@@ -0,0 +1,19 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class TokenGeneratorTest {
private final TokenGenerator tokenGenerator = new TokenGenerator();
@Test
void generateCreatesVpdLiveTokenAndSixteenCharacterPrefix() {
String token = tokenGenerator.generate();
assertThat(token).startsWith("vpd_live_");
assertThat(token).hasSizeGreaterThan(40);
assertThat(tokenGenerator.prefix(token)).hasSize(16);
}
}

View File

@@ -0,0 +1,16 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class TokenHasherTest {
private final TokenHasher tokenHasher = new TokenHasher();
@Test
void sha256MatchesOracleStandardHashSeedValue() {
assertThat(tokenHasher.sha256("cb_hr_key"))
.isEqualTo("240CD9BCE11219EAFB691226D5868C2BE4D1600F6460C77C0485CB38D56FC053");
}
}