From fbe3d4682b212d24105017cf8221fc592b4361ac Mon Sep 17 00:00:00 2001 From: devmrko Date: Mon, 29 Jun 2026 07:01:53 +0900 Subject: [PATCH] fix #557: guide permission-driven VPD flow --- .../557-guided-permission-vpd-flow/README.md | 110 ++++++ .../domain/probe/ProbeResult.java | 61 ++++ .../domain/probe/ProbeStatus.java | 1 + .../domain/vpd/VpdFunctionOption.java | 4 + .../domain/vpd/VpdPolicyView.java | 4 + .../service/BearerTokenService.java | 11 + .../service/OrdsProbeService.java | 5 +- .../service/ProbeErrorClassifier.java | 3 + .../service/VpdPolicyService.java | 31 +- .../vpdbackoffice/web/ProbeController.java | 41 ++- .../web/VpdPolicyController.java | 20 ++ src/main/resources/static/css/app.css | 322 +++++++++++++++++- src/main/resources/static/js/app.js | 40 +++ src/main/resources/templates/dashboard.html | 103 ++++-- .../resources/templates/fragments/layout.html | 56 +-- .../templates/fragments/probe-result.html | 132 ++++--- src/main/resources/templates/probe.html | 103 ++---- src/main/resources/templates/tokens.html | 64 ++-- .../templates/vpd-filter-policies.html | 309 ++++++++--------- .../resources/templates/vpd-policies.html | 161 +++------ .../domain/probe/ProbeResultTest.java | 100 ++++++ .../service/ProbeErrorClassifierTest.java | 8 + .../service/VpdPolicyServiceTest.java | 96 ++++++ .../web/GuidedFlowTemplateTest.java | 55 +++ 24 files changed, 1351 insertions(+), 489 deletions(-) create mode 100644 docs/design/557-guided-permission-vpd-flow/README.md create mode 100644 src/test/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResultTest.java create mode 100644 src/test/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyServiceTest.java create mode 100644 src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java diff --git a/docs/design/557-guided-permission-vpd-flow/README.md b/docs/design/557-guided-permission-vpd-flow/README.md new file mode 100644 index 0000000..4617d70 --- /dev/null +++ b/docs/design/557-guided-permission-vpd-flow/README.md @@ -0,0 +1,110 @@ +# 설계서: 권한체계 중심 VPD 안내와 토큰 검증 흐름 (#557) + +> **상태**: Approved +> **작성**: [AI] Architect · **최종수정**: 2026-06-29 +> **추적성** — Redmine: #557 · 관련 ADR: 없음 +> · 구현 파일: `ProbeController`, `ProbeResult`, `VpdPolicyService`, 대시보드/VPD/토큰/검증 템플릿 +> · 테스트: `ProbeResultTest`, `VpdPolicyServiceTest`, `GuidedFlowTemplateTest` + +## 1. 목적 (Why) + +권한을 만든 사람의 머릿속 구조를 모르는 운영자도 “누가 어떤 데이터를 볼 수 있는지 정하고, DB가 그대로 제한하는지 확인한다”는 한 흐름으로 제품을 이해하고 사용할 수 있게 한다. + +## 2. 범위 (Scope) + +- **포함**: Macro→Micro 설명 구조, 내비게이션 재분류, 동적 권한 VPD 기본 적용, custom filter 고급 분리, 토큰 검증 단순화, 상태별 일반 문장과 다음 행동, 발급→검증 연결. +- **제외**: 권한 데이터 모델 변경, VPD 함수 SQL 알고리즘 변경, 토큰 원문 저장, 운영 DB DDL 자동 실행, ORDS handler 생성 방식 변경. + +## 3. 인수조건 (Acceptance Criteria) + +- [x] 첫 화면에서 권한 설계 → DB 보호 → 토큰 발급 → 결과 확인의 목적과 순서를 이해할 수 있다. +- [x] 기본 VPD 적용은 객체만 선택하고 권한체계 동적 함수와 SELECT 정책을 서버가 결정한다. +- [x] `CB_AGENT_DOC_VPD_FILTER`는 일반 UI에서 수정할 수 없고 직접 POST도 거부된다. +- [x] 별도 filter/predicate와 policy 교체는 사용 기준·안전 규칙이 있는 고급 영역에만 노출된다. +- [x] 검증 화면은 등록 토큰 선택 없이 Bearer 원문과 대상만 입력한다. +- [x] 검증 결과가 상태, 적용 주체, 행 결과, 다음 행동 순으로 설명되고 HTTP 원문은 접혀 있다. +- [x] 존재하지 않는 테스트 토큰은 “DB에 등록된 토큰이 아님”과 재발급 절차를 안내한다. +- [x] 발급 직후 토큰 복사와 검증 화면 이동이 명확하다. + +## 4. 컨텍스트 & 제약 + +- 토큰 원문은 보안상 저장하지 않고 SHA-256 hash만 저장한다. 따라서 목록에서 원문을 재선택해 호출하는 기능은 제공하지 않는다. +- 권한 원천은 사용자/그룹/역할/권한/규칙 테이블이며 VPD 함수가 요청 시점에 이를 조회한다. +- 권한이 없거나 컨텍스트/규칙이 잘못되면 `1=0`으로 닫는 fail-closed 원칙을 유지한다. +- 운영 DB의 기존 custom policy는 삭제하지 않고 고급 관리 기능으로 남긴다. + +## 5. 아키텍처 개요 + +```text +[Macro: 무엇을 하려는가] + 누가(User/Group) → 어떤 역할(Role) → 어떤 데이터(Object/Rule) + │ + ▼ +[Micro: DB가 어떻게 지키는가] + CB_PERMISSION* 테이블 → CB_AGENT_DOC_VPD_FILTER → Oracle VPD + │ + ▼ +[Evidence: 실제로 지켜졌는가] + 1회 표시 토큰 → ORDS 호출 → 사용자/역할 + 보이는 행 + 다음 행동 +``` + +- I/O 경계: controller/service는 DB catalog와 ORDS를 호출한다. +- 순수 표현 경계: `ProbeResult`의 상태별 제목·설명·다음 행동은 외부 I/O 없이 테스트한다. +- 안전 경계: 기본 endpoint는 동적 함수만 사용하고 custom 함수 생성/교체 endpoint는 고급 기능으로 유지한다. + +## 6. 데이터 모델 + +- 입력: `objectKey`, `bearerToken`, `objectId`, `limit`. +- 기본 VPD 명령: policy `CB_PERMISSION_SELECT_POLICY`, function `*.CB_AGENT_DOC_VPD_FILTER`, statements `SELECT`, enabled `true`, update check `false`. +- 검증 표현: 기존 `ProbeResult`에 상태별 `title`, `plainSummary`, `nextAction`, `successLike` 계산 메서드를 둔다. +- 검증 컨텍스트: hash로 찾은 `TokenContextView`와 `ProtectedObject`를 controller model에 추가한다. 원문은 model/result/log에 저장하지 않는다. + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임 | 입력 | 출력 | 에러/실패 | 복잡? | +|---|---|---|---|---|---| +| `createDefaultPermissionPolicy` | 객체에 표준 동적 권한 VPD 연결 | `objectKey` | 없음 | 함수 미설치, 중복/DB 오류 | 단순 | +| `saveFilterFunction` guard | 핵심 동적 함수 덮어쓰기 차단 | owner/name/predicate | 없음 | 핵심 함수명이면 `AppException` | 단순 | +| `ProbeResult.title/plainSummary/nextAction` | 기술 상태를 일반 문장으로 변환 | status/result | 문자열 | 알 수 없는 상태도 안전 안내 | 단순 | +| `findTokenContextByPlainToken` | 원문 hash에 대응하는 사용자/역할 설명 조회 | 토큰 원문 | nullable context | 미등록이면 null | 단순 | +| `ProbeController.run` | 단일 토큰 입력으로 실행·설명 model 구성 | form | fragment | status별 결과 fragment | 단순 | + +## 8. 흐름 / 알고리즘 + +1. 운영자는 사용자/그룹/역할과 객체별 행·열 규칙을 저장한다. +2. 보호할 DB 객체를 선택하고 “권한체계 연결”을 누른다. +3. 서버는 설치된 `CB_AGENT_DOC_VPD_FILTER`를 찾아 동적 SELECT policy를 붙인다. +4. 토큰을 발급하면 원문을 한 번 복사하고 검증으로 이동한다. +5. 검증 시 hash로 토큰과 사용자를 식별하고 ORDS를 호출한다. +6. 화면은 사용자/직접 역할/그룹 상속, 보인 행 수 또는 차단 이유, 다음 행동을 먼저 보여준다. +7. HTTP request/response는 문제 분석용 상세 영역에서만 연다. + +## 9. 엣지케이스 & 에러 처리 + +- 토큰 미등록: 환경 파일과 DB가 어긋난 상태로 설명하고 새 발급을 안내한다. +- 만료/회수: 새 토큰 발급 또는 활성 토큰 사용을 안내한다. +- 0행: 오류가 아니라 VPD가 현재 권한 기준으로 모든 행을 제외했을 가능성을 먼저 설명한다. +- ORDS 미설정/경로 오류/접속 실패: 권한 문제와 인프라 문제를 구분한다. +- 기존 별도 함수가 ORA-28110을 내는 경우: 토큰 오류와 분리해 `VPD_FILTER_ERROR`로 설명하고 정상 동적 권한 객체를 검증 목록에서 우선한다. +- 핵심 함수 미설치: 자동으로 custom predicate를 만들지 않고 설치 상태 확인을 요구한다. +- 핵심 함수 수정 시도: UI와 service 양쪽에서 차단한다. + +## 10. 테스트 계획 + +- `ProbeResultTest`: 성공, 0행, 토큰 미등록, 만료, ORDS 장애의 일반 문장/다음 행동. +- `VpdPolicyServiceTest`: 핵심 함수 수정 거부, 기본 적용이 표준 policy/function/SELECT/DYNAMIC 옵션을 사용. +- `GuidedFlowTemplateTest`: 검증의 단일 token 입력, 기술 상세 접힘, VPD 기본/고급 분리, 대시보드 4단계. +- 전체 `mvn test` 34건, `mvn -DskipTests package`. +- 실행 환경에서는 로그인 후 주요 페이지 HTTP 200과 실제 신규 토큰 검증을 확인한다. 외부 ORDS가 없으면 상태별 설명까지만 검증한다. + +## 11. 리스크 & 대안 검토 + +- 기존 template 선택 UI를 기본으로 유지하면 유연하지만 일반 사용자가 정책 이름과 함수를 이해해야 한다. 객체만 받는 전용 기본 endpoint를 선택한다. +- 목록의 등록 토큰을 선택하게 하려면 원문 저장이 필요해진다. 보안 경계를 유지하고 원문 직접 입력만 제공한다. +- custom 기능을 제거하면 기존 운영 정책 관리가 막힌다. 삭제 대신 고급 영역과 서버 안전장치로 격리한다. + +## 12. 미해결 질문 (Open Questions) + +- 실제 환경에서 동적 함수가 연결된 객체는 신규 토큰으로 ORDS/VPD 성공 응답까지 확인했다. +- 기존 `BOARD_ASSIGNMENTS_FILTER`는 ORA-28110 상태다. 운영 policy 교체는 DDL 승인 후 별도 Filter 고급 화면에서 표준 동적 함수로 복구해야 한다. +- `.env`의 기존 테스트 토큰 두 개는 현재 DB에 없으며, 환경 비밀 교체는 저장소 밖 운영 작업으로 남긴다. diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java index 6f5b64d..c0ddfd7 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResult.java @@ -44,4 +44,65 @@ public record ProbeResult( responseBody ); } + + public boolean successLike() { + return status == ProbeStatus.SUCCESS || status == ProbeStatus.VPD_DENY_EMPTY_RESULT; + } + + public String title() { + return switch (status) { + case SUCCESS -> "권한에 따라 데이터를 볼 수 있습니다."; + case VPD_DENY_EMPTY_RESULT -> "현재 권한으로 볼 수 있는 행이 없습니다."; + case TOKEN_NOT_FOUND -> "DB에 등록되지 않은 토큰입니다."; + case TOKEN_INACTIVE -> "만료되었거나 회수된 토큰입니다."; + case INVALID_TOKEN -> "입력한 토큰 정보가 일치하지 않습니다."; + case OBJECT_DISABLED -> "검증 대상이 비활성 상태입니다."; + case OBJECT_NOT_ACCESSIBLE -> "현재 권한으로 이 대상에 접근할 수 없습니다."; + case VPD_FILTER_ERROR -> "이 객체에 연결된 별도 VPD Filter가 실행되지 않습니다."; + case ORDS_PATH_NOT_FOUND -> "검증 대상의 ORDS 경로를 찾지 못했습니다."; + case ORDS_NOT_CONFIGURED -> "ORDS 연결 주소가 아직 설정되지 않았습니다."; + case ORDS_UNAVAILABLE -> "ORDS 서버에 연결할 수 없습니다."; + case ORDS_TIMEOUT -> "ORDS 응답을 기다리다 시간이 초과되었습니다."; + case INVALID_ORDS_RESPONSE -> "ORDS 응답 형식을 해석할 수 없습니다."; + case UNKNOWN_ERROR -> "검증 중 예상하지 못한 문제가 발생했습니다."; + }; + } + + public String plainSummary() { + return switch (status) { + case SUCCESS -> "토큰의 사용자와 역할을 기준으로 VPD가 적용되었고, 허용된 데이터 " + rowCount + + "개가 반환되었습니다."; + case VPD_DENY_EMPTY_RESULT -> "호출은 정상 처리됐지만 VPD가 현재 사용자에게 허용한 행은 0개입니다. 권한 규칙과 실제 데이터가 맞지 않으면 정상 결과이며 오류가 아닐 수 있습니다."; + case TOKEN_NOT_FOUND -> "입력한 원문과 일치하는 등록 기록이 현재 DB에 없습니다. 예전에 발급한 값이거나 다른 환경의 토큰일 수 있습니다."; + case TOKEN_INACTIVE -> "토큰은 DB에 있지만 만료되었거나 관리자가 회수해 더 이상 사용자 권한을 증명할 수 없습니다."; + case INVALID_TOKEN -> "화면에서 선택한 정보와 입력한 토큰 원문이 서로 다릅니다."; + case OBJECT_DISABLED -> "선택한 객체가 검증 대상으로 활성화되어 있지 않아 ORDS 호출을 시작하지 않았습니다."; + case OBJECT_NOT_ACCESSIBLE -> "토큰은 확인됐지만 ORDS 또는 DB가 이 객체에 대한 접근을 거부했습니다."; + case VPD_FILTER_ERROR -> "토큰과 사용자 권한은 확인됐지만, 대상 객체의 VPD 함수가 DB 오류를 내어 행 필터를 계산하지 못했습니다."; + case ORDS_PATH_NOT_FOUND -> "권한 판단 전 단계에서 실제 ORDS 주소와 등록된 객체 경로가 일치하지 않았습니다."; + case ORDS_NOT_CONFIGURED -> "백오피스가 호출할 ORDS 기준 주소가 없어 권한 검증을 시작하지 못했습니다."; + case ORDS_UNAVAILABLE -> "권한 판단 전 단계에서 ORDS 서버 또는 네트워크에 연결하지 못했습니다."; + case ORDS_TIMEOUT -> "ORDS가 제한 시간 안에 응답하지 않아 권한 결과를 확인하지 못했습니다."; + case INVALID_ORDS_RESPONSE -> "ORDS 호출은 끝났지만 rows/items 배열이 없는 응답이라 권한 결과로 표시하지 못했습니다."; + case UNKNOWN_ERROR -> "토큰, 권한, ORDS 중 어느 단계의 문제인지 기술 상세를 확인해야 합니다."; + }; + } + + public String nextAction() { + return switch (status) { + case SUCCESS -> "반환된 행과 마스킹 컬럼이 예상한 범위인지 확인하세요. 다르면 권한 화면의 행·열 규칙을 조정한 뒤 다시 검증하세요."; + case VPD_DENY_EMPTY_RESULT -> "유효 권한 화면에서 사용자에게 직접 또는 그룹으로 상속된 역할과 행 규칙을 확인하세요."; + case TOKEN_NOT_FOUND -> "토큰 화면에서 현재 환경의 사용자에게 새 토큰을 발급하고, 한 번만 표시되는 원문을 복사해 다시 검증하세요."; + case TOKEN_INACTIVE -> "토큰 화면에서 활성 토큰을 새로 발급한 뒤 다시 검증하세요."; + case INVALID_TOKEN -> "복사한 원문이 맞는지 확인하고, 원문을 잃었다면 새 토큰을 발급하세요."; + case OBJECT_DISABLED -> "보호 객체를 활성화하고 권한을 등록한 뒤 다시 검증하세요."; + case OBJECT_NOT_ACCESSIBLE -> "유효 권한과 ORDS handler의 대상 객체가 같은지 확인하세요."; + case VPD_FILTER_ERROR -> "토큰이나 권한을 바꾸지 말고 DB 보호 연결에서 이 객체의 Filter를 확인하세요. 일반 권한 객체라면 권한체계 자동 Filter로 복구하세요."; + case ORDS_PATH_NOT_FOUND -> "보호 객체의 ORDS 경로와 실제 module/template 경로를 맞춘 뒤 다시 실행하세요."; + case ORDS_NOT_CONFIGURED -> "설정에서 ORDS 기준 주소를 등록한 뒤 백오피스를 재시작하세요."; + case ORDS_UNAVAILABLE, ORDS_TIMEOUT -> "권한 설정을 바꾸지 말고 먼저 ORDS 실행 상태, 주소와 네트워크를 확인하세요."; + case INVALID_ORDS_RESPONSE -> "ORDS handler가 rows 또는 items 배열을 반환하는지 확인하세요."; + case UNKNOWN_ERROR -> "아래 기술 상세의 오류 코드와 응답을 확인한 뒤 해당 단계부터 점검하세요."; + }; + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeStatus.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeStatus.java index b081591..daa0908 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeStatus.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeStatus.java @@ -8,6 +8,7 @@ public enum ProbeStatus { OBJECT_DISABLED, INVALID_TOKEN, OBJECT_NOT_ACCESSIBLE, + VPD_FILTER_ERROR, ORDS_PATH_NOT_FOUND, ORDS_NOT_CONFIGURED, ORDS_UNAVAILABLE, diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionOption.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionOption.java index c098bfc..50b844f 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionOption.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdFunctionOption.java @@ -15,4 +15,8 @@ public record VpdFunctionOption( public String label() { return value() + " / " + objectType; } + + public boolean permissionSystemDefault() { + return "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName); + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyView.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyView.java index cc10555..e872eb7 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyView.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vpd/VpdPolicyView.java @@ -24,4 +24,8 @@ public record VpdPolicyView( String packagePrefix = packageName == null || packageName.isBlank() ? "" : packageName + "."; return functionOwner + "." + packagePrefix + functionName; } + + public boolean permissionSystemDefault() { + return "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName); + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java index cafd48d..22ca3be 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java @@ -87,6 +87,17 @@ public class BearerTokenService { .toList(); } + public TokenContextView findTokenContextByPlainToken(String plainToken) { + BearerTokenRecord record = findByPlainToken(plainToken); + if (record == null) { + return null; + } + return findTokenContextOptions().stream() + .filter(context -> context.keyId() == record.keyId()) + .findFirst() + .orElse(null); + } + public BearerTokenRecord findById(long keyId) { return tokenMapper.findById(keyId); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java index abca541..1e4f227 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java @@ -80,7 +80,10 @@ public class OrdsProbeService { token = tokenService.findByPlainToken(command.bearerToken()); if (token == null) { return auditAndReturn(command, ProbeResult.blocked( - ProbeStatus.TOKEN_NOT_FOUND, "TOKEN_NOT_FOUND", "토큰을 찾을 수 없습니다.")); + ProbeStatus.TOKEN_NOT_FOUND, + "TOKEN_NOT_FOUND", + "현재 DB에 등록된 토큰이 아닙니다. 토큰 화면에서 이 환경의 새 토큰을 발급하세요." + )); } } else { token = tokenService.findById(command.tokenKeyId()); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifier.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifier.java index 7b44d7c..5fe4fed 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifier.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifier.java @@ -20,6 +20,9 @@ public class ProbeErrorClassifier { if (text.contains("ORA-00942") || text.contains("ORA-01031")) { return ProbeStatus.OBJECT_NOT_ACCESSIBLE; } + if (text.contains("ORA-28110") || text.contains("SQL Error Code 28110")) { + return ProbeStatus.VPD_FILTER_ERROR; + } if (status != null && (status.value() == 401 || status.value() == 403)) { return ProbeStatus.INVALID_TOKEN; } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java index 1732d01..8c46c99 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java @@ -96,6 +96,11 @@ public class VpdPolicyService { @Transactional public void saveFilterFunction(String functionOwnerValue, String functionNameValue, String filterPredicateValue) { + String functionName = requiredIdentifier(functionNameValue, "Function name"); + if (DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(functionName)) { + throw new AppException("기본 동적 권한 필터 " + DEFAULT_PERMISSION_FILTER_FUNCTION + + "는 이 화면에서 수정할 수 없습니다. 권한체계는 사용자·그룹·역할·권한 규칙 화면에서 변경하세요."); + } String currentUser = jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class); String functionOwner = functionOwnerValue == null || functionOwnerValue.isBlank() ? currentUser @@ -104,7 +109,6 @@ public class VpdPolicyService { throw new AppException("Filter function 등록/수정은 현재 연결 사용자 스키마에만 가능합니다. 현재 사용자: " + currentUser + ", Function owner: " + functionOwner); } - String functionName = requiredIdentifier(functionNameValue, "Function name"); String filterPredicate = filterPredicateValue == null ? "" : filterPredicateValue.trim(); if (filterPredicate.isBlank()) { throw new AppException("Filter predicate는 필수입니다."); @@ -113,6 +117,31 @@ public class VpdPolicyService { clearCatalogCache(); } + @Transactional + public void createDefaultPermissionPolicy(String objectKey) { + String[] objectParts = objectKey == null ? new String[0] : objectKey.split("\\.", 2); + if (objectParts.length != 2) { + throw new AppException("보호할 객체 형식이 올바르지 않습니다: " + objectKey); + } + String functionKey = formOptions().defaultPermissionFunctionKey(); + if (functionKey.isBlank()) { + throw new AppException("기본 동적 권한 필터 " + DEFAULT_PERMISSION_FILTER_FUNCTION + + "가 설치되어 있지 않습니다. 운영 상태에서 동적 권한 필터 설치 여부를 확인한 뒤 다시 적용하세요."); + } + createPolicy(new VpdPolicyCreateCommand( + objectParts[0], + objectParts[1], + COMMON_POLICY_NAME, + functionKey, + null, + null, + "SELECT", + true, + false, + null + )); + } + @Transactional public void replacePolicy(String oldObjectKey, String oldPolicyName, VpdPolicyCreateCommand command) { String[] objectParts = oldObjectKey == null ? new String[0] : oldObjectKey.split("\\.", 2); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java index c6563c2..fe3bf5a 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java @@ -1,9 +1,15 @@ package com.cloudhandson.vpdbackoffice.web; import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand; +import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject; import com.cloudhandson.vpdbackoffice.service.BearerTokenService; import com.cloudhandson.vpdbackoffice.service.OrdsProbeService; import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService; +import com.cloudhandson.vpdbackoffice.service.VpdPolicyService; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -16,33 +22,58 @@ public class ProbeController { private final OrdsProbeService probeService; private final ProtectedObjectService protectedObjectService; private final BearerTokenService tokenService; + private final VpdPolicyService vpdPolicyService; public ProbeController( OrdsProbeService probeService, ProtectedObjectService protectedObjectService, - BearerTokenService tokenService + BearerTokenService tokenService, + VpdPolicyService vpdPolicyService ) { this.probeService = probeService; this.protectedObjectService = protectedObjectService; this.tokenService = tokenService; + this.vpdPolicyService = vpdPolicyService; } @GetMapping("/probe") public String probe(Model model) { - model.addAttribute("objects", protectedObjectService.findEnabled()); - model.addAttribute("tokens", tokenService.findTokenContextOptions()); + Set defaultObjectKeys = defaultPermissionObjectKeys(); + List objects = + protectedObjectService.findEnabled().stream() + .sorted(Comparator + .comparing((ProtectedObject object) -> + !defaultObjectKeys.contains(object.displayName())) + .thenComparing(ProtectedObject::displayName)) + .toList(); + model.addAttribute("objects", objects); + model.addAttribute("defaultObjectKeys", defaultObjectKeys); return "probe"; } @PostMapping("/probe") public String run( - @RequestParam(required = false) Long tokenKeyId, @RequestParam long objectId, @RequestParam String bearerToken, @RequestParam(defaultValue = "50") int limit, Model model ) { - model.addAttribute("result", probeService.runProbe(new ProbeCommand(tokenKeyId, objectId, bearerToken, limit))); + String normalizedToken = bearerToken == null ? "" : bearerToken.trim(); + model.addAttribute("result", probeService.runProbe(new ProbeCommand(null, objectId, normalizedToken, limit))); + model.addAttribute("tokenContext", tokenService.findTokenContextByPlainToken(normalizedToken)); + model.addAttribute("selectedObject", protectedObjectService.findEnabled().stream() + .filter(object -> object.objectId() == objectId) + .findFirst() + .orElse(null)); return "fragments/probe-result :: result"; } + + private Set defaultPermissionObjectKeys() { + Set result = new LinkedHashSet<>(); + vpdPolicyService.findPolicies().stream() + .filter(policy -> policy.permissionSystemDefault() && "YES".equalsIgnoreCase(policy.enabled())) + .map(policy -> policy.objectDisplayName().toUpperCase()) + .forEach(result::add); + return result; + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java index 22be6d1..5f9618c 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java @@ -80,6 +80,26 @@ public class VpdPolicyController { return "redirect:/vpd-policies"; } + @PostMapping("/vpd-policies/default") + public String createDefaultPolicy( + @RequestParam String objectKey, + RedirectAttributes redirectAttributes + ) { + try { + vpdPolicyService.createDefaultPermissionPolicy(objectKey); + redirectAttributes.addFlashAttribute( + "successMessage", + "권한체계 자동 필터를 연결했습니다: " + objectKey + ); + } catch (AppException exception) { + redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage()); + } catch (DataAccessException exception) { + RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception); + redirectAttributes.addFlashAttribute("errorMessage", message.message()); + } + return "redirect:/vpd-policies"; + } + @PostMapping("/vpd-filter-policies/filters") public String saveFilter( @RequestParam(required = false) String functionOwner, diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index 61c2311..7ccde8b 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -213,7 +213,7 @@ body { align-items: stretch; display: grid; gap: .5rem; - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr); margin-bottom: 1rem; min-width: 0; } @@ -1508,3 +1508,323 @@ body { padding-right: .65rem; } } + +/* Guided permission journey (#557) */ +.guided-hero { + max-width: 920px; +} + +.guided-hero h1 { + font-size: clamp(1.75rem, 3.2vw, 2.5rem); + line-height: 1.18; + margin-top: .35rem; +} + +.guided-hero p { + font-size: 1rem; + line-height: 1.65; + max-width: 850px; +} + +.journey-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-bottom: 1rem; +} + +.journey-card { + align-items: flex-start; + background: var(--rw-surface); + border: 1px solid var(--rw-border); + border-radius: 8px; + box-shadow: var(--rw-shadow); + color: inherit; + display: flex; + gap: 1rem; + min-width: 0; + padding: 1.15rem; + text-decoration: none; +} + +.journey-card:hover { + border-color: var(--rw-primary); + color: inherit; + transform: translateY(-1px); +} + +.journey-number { + align-items: center; + background: var(--rw-primary-soft); + border-radius: 999px; + color: var(--rw-primary); + display: inline-flex; + flex: 0 0 2.2rem; + font-weight: 800; + height: 2.2rem; + justify-content: center; +} + +.journey-card h2 { + font-size: 1.05rem; + font-weight: 800; + margin: .1rem 0 .35rem; +} + +.journey-card p { + color: var(--rw-muted); + line-height: 1.5; + margin: 0 0 .75rem; +} + +.journey-card strong { + color: var(--rw-primary); + font-size: .88rem; +} + +.macro-micro-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.macro-micro-grid > div { + background: var(--rw-surface-muted); + border: 1px solid var(--rw-border); + border-radius: 8px; + min-width: 0; + padding: 1rem; +} + +.macro-micro-grid h2, +.macro-micro-grid h3 { + font-size: 1rem; + font-weight: 800; + margin: .3rem 0 .45rem; +} + +.macro-micro-grid p { + color: var(--rw-muted); + line-height: 1.55; + margin: 0; +} + +.guided-check-intro { + align-items: center; + display: flex; + gap: 1rem; + justify-content: space-between; +} + +.guided-check-intro h2 { + margin: .25rem 0 .4rem; +} + +.guided-check-intro p { + color: var(--rw-muted); + margin: 0; + max-width: 760px; +} + +.probe-form { + align-items: end; +} + +.probe-submit { + min-height: 2.8rem; +} + +.empty-result-guide { + color: var(--rw-muted); +} + +.empty-result-guide ol { + margin: .65rem 0 0; + padding-left: 1.25rem; +} + +.probe-result-flow { + display: grid; + gap: 1rem; +} + +.probe-result-flow > .section-heading { + margin-bottom: 0; +} + +.probe-summary { + color: var(--rw-text); + font-size: 1rem; + line-height: 1.6; + margin: 0; +} + +.result-section { + border-top: 1px solid var(--rw-border); + padding-top: 1rem; +} + +.result-section h3, +.next-action-card h3, +.probe-exchange h3 { + font-size: .92rem; + font-weight: 800; + margin: 0; +} + +.result-metrics { + display: grid; + gap: .75rem; + grid-template-columns: minmax(120px, .5fr) minmax(0, 1.5fr); +} + +.result-metrics > div { + background: var(--rw-surface-muted); + border: 1px solid var(--rw-border); + border-radius: 8px; + padding: .8rem; +} + +.result-metrics span { + color: var(--rw-muted); + display: block; + font-size: .76rem; + font-weight: 700; +} + +.result-metrics strong { + display: block; + margin-top: .25rem; + overflow-wrap: anywhere; +} + +.next-action-card { + background: var(--rw-primary-soft); + border: 1px solid rgba(122, 62, 47, .2); + border-radius: 8px; + padding: 1rem; +} + +.next-action-card p { + line-height: 1.55; + margin: .45rem 0 .8rem; +} + +.technical-details, +.advanced-details { + border: 1px solid var(--rw-border); + border-radius: 8px; + padding: .8rem; +} + +.technical-details > summary, +.advanced-details > summary { + cursor: pointer; + font-weight: 800; +} + +.issued-token-card { + background: var(--rw-primary-soft); + border: 1px solid rgba(122, 62, 47, .25); + border-radius: 8px; + box-shadow: var(--rw-shadow); + margin-bottom: 1rem; + padding: 1.15rem; +} + +.issued-token-card h2 { + font-size: 1.05rem; + font-weight: 800; + margin: .3rem 0 .65rem; +} + +.issued-token-card .token-value { + background: var(--rw-surface); + border: 1px solid var(--rw-border); + border-radius: 8px; + padding: .75rem; +} + +.default-vpd-form { + grid-template-columns: minmax(220px, 1.2fr) minmax(260px, 1.5fr) auto; +} + +.default-policy-summary, +.protected-function-card { + background: var(--rw-surface-muted); + border: 1px solid var(--rw-border); + border-radius: 8px; + display: grid; + gap: .2rem; + min-width: 0; + padding: .7rem .85rem; +} + +.default-policy-summary span, +.protected-function-card span { + color: var(--rw-muted); + font-size: .8rem; +} + +.advanced-guidance { + background: #fff8e8; + border: 1px solid #e7d29c; + border-radius: 8px; + padding: 1rem; +} + +.advanced-guidance h2 { + margin-bottom: .6rem; +} + +.advanced-guidance p { + margin: .3rem 0 0; +} + +.advanced-guidance ul { + margin: 0; + padding-left: 1.25rem; +} + +.advanced-guidance li + li { + margin-top: .45rem; +} + +.protected-function-card { + grid-template-columns: auto minmax(180px, auto) 1fr; + margin-top: 1rem; +} + +.filter-replace-form { + display: grid; + gap: .4rem; + grid-template-columns: minmax(150px, .8fr) minmax(220px, 1fr) auto; + min-width: 520px; +} + +@media (max-width: 920px) { + .journey-grid, + .macro-micro-grid, + .default-vpd-form { + grid-template-columns: 1fr; + } + + .guided-check-intro { + align-items: flex-start; + flex-direction: column; + } + + .protected-function-card { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .journey-card { + padding: .9rem; + } + + .result-metrics { + grid-template-columns: 1fr; + } +} diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index 18b92de..ffaa3c2 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -115,6 +115,44 @@ function initPersistentMenus() { }); } +function initQuestionPresets(root = document) { + root.querySelectorAll('.question-preset[data-target]').forEach((button) => { + if (button.dataset.presetReady === 'true') { + return; + } + button.dataset.presetReady = 'true'; + button.addEventListener('click', () => { + const target = document.getElementById(button.dataset.target || ''); + if (target) { + target.value = button.dataset.question || ''; + target.focus(); + } + }); + }); +} + +function initCopyButtons(root = document) { + root.querySelectorAll('[data-copy-source]').forEach((button) => { + if (button.dataset.copyReady === 'true') { + return; + } + button.dataset.copyReady = 'true'; + button.addEventListener('click', async () => { + const source = document.getElementById(button.dataset.copySource || ''); + const value = source?.textContent?.trim(); + if (!value) { + return; + } + try { + await navigator.clipboard.writeText(value); + button.textContent = '복사했습니다'; + } catch (error) { + button.textContent = '복사 실패 · 직접 선택하세요'; + } + }); + }); +} + function escapeHtml(value) { return value .replaceAll('&', '&') @@ -811,6 +849,8 @@ function initVpdTargetFilters() { document.addEventListener('DOMContentLoaded', () => { initPersistentMenus(); + initQuestionPresets(); + initCopyButtons(); renderMarkdownViews(); initVpdTargetFilters(); const master = document.getElementById('userRoleMaster'); diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index ddf1da7..429b635 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -1,64 +1,93 @@ - +
-
-

VPD 권한 백오피스

-

Oracle Database의 VPD/ORDS 기능을 권한 테이블과 운영 UI로 제어하고 검증합니다.

-
- -
+
+ 권한을 정하면 DB가 그대로 지킵니다 +

누가 어떤 데이터를 볼 수 있는지 설계하고, 실제 결과까지 확인하세요.

+

사용자·그룹·역할에 권한을 연결하면 Oracle VPD가 요청할 때마다 그 규칙을 읽어 허용된 행만 반환합니다. 별도 SQL 필터를 만드는 일은 예외적인 고급 작업입니다.

+
데이터 처리 오류가 발생했습니다.
-
- ./run.sh backoffice-support -
+
./run.sh backoffice-support
-
- - 보호 객체 - 0 +
+ + 1 +
+

1. 권한 설계

+

사용자와 그룹에 역할을 주고, 역할마다 볼 수 있는 객체·행·컬럼을 정합니다.

+ 권한 규칙 만들기 → +
- - 역할 - 0 + + 2 +
+

2. DB 보호 연결

+

보호할 TABLE/VIEW를 고르면 동적 VPD가 1단계의 권한체계를 자동으로 적용합니다.

+ 보호 객체 연결하기 → +
- - 토큰 - 0 + + 3 +
+

3. 토큰 발급

+

검증할 사용자를 선택해 토큰을 발급하고, 한 번만 보이는 원문을 복사합니다.

+ 테스트 토큰 발급하기 → +
+ + 4 +
+

4. 결과 확인

+

토큰으로 ORDS를 호출해 사용자·상속 역할과 실제로 보이는 행을 함께 확인합니다.

+ 권한 결과 확인하기 → +
+
+
+ +
+
+ MACRO · 전체 관점 +

권한체계가 유일한 기준입니다.

+

일상적인 변경은 사용자, 그룹, 역할, 권한 규칙에서만 합니다. 같은 규칙을 화면과 DB 필터에 이중으로 작성하지 않습니다.

+
+
+ MICRO · 실행 관점 +

VPD가 요청마다 조건을 계산합니다.

+

토큰에서 사용자를 찾고 직접 역할과 그룹 상속 역할을 합친 뒤, 객체의 ALLOW/DENY 및 행·열 규칙을 적용합니다. 근거는 마지막 검증 단계에서 확인합니다.

+
+
+ +
+ 보호 객체0 + 역할0 + 발급 이력0
-

최근 보호 객체

- 검증 실행 +
+

현재 검증 가능한 데이터

+

권한 규칙과 ORDS 경로가 등록된 보호 객체입니다.

+
+ 결과 확인
- - - - - - - - + - - - - - - - + + + +
OwnerObjectORDS PathStatus
데이터 객체검증 경로상태
ADMINCB_V_SEARCH_DOCUMENTSsearch/documentsenabled
등록된 보호 객체가 없습니다.ADMIN.OBJECTpath사용 가능
아직 검증할 보호 객체가 없습니다. 1단계에서 객체 권한을 먼저 등록하세요.
diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index 2bc51e7..d3a9b5d 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -16,27 +16,28 @@ VPD Backoffice
+
+ + -
-
- -
Chatbot Reasoning SSE 서비스 @@ -44,11 +45,10 @@
@@ -60,23 +60,29 @@
-
+
- Backoffice Tables - 권한 테이블 -

사용자, 그룹, 역할, 행 규칙, 컬럼 원문 허용을 저장합니다.

+ 1 · WHO / WHAT + 권한 설계 +

누가 어떤 데이터의 어느 행과 컬럼을 볼지 정합니다.

- Oracle Database - VPD Policy -

TABLE/VIEW에 policy function을 붙여 DB에서 행 접근을 제한합니다.

+ 2 · ENFORCE + DB 보호 연결 +

VPD가 저장된 권한체계를 매번 읽어 DB에서 행을 자동 제한합니다.

+
+
+
+ 3 · IDENTITY + 토큰 발급 +

검증할 사용자를 나타내는 일회성 원문 토큰을 준비합니다.

- Oracle REST Data Services - ORDS 서빙/검증 -

VPD가 적용된 TABLE/VIEW를 HTTP API로 호출해 결과를 확인합니다.

+ 4 · EVIDENCE + 결과 확인 +

ORDS를 호출해 그 사용자에게 실제로 보이는 데이터만 확인합니다.

diff --git a/src/main/resources/templates/fragments/probe-result.html b/src/main/resources/templates/fragments/probe-result.html index c539958..2d3df59 100644 --- a/src/main/resources/templates/fragments/probe-result.html +++ b/src/main/resources/templates/fragments/probe-result.html @@ -1,58 +1,98 @@ -
+
-

검증 결과

- SUCCESS +
+ 검증 결론 +

권한 결과

+
+ 검증 완료
-
- ERROR - message -
+

결과 설명

-
- Rows: 0 - - Masked: - -
+
+
+

적용된 사용자와 권한

+ ACTIVE +
+
+
+
사용자
user
+
+
직접 받은 역할
+
ROLE
+
없음
+
+
+
소속 그룹
+
GROUP
+
없음
+
+
+
그룹에서 상속된 역할
+
ROLE
+
없음
+
+
+
+
+ 토큰에서 사용자를 찾지 못해 역할과 그룹을 계산하지 않았습니다. +
+
-
-
-

Request Headers

-
{}
-
-
-

Request Payload

-
{}
-
-
-

Response Headers

-
{}
-
-
-

Response Body

-

-    
-
+
+
+

VPD가 적용된 실제 결과

+ ADMIN.OBJECT +
+
+
보이는 행0
+
+ NULL/마스킹으로 확인된 컬럼 + column + 없음 +
+
-
- - - - - - - - - - - -
column
value
-
+
+ + + + + + + +
column
value
+
+
+ +
+

다음에 할 일

+

다음 행동

+ +
+ +
+ 기술 상세: 오류 코드와 HTTP 요청·응답 보기 +
+ ERROR + message +
+
+

Request Headers

{}
+

Request Payload

{}
+

Response Headers

{}
+

Response Body

+
+
diff --git a/src/main/resources/templates/probe.html b/src/main/resources/templates/probe.html index 9c9bfd4..69ba362 100644 --- a/src/main/resources/templates/probe.html +++ b/src/main/resources/templates/probe.html @@ -1,96 +1,67 @@ - +
-

ORDS 검증

-

ORDS가 서빙하는 TABLE/VIEW 호출 결과로 VPD/Redaction 적용 여부를 확인합니다.

+

권한 결과 확인

+

한 사용자의 토큰으로 실제 데이터를 요청해, 설계한 권한이 DB에서 그대로 적용되는지 확인합니다.

+
+
+ 이번 단계에서 확인하는 것 +

“이 사용자는 이 데이터에서 무엇을 볼 수 있는가?”

+

토큰은 사용자를 찾는 열쇠입니다. 서버가 직접 역할과 그룹 상속 역할을 합치고, VPD가 저장된 행·열 규칙을 적용한 결과를 보여줍니다.

+
+ 테스트 토큰이 없나요? 먼저 발급하기 +
+
-

ORDS 호출

- MCP Reasoning으로 해석 +
+

검증할 사용자와 데이터 입력

+

등록 토큰 목록에서 고르는 대신 발급할 때 복사한 원문 하나만 사용합니다.

+
-
+ -
-
-
검증 결과가 여기에 표시됩니다.
+
+
+ 결과는 세 가지 순서로 설명합니다. +
    +
  1. 토큰이 어떤 사용자와 역할로 해석됐는지
  2. +
  3. VPD 적용 후 실제로 몇 행이 보였는지
  4. +
  5. 예상과 다를 때 어디를 확인해야 하는지
  6. +
+
diff --git a/src/main/resources/templates/tokens.html b/src/main/resources/templates/tokens.html index 820f7c4..63dd959 100644 --- a/src/main/resources/templates/tokens.html +++ b/src/main/resources/templates/tokens.html @@ -1,22 +1,35 @@ - +
-

Bearer Token

-

토큰은 발급 직후 한 번만 원문을 표시합니다.

+

테스트 토큰 발급

+

검증할 사용자를 나타내는 토큰을 만들고, 한 번만 표시되는 원문을 다음 단계에서 사용합니다.

+
+
-
-
새 토큰이 발급되었습니다. 이 값은 다시 표시되지 않습니다.
- -
+
+ 발급 완료 · 지금 한 번만 표시됩니다 +

이 토큰 원문을 복사한 뒤 권한 결과 확인으로 이동하세요.

+ +
+ + 권한 결과 확인으로 이동 +
+

화면을 떠난 뒤에는 원문을 다시 볼 수 없습니다. 잃어버리면 기존 토큰을 복구하지 말고 새로 발급하세요.

+
-

토큰 발급

+
+
+

1. 검증할 사용자 선택

+

토큰에는 사용자의 직접 역할과 그룹 상속 역할이 연결됩니다. 권한 자체를 토큰에 복사하지 않으므로 이후 권한 변경도 동적으로 반영됩니다.

+
+
- +
-

토큰 목록

+
+
+

발급 이력

+

보안상 prefix와 상태만 보관합니다. 이 목록에서는 원문을 복사하거나 복구할 수 없습니다.

+
+ 0 +
- - - - - - - - - - + - + - + + - - - +
IDUserPrefixExpiresRevoked
ID사용자용도식별용 Prefix만료회수
1 uservpd_live_x용도vpd_**** 2026 - @@ -67,9 +79,7 @@
등록된 토큰이 없습니다.
아직 발급된 토큰이 없습니다.
diff --git a/src/main/resources/templates/vpd-filter-policies.html b/src/main/resources/templates/vpd-filter-policies.html index c5b1d03..9526d22 100644 --- a/src/main/resources/templates/vpd-filter-policies.html +++ b/src/main/resources/templates/vpd-filter-policies.html @@ -1,12 +1,13 @@ - +
-

Filter Policy 관리

-

Filter function과 적용된 VPD policy를 관리합니다. TABLE/VIEW 개별/벌크 적용은 VPD 설정에서 처리합니다.

+ 고급 설정 +

별도 Filter 관리

+

일반 권한은 이 화면에서 만들지 않습니다. 권한체계로 표현할 수 없는 객체 전용 조건이 있을 때만 별도 함수를 추가합니다.

@@ -16,173 +17,161 @@
등록되었습니다.
처리할 수 없습니다.
-
+
-

Filter 등록/수정

- VPD 적용 화면 -
-
- - - - -
- - - - +
+ 기본 동작: 권한체계 자동 반영 +

대부분의 변경은 권한 규칙 화면에서 끝납니다.

- - - - - + 권한 규칙으로 이동 +
+
+
+

운영자가 관리하는 것

+

사용자, 그룹, 역할, 객체별 ALLOW/DENY, 행 조건과 컬럼 공개 범위를 관리합니다.

+
+
+

DB가 자동으로 하는 것

+

CB_AGENT_DOC_VPD_FILTER가 요청마다 그 권한을 합쳐 predicate를 만들고, 권한이나 유효한 컨텍스트가 없으면 1=0으로 차단합니다.

+
+
+
+ 보호되는 핵심 함수 + CB_AGENT_DOC_VPD_FILTER + 이 화면과 서버 양쪽에서 직접 덮어쓰기를 차단합니다. +
-
-

Filter 목록

- 0 -
-
- - - - - - - - - - - - - - - - - + + + + +
FunctionTypeSource
ADMIN.FILTERFUNCTION - -
-
-
- Source 보기를 누르면 현재 function source가 표시됩니다. -
-
- - - - -
- - - +
+ 고급: 별도 Filter 만들기 +
+

먼저 이 기준을 확인하세요.

+
    +
  • 사용 가능: 권한 테이블로 표현할 수 없는 객체 고유 조건을 별도 함수로 격리할 때
  • +
  • 사용 금지: 부서, 본인, 특정 값, 역할별 허용처럼 기존 권한 규칙이 지원하는 조건을 중복 작성할 때
  • +
  • 안전 원칙: 컨텍스트가 없거나 값이 잘못되면 반드시 1=0인 fail-closed 결과를 사용
  • +
  • 검증 원칙: 허용·거부·빈 컨텍스트·예상 밖 값 테스트를 만든 뒤 한 객체에 먼저 적용
  • +
+
+ + + + + + +
+ + +
+ + + +
+

설치된 Filter 함수

+ 0 +
+
+ + + + + + + + + + + - - - - - - -
Function구분Source / 변경
ADMIN.FILTER + 권한체계 기본 + 별도 Filter + + +
+
Source 보기를 누르면 현재 함수 내용을 표시합니다.
+
+ 이 함수는 권한체계의 핵심 실행 경로이므로 직접 수정할 수 없습니다. 권한 규칙을 변경하세요.
- - - -
등록된 VPD filter function 후보가 없습니다.
-
+
+ + + + + +
+
설치된 VPD 함수가 없습니다.
+
+
-
-

적용된 Policy 목록/수정

- 0 -
-
- - - - - - - - - - - - - - - - - - - - - - -
ObjectPolicyFunctionStatus수정
ADMIN.TABLEPOLICYADMIN.FILTERYES -
- - - - - - - - - -
-
등록된 VPD policy가 없습니다.
-
+
+ 고급: 적용된 Policy를 별도 Filter로 교체 +
+ 영향 범위 +

기존 policy를 삭제한 뒤 새 policy를 등록합니다. 기본 권한체계 경로에서 벗어나므로 대상 객체, 거부 조건, 복구할 기본 함수와 검증 토큰을 먼저 준비하세요.

+
+
+ + + + + + + + + + + + +
Object현재 Policy현재 Function구분교체
ADMIN.TABLEPOLICYADMIN.FILTER + 권한체계 자동 + 별도 Filter + +
+ + + + + + + + + +
+
적용된 VPD policy가 없습니다.
+
+
-
- diff --git a/src/main/resources/templates/vpd-policies.html b/src/main/resources/templates/vpd-policies.html index 1b287ab..562b6d5 100644 --- a/src/main/resources/templates/vpd-policies.html +++ b/src/main/resources/templates/vpd-policies.html @@ -5,8 +5,8 @@
-

VPD 설정

-

Oracle Database의 TABLE/VIEW에 VPD policy와 policy function/filter predicate를 적용합니다.

+

DB 보호 연결

+

권한 화면에서 만든 사용자·그룹·역할·행·열 규칙을 Oracle VPD가 실제 TABLE/VIEW에 적용하도록 연결합니다.

@@ -20,24 +20,29 @@
-

VPD 적용

- Filter Policy 관리 +
+ 기본 동작 +

권한체계 자동 적용

+
+ 권한 규칙 확인
- Filter Function + 사용자·그룹·역할 - Policy Template + 객체별 행·열 권한 - TABLE/VIEW 적용 + 동적 VPD + + 허용된 데이터만 반환
-

VPD는 개별 보호 객체 적용을 기본으로 하고, 같은 Policy Template을 스키마 TABLE/VIEW에 확장할 때만 벌크 적용을 사용합니다.

+

CB_AGENT_DOC_VPD_FILTER가 요청할 때마다 현재 권한체계를 읽습니다. 일상적인 권한 변경은 이 함수나 predicate가 아니라 권한 규칙에서 하세요.

-

VPD 적용 대상 TABLE/VIEW

-

Oracle DB catalog 기준의 TABLE/VIEW 목록입니다. ORDS handler는 여러 객체를 조합할 수 있으므로 이 표의 ORDS 경로는 사용 여부가 아니라 백오피스에 등록된 검증용 경로입니다.

+

보호할 수 있는 DB 객체

+

VPD가 붙었는지, 권한 규칙과 검증 경로가 준비됐는지 한곳에서 확인합니다. 아래 기본 적용에서는 객체만 선택하면 됩니다.

0
@@ -186,73 +191,40 @@
-

개별 적용

+
+ 권장 · 일반 작업 +

객체 하나에 권한체계 연결

+

객체만 선택하면 표준 동적 함수, SELECT 정책, 즉시 활성화를 서버가 안전한 기본값으로 적용합니다.

+
-
+ - - - -
- 벌크 적용 + 고급: 여러 객체에 권한체계 일괄 연결 +
+ 언제 사용하나요? +

한 스키마의 TABLE/VIEW가 모두 같은 권한체계를 사용해야 하고, 대상 목록을 먼저 검토했을 때만 사용합니다. 기존 policy가 있는 객체는 건너뜁니다.

+
+ - - - -
- Statement Types -
- -
-
-
- - -
-
- - -
-

벌크 적용은 선택한 Policy Template을 스키마의 TABLE/VIEW 목록에 적용합니다. Filter Function을 직접 선택하거나 predicate를 입력하지 않습니다.

- +

모든 대상에 표준 CB_PERMISSION_SELECT_POLICYCB_AGENT_DOC_VPD_FILTER를 적용합니다. 별도 predicate가 필요하면 운영·고급 메뉴의 가이드를 먼저 확인하세요.

+
-

VPD Policies

+
+

현재 적용된 DB 보호 정책

+

“권한체계 자동” 표시는 권한 규칙 변경을 요청 시점에 동적으로 반영하는 표준 정책입니다.

+
0
@@ -368,6 +298,8 @@ OWNER.FUNC
클릭하면 filter source를 봅니다.
+ 권한체계 자동 + 별도 Filter SELECT @@ -416,16 +348,5 @@
- diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResultTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResultTest.java new file mode 100644 index 0000000..6800333 --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/domain/probe/ProbeResultTest.java @@ -0,0 +1,100 @@ +package com.cloudhandson.vpdbackoffice.domain.probe; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ProbeResultTest { + + @Test + void explainsSuccessfulResultInPlainLanguage() { + ProbeResult result = new ProbeResult( + ProbeStatus.SUCCESS, + List.of("DOC_ID"), + List.of(Map.of("DOC_ID", 1), Map.of("DOC_ID", 2)), + 2, + List.of(), + null, + null, + null, + null, + null, + null + ); + + assertThat(result.successLike()).isTrue(); + assertThat(result.title()).contains("데이터를 볼 수 있습니다"); + assertThat(result.plainSummary()).contains("2개").contains("VPD"); + assertThat(result.nextAction()).contains("예상한 범위"); + } + + @Test + void treatsEmptyRowsAsAnEnforcedPermissionOutcome() { + ProbeResult result = new ProbeResult( + ProbeStatus.VPD_DENY_EMPTY_RESULT, + List.of(), + List.of(), + 0, + List.of(), + null, + null, + null, + null, + null, + null + ); + + assertThat(result.successLike()).isTrue(); + assertThat(result.title()).contains("볼 수 있는 행이 없습니다"); + assertThat(result.plainSummary()).contains("오류가 아닐 수 있습니다"); + assertThat(result.nextAction()).contains("유효 권한"); + } + + @Test + void givesActionableGuidanceForUnknownToken() { + ProbeResult result = ProbeResult.blocked( + ProbeStatus.TOKEN_NOT_FOUND, + "TOKEN_NOT_FOUND", + "토큰을 찾을 수 없습니다." + ); + + assertThat(result.successLike()).isFalse(); + assertThat(result.title()).contains("등록되지 않은 토큰"); + assertThat(result.plainSummary()).contains("DB").contains("원문"); + assertThat(result.nextAction()).contains("새 토큰").contains("발급"); + } + + @Test + void distinguishesInactiveTokenAndOrdsFailure() { + ProbeResult inactive = ProbeResult.blocked( + ProbeStatus.TOKEN_INACTIVE, + "TOKEN_INACTIVE", + "만료되었거나 회수된 토큰입니다." + ); + ProbeResult unavailable = ProbeResult.blocked( + ProbeStatus.ORDS_UNAVAILABLE, + "ORDS_UNAVAILABLE", + "연결할 수 없습니다." + ); + + assertThat(inactive.title()).contains("만료되었거나 회수"); + assertThat(inactive.nextAction()).contains("활성 토큰"); + assertThat(unavailable.title()).contains("ORDS"); + assertThat(unavailable.nextAction()).contains("권한 설정을 바꾸지 말고"); + } + + @Test + void identifiesBrokenCustomVpdFilterSeparatelyFromTokenErrors() { + ProbeResult result = ProbeResult.blocked( + ProbeStatus.VPD_FILTER_ERROR, + "VPD_FILTER_ERROR", + "ORA-28110" + ); + + assertThat(result.title()).contains("VPD Filter"); + assertThat(result.plainSummary()).contains("토큰과 사용자 권한은 확인"); + assertThat(result.nextAction()).contains("토큰이나 권한을 바꾸지 말고").contains("자동 Filter"); + } +} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifierTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifierTest.java index 864c5d1..eb413a7 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifierTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/ProbeErrorClassifierTest.java @@ -37,6 +37,14 @@ class ProbeErrorClassifierTest { .isEqualTo(ProbeStatus.ORDS_PATH_NOT_FOUND); } + @Test + void classifiesBrokenVpdPolicyFunction() { + assertThat(classifier.classify( + HttpStatus.INTERNAL_SERVER_ERROR, + "SQL Error Code 28110, Error Message: ORA-28110: The VPD policy function has error" + )).isEqualTo(ProbeStatus.VPD_FILTER_ERROR); + } + @Test void detectsOrdsConnectionRefused() { assertThat(classifier.isUnavailable(new ResourceAccessException( diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyServiceTest.java new file mode 100644 index 0000000..b4ae0b3 --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyServiceTest.java @@ -0,0 +1,96 @@ +package com.cloudhandson.vpdbackoffice.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.cloudhandson.vpdbackoffice.domain.vpd.VpdFunctionOption; +import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyFormOptions; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +class VpdPolicyServiceTest { + + @Test + void protectsTheDynamicPermissionFunctionFromManualOverwrite() { + RecordingJdbcTemplate jdbcTemplate = new RecordingJdbcTemplate(); + VpdPolicyService service = new VpdPolicyService(null, jdbcTemplate, null); + + assertThatThrownBy(() -> service.saveFilterFunction( + "ADMIN", + "CB_AGENT_DOC_VPD_FILTER", + "1=1" + )) + .isInstanceOf(AppException.class) + .hasMessageContaining("권한체계") + .hasMessageContaining("수정할 수 없습니다"); + + assertThat(jdbcTemplate.updateCount).isZero(); + } + + @Test + void defaultApplyAlwaysUsesTheDynamicPermissionFunction() { + RecordingJdbcTemplate jdbcTemplate = new RecordingJdbcTemplate(); + VpdPolicyService service = new VpdPolicyService(null, jdbcTemplate, null) { + @Override + public VpdPolicyFormOptions formOptions() { + return new VpdPolicyFormOptions( + List.of(), + List.of("ADMIN"), + List.of("ADMIN"), + List.of(new VpdFunctionOption("ADMIN", null, "CB_AGENT_DOC_VPD_FILTER", "FUNCTION")), + List.of(), + List.of("SELECT") + ); + } + }; + + service.createDefaultPermissionPolicy("ADMIN.DOCUMENTS"); + + assertThat(jdbcTemplate.lastSql).contains("DBMS_RLS.ADD_POLICY").contains("DBMS_RLS.DYNAMIC"); + assertThat(jdbcTemplate.lastArgs).containsExactly( + "ADMIN", + "DOCUMENTS", + "CB_PERMISSION_SELECT_POLICY", + "ADMIN", + "CB_AGENT_DOC_VPD_FILTER", + "SELECT" + ); + } + + @Test + void defaultApplyFailsClosedWhenPermissionFunctionIsNotInstalled() { + VpdPolicyService service = new VpdPolicyService(null, new RecordingJdbcTemplate(), null) { + @Override + public VpdPolicyFormOptions formOptions() { + return new VpdPolicyFormOptions( + List.of(), List.of(), List.of(), List.of(), List.of(), List.of("SELECT") + ); + } + }; + + assertThatThrownBy(() -> service.createDefaultPermissionPolicy("ADMIN.DOCUMENTS")) + .isInstanceOf(AppException.class) + .hasMessageContaining("동적 권한 필터") + .hasMessageContaining("설치"); + } + + private static class RecordingJdbcTemplate extends JdbcTemplate { + private String lastSql; + private Object[] lastArgs = new Object[0]; + private int updateCount; + + @Override + public T queryForObject(String sql, Class requiredType) { + return requiredType.cast("ADMIN"); + } + + @Override + public int update(String sql, Object... args) { + lastSql = sql; + lastArgs = args; + updateCount++; + return 1; + } + } +} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java new file mode 100644 index 0000000..6c8bd59 --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java @@ -0,0 +1,55 @@ +package com.cloudhandson.vpdbackoffice.web; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +class GuidedFlowTemplateTest { + + @Test + void dashboardExplainsTheFourStepPermissionJourney() throws IOException { + String html = template("dashboard.html"); + + assertThat(html) + .contains("1. 권한 설계") + .contains("2. DB 보호 연결") + .contains("3. 토큰 발급") + .contains("4. 결과 확인"); + } + + @Test + void probeUsesOneTokenInputAndHidesTechnicalExchangeByDefault() throws IOException { + String probe = template("probe.html"); + String result = template("fragments/probe-result.html"); + + assertThat(probe).contains("name=\"bearerToken\"").doesNotContain("name=\"tokenKeyId\""); + assertThat(result) + .contains("적용된 사용자와 권한") + .contains("다음에 할 일") + .contains("