diff --git a/.gitignore b/.gitignore index 4ce7e24..d418299 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ wallet/ logs/ *.log +# Java / Maven +target/ + # OS .DS_Store Thumbs.db diff --git a/docs/design/424-spring-boot-vpd-backoffice/README.md b/docs/design/424-spring-boot-vpd-backoffice/README.md index 92249e1..7a69b3c 100644 --- a/docs/design/424-spring-boot-vpd-backoffice/README.md +++ b/docs/design/424-spring-boot-vpd-backoffice/README.md @@ -144,6 +144,7 @@ src/test/java/com/cloudhandson/vpdbackoffice/ - `owner`, `object_name`, `column_name`은 Oracle identifier 허용 문자로 검증하되, 검증 후에도 SQL 문자열 직접 조합에 사용하지 않는다. - ORDS 검증 대상은 `CB_PROTECTED_OBJECT.enabled_yn = 'Y'`인 행만 허용한다. - 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` 중 허용 값만 저장한다. - `CUSTOM_PREDICATE`는 초기 구현에서 비활성화한다. @@ -182,7 +183,7 @@ src/test/java/com/cloudhandson/vpdbackoffice/ 1. 관리자가 사용자와 만료일을 선택한다. 2. Service가 사용자 활성 상태와 만료일을 검증한다. 3. `vpd_live_` prefix와 충분한 난수 token body를 생성한다. -4. server-side pepper를 사용해 hash를 만든다. +4. 기존 ORDS 패키지와 호환되는 SHA-256 hash를 만든다. 5. prefix, hash, 만료일만 저장한다. 6. 원문 token은 발급 완료 화면에 한 번만 표시한다. diff --git a/docs/design/424-spring-boot-vpd-backoffice/fn-issueToken.md b/docs/design/424-spring-boot-vpd-backoffice/fn-issueToken.md index f7d8aae..055d367 100644 --- a/docs/design/424-spring-boot-vpd-backoffice/fn-issueToken.md +++ b/docs/design/424-spring-boot-vpd-backoffice/fn-issueToken.md @@ -37,7 +37,7 @@ IssuedToken issueToken(TokenIssueCommand command) 3. 정책상 최대 유효 기간을 넘으면 실패한다. 4. `vpd_live_` prefix와 256bit 이상 난수 body를 생성한다. 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`를 저장한다. 8. audit log를 저장한다. 9. 원문 token을 포함한 `IssuedToken`을 반환한다. @@ -49,7 +49,6 @@ IssuedToken issueToken(TokenIssueCommand command) | 사용자가 없음 | 발급 중단 | `UserNotFoundException` | | 비활성 사용자 | 발급 중단 | `InactiveUserException` | | 만료일이 과거 | 발급 중단 | `InvalidTokenExpiryException` | -| pepper 설정 없음 | 발급 중단 | `TokenConfigurationException` | | DB 저장 실패 | transaction rollback | `DataAccessException` | ## 7. 엣지케이스 @@ -78,7 +77,7 @@ IssuedToken issueToken(TokenIssueCommand command) - [ ] 정상: 저장된 값에 원문 token이 포함되지 않음 - [ ] 실패: 비활성 사용자 입력 시 예외 - [ ] 실패: 과거 만료일 입력 시 예외 -- [ ] 실패: pepper 설정 없음 +- [ ] 정상: `cb_hr_key` hash가 Oracle `STANDARD_HASH('cb_hr_key', 'SHA256')`와 같은 값 ## 11. 추적성 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..6e4068a --- /dev/null +++ b/pom.xml @@ -0,0 +1,76 @@ + + + 4.0.0 + + com.cloudhandson + vpd-permission-backoffice + 0.1.0-SNAPSHOT + jar + + vpd-permission-backoffice + Spring Boot backoffice for VPD, bearer token, and ORDS permission probes + + + org.springframework.boot + spring-boot-starter-parent + 3.3.8 + + + + + 21 + 3.0.4 + 23.6.0.24.10 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + org.mybatis.spring.boot + mybatis-spring-boot-starter + ${mybatis-spring-boot.version} + + + com.oracle.database.jdbc + ojdbc11 + ${oracle.jdbc.version} + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/run.sh b/run.sh index 7904369..ddb4574 100755 --- a/run.sh +++ b/run.sh @@ -9,6 +9,7 @@ # ./run.sh adb # ADB 측 cleanup → dblinks → perm/ctx/view/policy → end_users # ./run.sh tests # 4-user (my/pg/both/none) 로 접속해서 행 필터 검증 # ./run.sh audit # admin 으로 정책/뷰/유저 상태 점검 +# ./run.sh backoffice-support # Spring Boot 백오피스 보조 객체 생성 # ./run.sh all # source → adb → tests → audit # ./run.sh teardown # ADB 측 객체 + 원격 link/cred 만 정리 (원격 PG/MySQL 데이터는 보존) # @@ -167,6 +168,12 @@ do_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() { log "=== teardown: ADB 측 객체 + dblink/credential 정리 ===" warn "원격 PG/MySQL 의 customers 테이블은 건드리지 않습니다 (수동으로 DROP 하세요)" @@ -218,6 +225,7 @@ case "$CMD" in adb) do_prereq; do_adb ;; tests) do_prereq; do_tests ;; audit) do_prereq; do_audit ;; + backoffice-support) do_prereq; do_backoffice_support ;; teardown) do_prereq; do_teardown ;; all) do_prereq @@ -237,6 +245,6 @@ case "$CMD" in 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 diff --git a/sql/adb/25_agent_ords_security_backoffice_support.sql b/sql/adb/25_agent_ords_security_backoffice_support.sql new file mode 100644 index 0000000..d28ff2a --- /dev/null +++ b/sql/adb/25_agent_ords_security_backoffice_support.sql @@ -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; diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/VpdBackofficeApplication.java b/src/main/java/com/cloudhandson/vpdbackoffice/VpdBackofficeApplication.java new file mode 100644 index 0000000..55d316c --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/VpdBackofficeApplication.java @@ -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); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java new file mode 100644 index 0000000..539c791 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java @@ -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(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java new file mode 100644 index 0000000..87145dc --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java @@ -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) { + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java new file mode 100644 index 0000000..3430d25 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/OrdsClientConfig.java @@ -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(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java new file mode 100644 index 0000000..ba66a9f --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java @@ -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(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/audit/AuditEvent.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/audit/AuditEvent.java new file mode 100644 index 0000000..39ad663 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/audit/AuditEvent.java @@ -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 +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/AppRole.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/AppRole.java new file mode 100644 index 0000000..eba793a --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/AppRole.java @@ -0,0 +1,8 @@ +package com.cloudhandson.vpdbackoffice.domain.permission; + +public record AppRole( + long roleId, + String roleName, + String description +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionRule.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionRule.java new file mode 100644 index 0000000..62f6219 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionRule.java @@ -0,0 +1,9 @@ +package com.cloudhandson.vpdbackoffice.domain.permission; + +public record PermissionRule( + long ruleId, + long permissionId, + String ruleType, + String ruleValue +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSet.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSet.java new file mode 100644 index 0000000..e42812d --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSet.java @@ -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 rules, + List visibleColumns +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSetCommand.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSetCommand.java new file mode 100644 index 0000000..cc338cb --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionSetCommand.java @@ -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 rules, + List visibleColumns +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/RuleCommand.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/RuleCommand.java new file mode 100644 index 0000000..11dca96 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/RuleCommand.java @@ -0,0 +1,9 @@ +package com.cloudhandson.vpdbackoffice.domain.permission; + +import jakarta.validation.constraints.NotBlank; + +public record RuleCommand( + @NotBlank String ruleType, + String ruleValue +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java new file mode 100644 index 0000000..3fab295 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeCommand.java @@ -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 +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java new file mode 100644 index 0000000..e942032 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java @@ -0,0 +1,19 @@ +package com.cloudhandson.vpdbackoffice.domain.probe; + +import java.util.List; +import java.util.Map; + +public record ProbeResult( + ProbeStatus status, + List columns, + List> rows, + int rowCount, + List 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); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeStatus.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeStatus.java new file mode 100644 index 0000000..146f1fa --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeStatus.java @@ -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 +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumn.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumn.java new file mode 100644 index 0000000..ef914f8 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedColumn.java @@ -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); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObject.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObject.java new file mode 100644 index 0000000..bedbf86 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/protectedobject/ProtectedObject.java @@ -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; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java new file mode 100644 index 0000000..cfe4225 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java @@ -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); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/IssuedToken.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/IssuedToken.java new file mode 100644 index 0000000..9732af5 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/IssuedToken.java @@ -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 +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenIssueCommand.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenIssueCommand.java new file mode 100644 index 0000000..00eea49 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenIssueCommand.java @@ -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 +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/AppUser.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/AppUser.java new file mode 100644 index 0000000..b22aee6 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/user/AppUser.java @@ -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); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/AuditMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/AuditMapper.java new file mode 100644 index 0000000..7ba4ca5 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/AuditMapper.java @@ -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); +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java new file mode 100644 index 0000000..d80923b --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java @@ -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 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 + ); +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/PermissionMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/PermissionMapper.java new file mode 100644 index 0000000..399928c --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/PermissionMapper.java @@ -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 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(); +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java new file mode 100644 index 0000000..3945a54 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/ProtectedObjectMapper.java @@ -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 findEnabled(); + + ProtectedObject findById(@Param("objectId") long objectId); + + List findColumns(@Param("objectId") long objectId); +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/UserMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/UserMapper.java new file mode 100644 index 0000000..a349cb0 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/UserMapper.java @@ -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 findAll(); + + AppUser findById(@Param("userId") long userId); +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/AppException.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/AppException.java new file mode 100644 index 0000000..7e743b9 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/AppException.java @@ -0,0 +1,8 @@ +package com.cloudhandson.vpdbackoffice.service; + +public class AppException extends RuntimeException { + + public AppException(String message) { + super(message); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/AuditService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/AuditService.java new file mode 100644 index 0000000..67990f9 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/AuditService.java @@ -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); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java new file mode 100644 index 0000000..35c6738 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java @@ -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 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)); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java new file mode 100644 index 0000000..0b8516a --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java @@ -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_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 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> rows = new ArrayList<>(); + Set columns = new LinkedHashSet<>(); + for (JsonNode rowNode : rowsNode) { + Map 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 findMaskedColumns(long objectId, List> rows) { + if (rows.isEmpty()) { + return List.of(); + } + List sensitiveColumns = protectedObjectService.findColumns(objectId).stream() + .filter(ProtectedColumn::sensitive) + .map(column -> column.columnName().toLowerCase(Locale.ROOT)) + .toList(); + if (sensitiveColumns.isEmpty()) { + return List.of(); + } + + List masked = new ArrayList<>(); + for (String column : sensitiveColumns) { + boolean present = false; + boolean allNull = true; + for (Map row : rows) { + for (Map.Entry 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); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java new file mode 100644 index 0000000..86ef10c --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java @@ -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 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 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 rules) { + if (rules == null || rules.isEmpty()) { + throw new AppException("행 규칙은 하나 이상 필요합니다."); + } + Set 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 visibleColumns) { + if (visibleColumns == null || visibleColumns.isEmpty()) { + return; + } + Set 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(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifier.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifier.java new file mode 100644 index 0000000..f14f243 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifier.java @@ -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; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java new file mode 100644 index 0000000..aa0474a --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/ProtectedObjectService.java @@ -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 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 findColumns(long objectId) { + return mapper.findColumns(objectId); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/TokenGenerator.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/TokenGenerator.java new file mode 100644 index 0000000..44a1e76 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/TokenGenerator.java @@ -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); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/TokenHasher.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/TokenHasher.java new file mode 100644 index 0000000..2a864d3 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/TokenHasher.java @@ -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"); + } + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/AppExceptionHandler.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/AppExceptionHandler.java new file mode 100644 index 0000000..90a7be8 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/AppExceptionHandler.java @@ -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"; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java new file mode 100644 index 0000000..943396a --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java @@ -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"; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java new file mode 100644 index 0000000..8d33270 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/PermissionController.java @@ -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 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"; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java new file mode 100644 index 0000000..95cfdbd --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java @@ -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"; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java new file mode 100644 index 0000000..ad5618f --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java @@ -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"; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..712e3c6 --- /dev/null +++ b/src/main/resources/application.yml @@ -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} diff --git a/src/main/resources/mapper/AuditMapper.xml b/src/main/resources/mapper/AuditMapper.xml new file mode 100644 index 0000000..b086d21 --- /dev/null +++ b/src/main/resources/mapper/AuditMapper.xml @@ -0,0 +1,13 @@ + + + + + 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 + ) + + diff --git a/src/main/resources/mapper/BearerTokenMapper.xml b/src/main/resources/mapper/BearerTokenMapper.xml new file mode 100644 index 0000000..833d355 --- /dev/null +++ b/src/main/resources/mapper/BearerTokenMapper.xml @@ -0,0 +1,43 @@ + + + + + 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 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 cb_agent_bearer_key_seq.NEXTVAL FROM dual + + + + 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} + ) + + + + 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 + + diff --git a/src/main/resources/mapper/PermissionMapper.xml b/src/main/resources/mapper/PermissionMapper.xml new file mode 100644 index 0000000..d2d8aeb --- /dev/null +++ b/src/main/resources/mapper/PermissionMapper.xml @@ -0,0 +1,76 @@ + + + + + SELECT role_id, role_name, CAST(NULL AS VARCHAR2(200)) AS description + FROM cb_app_role + ORDER BY role_name + + + + SELECT role_id, role_name, CAST(NULL AS VARCHAR2(200)) AS description + FROM cb_app_role + WHERE role_id = #{roleId} + + + + 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 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 cb_permission_seq.NEXTVAL FROM dual + + + + SELECT cb_permission_rule_seq.NEXTVAL FROM dual + + + + 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} + + + + UPDATE cb_permission + SET action_name = #{action} + WHERE perm_id = #{permissionId} + + + + DELETE FROM cb_permission_rule + WHERE perm_id = #{permissionId} + + + + INSERT INTO cb_permission_rule (rule_id, perm_id, rule_type, rule_value) + VALUES (#{ruleId}, #{permissionId}, #{ruleType}, #{ruleValue}) + + + + DELETE FROM cb_permission_column + WHERE permission_id = #{permissionId} + + + + INSERT INTO cb_permission_column (permission_id, column_name) + VALUES (#{permissionId}, #{columnName}) + + diff --git a/src/main/resources/mapper/ProtectedObjectMapper.xml b/src/main/resources/mapper/ProtectedObjectMapper.xml new file mode 100644 index 0000000..b4a673f --- /dev/null +++ b/src/main/resources/mapper/ProtectedObjectMapper.xml @@ -0,0 +1,24 @@ + + + + + SELECT object_id, owner, object_name, ords_path, enabled_yn + FROM cb_protected_object + WHERE enabled_yn = 'Y' + ORDER BY owner, object_name + + + + SELECT object_id, owner, object_name, ords_path, enabled_yn + FROM cb_protected_object + WHERE object_id = #{objectId} + + + + SELECT column_id, object_id, column_name, sensitive_yn, visible_role_id + FROM cb_protected_column + WHERE object_id = #{objectId} + ORDER BY column_id + + diff --git a/src/main/resources/mapper/UserMapper.xml b/src/main/resources/mapper/UserMapper.xml new file mode 100644 index 0000000..9434274 --- /dev/null +++ b/src/main/resources/mapper/UserMapper.xml @@ -0,0 +1,24 @@ + + + + + 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 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} + + diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css new file mode 100644 index 0000000..6e114d8 --- /dev/null +++ b/src/main/resources/static/css/app.css @@ -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; +} diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js new file mode 100644 index 0000000..564d36c --- /dev/null +++ b/src/main/resources/static/js/app.js @@ -0,0 +1,6 @@ +document.body.addEventListener('htmx:responseError', (event) => { + const target = event.detail.target; + if (target) { + target.innerHTML = '요청 처리 중 오류가 발생했습니다.'; + } +}); diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html new file mode 100644 index 0000000..efee94c --- /dev/null +++ b/src/main/resources/templates/dashboard.html @@ -0,0 +1,58 @@ + + + + + + + + VPD 권한 백오피스 + 보호 객체, Bearer Token, ORDS 검증 상태를 확인합니다. + + + + + 보호 객체 + 0 + + + 역할 + 0 + + + 토큰 + 0 + + + + + + 최근 보호 객체 + 검증 실행 + + + + + + Owner + Object + ORDS Path + Status + + + + + ADMIN + CB_V_SEARCH_DOCUMENTS + search/documents + enabled + + + 등록된 보호 객체가 없습니다. + + + + + + + + diff --git a/src/main/resources/templates/error.html b/src/main/resources/templates/error.html new file mode 100644 index 0000000..8ff730b --- /dev/null +++ b/src/main/resources/templates/error.html @@ -0,0 +1,14 @@ + + + + + + + + 처리할 수 없습니다. + + + 대시보드 + + + diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html new file mode 100644 index 0000000..df9ec49 --- /dev/null +++ b/src/main/resources/templates/fragments/layout.html @@ -0,0 +1,29 @@ + + + + + + VPD 백오피스 + + + + + + + + + + VPD Backoffice + + 권한 + 토큰 + ORDS 검증 + + + + 로그아웃 + + + + + diff --git a/src/main/resources/templates/fragments/probe-result.html b/src/main/resources/templates/fragments/probe-result.html new file mode 100644 index 0000000..add45c7 --- /dev/null +++ b/src/main/resources/templates/fragments/probe-result.html @@ -0,0 +1,39 @@ + + + + + + 검증 결과 + SUCCESS + + + + ERROR + message + + + + Rows: 0 + + Masked: + + + + + + + + column + + + + + value + + + + + + + diff --git a/src/main/resources/templates/permissions.html b/src/main/resources/templates/permissions.html new file mode 100644 index 0000000..aeb0af1 --- /dev/null +++ b/src/main/resources/templates/permissions.html @@ -0,0 +1,47 @@ + + + + + + + + 권한 관리 + 역할별 보호 객체 접근과 행 규칙을 저장합니다. + + + + + + + + + 역할 + + + + + + 보호 객체 + + + + + + 행 규칙 + + ALL + REGION + MY_DEPT + SELF + + + + 규칙 값 + + + 저장 + + + + + diff --git a/src/main/resources/templates/probe.html b/src/main/resources/templates/probe.html new file mode 100644 index 0000000..e55994e --- /dev/null +++ b/src/main/resources/templates/probe.html @@ -0,0 +1,44 @@ + + + + + + + + ORDS 검증 + Bearer Token과 보호 객체를 선택해 VPD/Redaction 적용 결과를 확인합니다. + + + + + + + Token ID + + + + + + Bearer Token 원문 + + + + 보호 객체 + + + + + + Limit + + + 호출 + + + + + 검증 결과가 여기에 표시됩니다. + + + + diff --git a/src/main/resources/templates/tokens.html b/src/main/resources/templates/tokens.html new file mode 100644 index 0000000..106a516 --- /dev/null +++ b/src/main/resources/templates/tokens.html @@ -0,0 +1,79 @@ + + + + + + + + Bearer Token + 토큰은 발급 직후 한 번만 원문을 표시합니다. + + + + + 새 토큰이 발급되었습니다. 이 값은 다시 표시되지 않습니다. + + + + + 토큰 발급 + + + + 사용자 + + + + + + 만료 시각 + + + + 설명 + + + 발급 + + + + + 토큰 목록 + + + + + ID + User + Prefix + Expires + Revoked + + + + + + 1 + user + vpd_live_x + 2026 + - + + + + + + 회수 + + + + + 등록된 토큰이 없습니다. + + + + + + + + diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java new file mode 100644 index 0000000..b75bc19 --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/PermissionServiceTest.java @@ -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 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 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; + } + } +} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifierTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifierTest.java new file mode 100644 index 0000000..4ca9420 --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifierTest.java @@ -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); + } +} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/TokenGeneratorTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/TokenGeneratorTest.java new file mode 100644 index 0000000..87862c3 --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/TokenGeneratorTest.java @@ -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); + } +} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/TokenHasherTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/TokenHasherTest.java new file mode 100644 index 0000000..f449734 --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/TokenHasherTest.java @@ -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"); + } +}
보호 객체, Bearer Token, ORDS 검증 상태를 확인합니다.
search/documents
역할별 보호 객체 접근과 행 규칙을 저장합니다.
Bearer Token과 보호 객체를 선택해 VPD/Redaction 적용 결과를 확인합니다.
토큰은 발급 직후 한 번만 원문을 표시합니다.
vpd_live_x